pax_global_header00006660000000000000000000000064143704136560014523gustar00rootroot0000000000000052 comment=8f79d51e6fbd1031e4f0ed31cd60c3c3333850e2 napari-0.5.0a1/000077500000000000000000000000001437041365600132215ustar00rootroot00000000000000napari-0.5.0a1/.devcontainer/000077500000000000000000000000001437041365600157605ustar00rootroot00000000000000napari-0.5.0a1/.devcontainer/Dockerfile000066400000000000000000000007061437041365600177550ustar00rootroot00000000000000# See here for image contents: https://github.com/microsoft/vscode-dev-containers/tree/v0.233.0/containers/python-3-miniconda/.devcontainer/base.Dockerfile FROM mcr.microsoft.com/vscode/devcontainers/miniconda:0-3 RUN apt-get update && export DEBIAN_FRONTEND=noninteractive \ && apt-get -y install --no-install-recommends \ libxcb-icccm4 libxcb-image0 libxcb-keysyms1 libxcb-randr0 \ libxcb-render-util0 libxcb-xinerama0 libxkbcommon-x11-0napari-0.5.0a1/.devcontainer/add-notice.sh000066400000000000000000000015441437041365600203270ustar00rootroot00000000000000# Display a notice when not running in GitHub Codespaces cat << 'EOF' > /usr/local/etc/vscode-dev-containers/conda-notice.txt When using "conda" from outside of GitHub Codespaces, note the Anaconda repository contains restrictions on commercial use that may impact certain organizations. See https://aka.ms/vscode-remote/conda/miniconda EOF notice_script="$(cat << 'EOF' if [ -t 1 ] && [ "${IGNORE_NOTICE}" != "true" ] && [ "${TERM_PROGRAM}" = "vscode" ] && [ "${CODESPACES}" != "true" ] && [ ! -f "$HOME/.config/vscode-dev-containers/conda-notice-already-displayed" ]; then cat "/usr/local/etc/vscode-dev-containers/conda-notice.txt" mkdir -p "$HOME/.config/vscode-dev-containers" ((sleep 10s; touch "$HOME/.config/vscode-dev-containers/conda-notice-already-displayed") &) fi EOF )" echo "${notice_script}" | tee -a /etc/bash.bashrc >> /etc/zsh/zshrc napari-0.5.0a1/.devcontainer/devcontainer.json000066400000000000000000000027621437041365600213430ustar00rootroot00000000000000// For format details, see https://aka.ms/devcontainer.json. For config options, see the README at: // https://github.com/microsoft/vscode-dev-containers/tree/v0.233.0/containers/python-3-miniconda { "name": "Miniconda (Python 3)", "build": { "context": "..", "dockerfile": "Dockerfile", "args": { "NODE_VERSION": "none" } }, // Set *default* container specific settings.json values on container create. "settings": { "python.defaultInterpreterPath": "/opt/conda/bin/python", "python.linting.enabled": true, "python.linting.mypyEnabled": true, "python.linting.flake8Enabled": true, "python.formatting.blackPath": "/opt/conda/bin/black", "python.linting.flake8Path": "/opt/conda/bin/flake8", "python.linting.mypyPath": "/opt/conda/bin/mypy", }, // Add the IDs of extensions you want installed when the container is created. "extensions": [ "ms-python.python", "ms-python.vscode-pylance" ], // Use 'forwardPorts' to make a list of ports inside the container available locally. "forwardPorts": [5900, 5901, 6080], // Use 'postCreateCommand' to run commands after the container is created. "postCreateCommand": "pip install -U pip && pip install -e .[pyqt, dev] && pre-commit install", // Comment out to connect as root instead. More info: https://aka.ms/vscode-remote/containers/non-root. "remoteUser": "vscode", "features": { "git": "os-provided", "github-cli": "latest", "desktop-lite": { "password": "napari", "webPort": "6080", "vncPort": "5901" } } } napari-0.5.0a1/.env_sample000066400000000000000000000030441437041365600153540ustar00rootroot00000000000000# TO USE THIS FILE RENAME IT TO '.env' # NOTE! Using this file requires `pip install python-dotenv` # ────────────────────────────────────────────────────────────── # Event Debugging, controls events.debugging.EventDebugSettings: NAPARI_DEBUG_EVENTS=0 # these are strict json, use double quotes # if INCLUDE_X is used, EXCLUDE_X is ignored. EVENT_DEBUG_INCLUDE_EMITTERS = [] # e.g. ["Points", "Selection"] EVENT_DEBUG_EXCLUDE_EMITTERS = ["TransformChain", "Context"] EVENT_DEBUG_INCLUDE_EVENTS = [] # e.g. ["set_data", "changed"] EVENT_DEBUG_EXCLUDE_EVENTS = ["status", "position"] EVENT_DEBUG_STACK_DEPTH = 20 # ────────────────────────────────────────────────────────────── # _PYTEST_RAISE=1 will prevent pytest from handling exceptions. # Use with a debugger that's set to break on "unhandled exceptions". # https://github.com/pytest-dev/pytest/issues/7409 _PYTEST_RAISE=0 # set to 1 to simulate Continuous integration tests CI=0 # set to 1 to allow tests that pop up a viewer or widget NAPARI_POPUP_TESTS=0 # ────────────────────────────────────────────────────────────── # You can also use any of the (nested) fields from NapariSettings # for example: # NAPARI_APPEARANCE_THEME='light' napari-0.5.0a1/.gitattributes000066400000000000000000000000451437041365600161130ustar00rootroot00000000000000napari_gui/_version.py export-subst napari-0.5.0a1/.github/000077500000000000000000000000001437041365600145615ustar00rootroot00000000000000napari-0.5.0a1/.github/CODEOWNERS000066400000000000000000000000001437041365600161420ustar00rootroot00000000000000napari-0.5.0a1/.github/FUNDING.yml000066400000000000000000000000761437041365600164010ustar00rootroot00000000000000github: numfocus custom: http://numfocus.org/donate-to-napari napari-0.5.0a1/.github/ISSUE_TEMPLATE/000077500000000000000000000000001437041365600167445ustar00rootroot00000000000000napari-0.5.0a1/.github/ISSUE_TEMPLATE/bug_report.md000066400000000000000000000012431437041365600214360ustar00rootroot00000000000000--- name: "\U0001F41B Bug Report" about: Submit a bug report to help us improve napari title: '' labels: bug assignees: '' --- ## 🐛 Bug ## To Reproduce Steps to reproduce the behavior: 1. 2. 3. ## Expected behavior ## Environment - Please copy and paste the information at napari info option in help menubar here: - Any other relevant information: ## Additional context napari-0.5.0a1/.github/ISSUE_TEMPLATE/config.yml000066400000000000000000000006731437041365600207420ustar00rootroot00000000000000# Ref: https://help.github.com/en/github/building-a-strong-community/configuring-issue-templates-for-your-repository#configuring-the-template-chooser blank_issues_enabled: true # default contact_links: - name: 🤷💻 napari forum url: https://forum.image.sc/tag/napari about: | Please ask general "how do I ... ?" questions over at image.sc - name: '💬 napari @ zulip' url: https://napari.zulipchat.com/ about: Chat with devs napari-0.5.0a1/.github/ISSUE_TEMPLATE/design_related.md000066400000000000000000000032301437041365600222350ustar00rootroot00000000000000--- name: "\U00002728 Design Related" about: Capture needs specific to design and user experience research title: '' labels: design assignees: liaprins-czi --- ### Overview of design need - Is there an existing GitHub issue this design work pertains to? If so, provide a link to it - Also link to any specific comments or threads where the problem to be solved by design is mentioned - In a sentence or two, describe the problem to be solved for users ### What level of design is needed? (Choose all that apply) _This section may be updated by the designer / UX researcher working on this issue_ - [ ] **User experience research:** high-level recommendation/exploration of user needs, design heuristics, and / or best practices to inform a design experience (Use this option when you feel there’s a challenge to be solved, but you’re curious about what the experience should be — may involve research studies to understand challenges/opportunities for design) - [ ] **Information flow / conceptual:** organizing and structuring of information flow and content, including layout on screen or across multiple steps - [ ] **Visual:** creating mockups, icons, etc (If choosing this level alone, it means that the content to be mocked up and its organization is already known and specified) ### Is design a blocker? - [ ] **Yes:** engineering cannot proceed without a design first - [ ] **No:** engineering can create a first version, and design can come in later to iterate and refine If selecting **Yes**, how much design input is needed to unblock engineering? For example, is a full, final visual design needed, or just a recommendation of which conceptual direction to go? napari-0.5.0a1/.github/ISSUE_TEMPLATE/documentation.md000066400000000000000000000003771437041365600221460ustar00rootroot00000000000000--- name: "\U0001F4DA Documentation" about: Report an issue with napari documentation title: '' labels: documentation assignees: '' --- ## 📚 Documentation napari-0.5.0a1/.github/ISSUE_TEMPLATE/feature_request.md000066400000000000000000000013651437041365600224760ustar00rootroot00000000000000--- name: "\U0001F680 Feature Request" about: Submit a proposal/request for a new napari feature title: '' labels: feature assignees: '' --- ## 🚀 Feature ## Motivation ## Pitch ## Alternatives ## Additional context napari-0.5.0a1/.github/ISSUE_TEMPLATE/task.md000066400000000000000000000003031437041365600202240ustar00rootroot00000000000000--- name: "\U0001F9F0 Task" about: Submit a proposal/request for a new napari feature title: '' labels: task assignees: '' --- ## 🧰 Task napari-0.5.0a1/.github/PULL_REQUEST_TEMPLATE.md000066400000000000000000000033511437041365600203640ustar00rootroot00000000000000# Description ## Type of change - [ ] Bug-fix (non-breaking change which fixes an issue) - [ ] New feature (non-breaking change which adds functionality) - [ ] Breaking change (fix or feature that would cause existing functionality to not work as expected) - [ ] This change requires a documentation update # References # How has this been tested? - [ ] example: the test suite for my feature covers cases x, y, and z - [ ] example: all tests pass with my change - [ ] example: I check if my changes works with both PySide and PyQt backends as there are small differences between the two Qt bindings. ## Final checklist: - [ ] My PR is the minimum possible work for the desired functionality - [ ] I have commented my code, particularly in hard-to-understand areas - [ ] I have made corresponding changes to the documentation - [ ] I have added tests that prove my fix is effective or that my feature works - [ ] If I included new strings, I have used `trans.` to make them localizable. For more information see our [translations guide](https://napari.org/developers/translations.html). napari-0.5.0a1/.github/TEST_FAIL_TEMPLATE.md000066400000000000000000000006251437041365600177530ustar00rootroot00000000000000--- title: "{{ env.TITLE }}" labels: [bug] --- The {{ workflow }} workflow failed on {{ date | date("YYYY-MM-DD HH:mm") }} UTC The most recent failing test was on {{ env.PLATFORM }} py{{ env.PYTHON }} {{ env.BACKEND }} with commit: {{ sha }} Full run: https://github.com/napari/napari/actions/runs/{{ env.RUN_ID }} (This post will be updated if another test fails, as long as this issue remains open.) napari-0.5.0a1/.github/dependabot.yml000066400000000000000000000006241437041365600174130ustar00rootroot00000000000000# https://docs.github.com/github/administering-a-repository/configuration-options-for-dependency-updates version: 2 updates: - package-ecosystem: "github-actions" directory: "/" schedule: interval: "monthly" commit-message: prefix: "ci(dependabot):" - package-ecosystem: "pip" directory: "/resources" schedule: interval: "weekly" target-branch: "develop" napari-0.5.0a1/.github/labeler.yml000066400000000000000000000006321437041365600167130ustar00rootroot00000000000000# See: .github/workflows/labeler.yml and https://github.com/marketplace/actions/labeler design: - 'napari/_qt/qt_resources/**/*' preferences: - 'napari/_qt/**/*/preferences_dialog.py' - 'napari/settings/**/*.py' qt: - 'napari/_qt/**/*.py' - 'napari/_qt/**/*.py' - 'napari/qt/**/*.py' - 'napari/qt/**/*.py' task: - '.github/**/*' tests: - '**/*/_tests/**/*.py' vispy: - 'napari/_vispy' napari-0.5.0a1/.github/missing_translations.md000066400000000000000000000011261437041365600213550ustar00rootroot00000000000000--- title: "[Automatic issue] Missing `_.trans()`." labels: "good first issue" --- It looks like one of our test cron detected missing translations. You can see the latest output [here](https://github.com/napari/napari/actions/workflows/test_translations.yml). There are likely new strings to either ignore, or to internationalise. You can also Update the cron script to update this issue with better information as well. Note that this issue will be automatically updated if kept open, or a new one will be created when necessary, if no open issue is found and new `_.trans` call are missing. napari-0.5.0a1/.github/workflows/000077500000000000000000000000001437041365600166165ustar00rootroot00000000000000napari-0.5.0a1/.github/workflows/auto_author_assign.yml000066400000000000000000000004461437041365600232430ustar00rootroot00000000000000# https://github.com/marketplace/actions/auto-author-assign name: 'Auto Author Assign' on: pull_request_target: types: [opened, reopened] permissions: pull-requests: write jobs: assign-author: runs-on: ubuntu-latest steps: - uses: toshimaru/auto-author-assign@v1.6.1 napari-0.5.0a1/.github/workflows/benchmarks.yml000066400000000000000000000156361437041365600214710ustar00rootroot00000000000000# This CI configuration for relative benchmarks is based on the research done # for scikit-image's implementation available here: # https://github.com/scikit-image/scikit-image/blob/9bdd010a8/.github/workflows/benchmarks.yml#L1 # Blog post with the rationale: https://labs.quansight.org/blog/2021/08/github-actions-benchmarks/ name: Benchmarks on: pull_request: types: [labeled] schedule: - cron: "6 6 * * 0" # every sunday workflow_dispatch: inputs: base_ref: description: "Baseline commit or git reference" required: true contender_ref: description: "Contender commit or git reference" required: true # This is the main configuration section that needs to be fine tuned to napari's needs # All the *_THREADS options is just to make the benchmarks more robust by not using parallelism env: OPENBLAS_NUM_THREADS: "1" MKL_NUM_THREADS: "1" OMP_NUM_THREADS: "1" ASV_OPTIONS: "--split --show-stderr --factor 1.5 --attribute timeout=300" # --split -> split final reports in tables # --show-stderr -> print tracebacks if errors occur # --factor 1.5 -> report anomaly if tested timings are beyond 1.5x base timings # --attribute timeout=300 -> override timeout attribute (default=60s) to allow slow tests to run # see https://asv.readthedocs.io/en/stable/commands.html#asv-continuous for more details! jobs: benchmark: if: ${{ github.event.label.name == 'run-benchmarks' && github.event_name == 'pull_request' || github.event_name == 'schedule' || github.event_name == 'workflow_dispatch' }} name: ${{ matrix.benchmark-name }} runs-on: ${{ matrix.runs-on }} strategy: fail-fast: false matrix: include: - benchmark-name: Qt asv-command: continuous selection-regex: "^benchmark_qt_.*" runs-on: macos-latest # Qt tests run on macOS to avoid using Xvfb business # xvfb makes everything run, but some tests segfault :shrug: # Fortunately, macOS graphics stack does not need xvfb! - benchmark-name: non-Qt asv-command: continuous selection-regex: "^benchmark_(?!qt_).*" runs-on: ubuntu-latest steps: # We need the full repo to avoid this issue # https://github.com/actions/checkout/issues/23 - uses: actions/checkout@v3 with: fetch-depth: 0 - uses: actions/setup-python@v4 name: Install Python with: python-version: "3.9" cache-dependency-path: setup.cfg - uses: tlambert03/setup-qt-libs@v1 - name: Setup asv run: python -m pip install asv virtualenv - uses: octokit/request-action@v2.x id: latest_release with: route: GET /repos/{owner}/{repo}/releases/latest owner: napari repo: napari env: GITHUB_TOKEN: ${{ secrets.GITHUB_TOKEN }} - name: Run ${{ matrix.benchmark-name }} benchmarks id: run_benchmark env: # asv will checkout commits, which might contain LFS artifacts; ignore those errors since # they are probably just documentation PNGs not needed here anyway GIT_LFS_SKIP_SMUDGE: 1 run: | set -euxo pipefail # ID this runner asv machine --yes if [[ $GITHUB_EVENT_NAME == pull_request ]]; then EVENT_NAME="PR #${{ github.event.pull_request.number }}" BASE_REF=${{ github.event.pull_request.base.sha }} CONTENDER_REF=${GITHUB_SHA} echo "Baseline: ${BASE_REF} (${{ github.event.pull_request.base.label }})" echo "Contender: ${CONTENDER_REF} (${{ github.event.pull_request.head.label }})" elif [[ $GITHUB_EVENT_NAME == schedule ]]; then EVENT_NAME="cronjob" BASE_REF="${{ fromJSON(steps.latest_release.outputs.data).target_commitish }}" CONTENDER_REF="${GITHUB_SHA}" echo "Baseline: ${BASE_REF} (${{ fromJSON(steps.latest_release.outputs.data).tag_name }})" echo "Contender: ${CONTENDER_REF} (current main)" elif [[ $GITHUB_EVENT_NAME == workflow_dispatch ]]; then EVENT_NAME="manual trigger" BASE_REF="${{ github.event.inputs.base_ref }}" CONTENDER_REF="${{ github.event.inputs.contender_ref }}" echo "Baseline: ${BASE_REF} (workflow input)" echo "Contender: ${CONTENDER_REF} (workflow input)" fi echo "EVENT_NAME=$EVENT_NAME" >> $GITHUB_ENV echo "BASE_REF=$BASE_REF" >> $GITHUB_ENV echo "CONTENDER_REF=$CONTENDER_REF" >> $GITHUB_ENV # Run benchmarks for current commit against base asv continuous $ASV_OPTIONS -b '${{ matrix.selection-regex }}' ${BASE_REF} ${CONTENDER_REF} \ | sed -E "/Traceback | failed$|PERFORMANCE DECREASED/ s/^/::error:: /" \ | tee asv_continuous.log # Report and export results for subsequent steps if grep "Traceback \|failed\|PERFORMANCE DECREASED" asv_continuous.log > /dev/null ; then exit 1 fi - name: Report Failures as Issue if: ${{ (github.event_name == 'schedule' || github.event_name == 'workflow_dispatch') && failure() }} uses: JasonEtco/create-an-issue@v2 env: GITHUB_TOKEN: ${{ secrets.GITHUB_TOKEN }} PLATFORM: ${{ matrix.runs-on }} PYTHON: "3.9" BACKEND: ${{ matrix.benchmark-name }} RUN_ID: ${{ github.run_id }} TITLE: "[test-bot] Benchmark tests failing" with: filename: .github/TEST_FAIL_TEMPLATE.md update_existing: true - name: Add more info to artifact if: always() run: | # Copy the full `asv continuous` log cp asv_continuous.log .asv/results/asv_continuous_${{ matrix.benchmark-name }}.log # ensure that even if this isn't a PR, the benchmark_report workflow can run without error touch .asv/results/message_${{ matrix.benchmark-name }}.txt # Add the message that might be posted as a comment on the PR # We delegate the actual comment to `benchmarks_report.yml` due to # potential token permissions issues if [[ $GITHUB_EVENT_NAME == pull_request ]]; then echo "${{ github.event.pull_request.number }}" > .asv/results/pr_number echo \ "The ${{ matrix.benchmark-name }} benchmark run requested by $EVENT_NAME ($CONTENDER_REF vs $BASE_REF) has" \ "finished with status '${{ steps.run_benchmark.outcome }}'. See the" \ "[CI logs and artifacts](||BENCHMARK_CI_LOGS_URL||) for further details." \ > .asv/results/message_${{ matrix.benchmark-name }}.txt fi - uses: actions/upload-artifact@v3 if: always() with: name: asv-benchmark-results-${{ github.run_id }}-${{ github.run_number }}-${{ github.run_attempt }} path: .asv/results napari-0.5.0a1/.github/workflows/benchmarks_report.yml000066400000000000000000000053311437041365600230530ustar00rootroot00000000000000# Report benchmark results to the PR # We need a dual workflow to make sure the token has the needed permissions to post comments # See https://stackoverflow.com/a/71683208 for more details # When this workflow is triggered, it pulls the latest version of this file on # the default branch. Changes to this file won't be reflected until after the # PR is merged. # https://docs.github.com/en/actions/using-workflows/events-that-trigger-workflows#workflow_run name: "Benchmarks - Report" on: workflow_run: workflows: [Benchmarks] types: - completed jobs: download: runs-on: ubuntu-latest steps: - name: "Download artifact" uses: actions/github-script@v6 with: script: | let allArtifacts = await github.rest.actions.listWorkflowRunArtifacts({ owner: context.repo.owner, repo: context.repo.repo, run_id: context.payload.workflow_run.id, }); let artifactName = `asv-benchmark-results-${context.payload.workflow_run.id}-${context.payload.workflow_run.run_number}-${context.payload.workflow_run.run_attempt}` let matchArtifact = allArtifacts.data.artifacts.filter((artifact) => { return artifact.name == artifactName })[0]; let download = await github.rest.actions.downloadArtifact({ owner: context.repo.owner, repo: context.repo.repo, artifact_id: matchArtifact.id, archive_format: 'zip', }); let fs = require('fs'); fs.writeFileSync(`${process.env.GITHUB_WORKSPACE}/asv_results.zip`, Buffer.from(download.data)); - name: Unzip and prepare data run: | unzip asv_results.zip # combine the Qt and non-Qt messages cat message_Qt.txt message_non-Qt.txt > message.txt - name: Replace URLs run: | sed -i 's@||BENCHMARK_CI_LOGS_URL||@${{ github.event.workflow_run.html_url }}@g' message.txt - name: Collect PR number if available run: | if [[ -f pr_number ]]; then echo "PR_NUMBER=$(cat pr_number)" >> $GITHUB_ENV fi - name: "Comment on PR" if: env.PR_NUMBER != '' uses: actions/github-script@v6 with: github-token: ${{ secrets.GITHUB_TOKEN }} script: | let fs = require('fs'); let issue_number = Number(process.env.PR_NUMBER); let body = fs.readFileSync('message.txt', 'utf8'); await github.rest.issues.createComment({ owner: context.repo.owner, repo: context.repo.repo, issue_number: issue_number, body: body, }); napari-0.5.0a1/.github/workflows/build_docs.yml000066400000000000000000000032241437041365600214510ustar00rootroot00000000000000name: Build PR Docs on: pull_request: branches: - main push: branches: - docs tags: - 'v*' workflow_dispatch: jobs: build-and-upload: name: Build & Upload Artifact runs-on: ubuntu-latest steps: - name: Clone docs repo uses: actions/checkout@v3 with: path: docs # place in a named directory repository: napari/docs - name: Clone main repo uses: actions/checkout@v3 with: path: napari-repo - name: Copy examples to docs folder run: | cp -R napari-repo/examples docs - uses: actions/setup-python@v4 with: python-version: 3.9 cache-dependency-path: setup.cfg - uses: tlambert03/setup-qt-libs@v1 - name: Install Dependencies run: | python -m pip install --upgrade pip python -m pip install "napari-repo/[all]" - name: Testing run: | python -c 'import napari; print(napari.__version__)' python -c 'import napari.layers; print(napari.layers.__doc__)' - name: Build Docs uses: aganders3/headless-gui@v1 env: GOOGLE_CALENDAR_ID: ${{ secrets.GOOGLE_CALENDAR_ID }} GOOGLE_CALENDAR_API_KEY: ${{ secrets.GOOGLE_CALENDAR_API_KEY }} with: # the napari-docs repo is cloned into a docs/ folder, hence the # invocation below. Locally, you should simply run make docs run: make -C docs docs GALLERY_PATH=../examples/ - name: Upload artifact uses: actions/upload-artifact@v3 with: name: docs path: docs/docs/_build napari-0.5.0a1/.github/workflows/deploy_docs.yml000066400000000000000000000011051437041365600216420ustar00rootroot00000000000000name: Build Docs on: push: branches: - main workflow_dispatch: concurrency: group: docs-${{ github.ref }} cancel-in-progress: true jobs: build-napari-docs: name: Build docs on napari/docs runs-on: ubuntu-latest steps: - name: Trigger workflow and wait uses: convictional/trigger-workflow-and-wait@v1.6.5 with: owner: napari repo: docs github_token: ${{ secrets.ACTIONS_DEPLOY_DOCS }} workflow_file_name: deploy_docs.yml trigger_workflow: true wait_workflow: true napari-0.5.0a1/.github/workflows/docker-singularity-publish.yml000066400000000000000000000102241437041365600246230ustar00rootroot00000000000000name: Docker and Singularity build # This workflow uses actions that are not certified by GitHub. # They are provided by a third-party and are governed by # separate terms of service, privacy policy, and support # documentation. on: workflow_dispatch: # schedule: # - cron: '31 0 * * *' push: branches: [ main ] # Publish semver tags as releases. tags: [ 'v*.*.*' ] env: # Use docker.io for Docker Hub if empty REGISTRY: ghcr.io jobs: build1: runs-on: ubuntu-latest permissions: contents: read packages: write strategy: fail-fast: false matrix: include: - recipe: Docker target: napari image-name: napari/napari - recipe: Docker target: napari-xpra image-name: napari/napari-xpra steps: - name: Checkout repository uses: actions/checkout@v3 # Login against a Docker registry except on PR # https://github.com/docker/login-action - name: Log into registry ${{ env.REGISTRY }} if: github.event_name != 'pull_request' uses: docker/login-action@f4ef78c080cd8ba55a85445d5b36e214a81df20a with: registry: ${{ env.REGISTRY }} username: ${{ github.actor }} password: ${{ secrets.GITHUB_TOKEN }} # Extract metadata (tags, labels) for Docker # https://github.com/docker/metadata-action # https://github.com/docker/build-push-action/blob/master/docs/advanced/tags-labels.md - name: Extract Docker metadata id: meta uses: docker/metadata-action@v4 with: # list of Docker images to use as base name for tags images: ${{ env.REGISTRY }}/${{ matrix.image-name }} # images: | # name/app # ghcr.io/username/app # generate Docker tags based on the following events/attributes tags: | type=schedule type=ref,event=branch type=ref,event=pr type=semver,pattern={{version}} type=semver,pattern={{major}}.{{minor}} type=semver,pattern={{major}} type=sha latest # oras://ghcr.io is tagged latest too, and seems to override the docker://ghcr.io tag -> race condition? # Build and push Docker image with Buildx (don't push on PR) # https://github.com/docker/build-push-action - name: Build and push Docker image uses: docker/build-push-action@v4 with: context: . push: ${{ github.event_name != 'pull_request' }} tags: ${{ steps.meta.outputs.tags }} labels: ${{ steps.meta.outputs.labels }} file: "dockerfile" target: ${{ matrix.target }} # ---- build2: needs: build1 runs-on: ubuntu-latest container: image: quay.io/singularity/docker2singularity:v3.10.0 options: --privileged permissions: contents: read packages: write strategy: fail-fast: false matrix: recipe: ["Singularity"] steps: - name: Checkout repository uses: actions/checkout@v3 - name: Continue if Singularity Recipe Exists run: | if [[ -f "${{ matrix.recipe }}" ]]; then echo "keepgoing=true" >> $GITHUB_ENV fi - name: Build Container if: ${{ env.keepgoing == 'true' }} env: recipe: ${{ matrix.recipe }} run: | ls if [ -f "${{ matrix.recipe }}" ]; then singularity build container.sif ${{ matrix.recipe }} tag=latest fi # Build the container and name by tag echo "Tag is $tag." echo "tag=$tag" >> $GITHUB_ENV - name: Login and Deploy Container if: (github.event_name != 'pull_request') env: keepgoing: ${{ env.keepgoing }} run: | if [[ "${keepgoing}" == "true" ]]; then echo ${{ secrets.GITHUB_TOKEN }} | singularity remote login -u ${{ secrets.GHCR_USERNAME }} --password-stdin oras://ghcr.io singularity push container.sif oras://ghcr.io/${GITHUB_REPOSITORY}:${tag} fi napari-0.5.0a1/.github/workflows/labeler.yml000066400000000000000000000003761437041365600207550ustar00rootroot00000000000000# https://github.com/marketplace/actions/labeler name: "Pull Request Labeler" on: - pull_request_target jobs: triage: runs-on: ubuntu-latest steps: - uses: actions/labeler@main with: repo-token: "${{ secrets.GITHUB_TOKEN }}" napari-0.5.0a1/.github/workflows/make_bundle.yml000066400000000000000000000077311437041365600216170ustar00rootroot00000000000000on: push: # Sequence of patterns matched against refs/tags tags: - "v*" # Push events to matching v*, i.e. v1.0, v20.15.10 branches: - main pull_request: branches: - main paths-ignore: - 'docs/**' schedule: - cron: "0 0 * * *" # Allows you to run this workflow manually from the Actions tab workflow_dispatch: concurrency: group: create-bundle-${{ github.ref }} cancel-in-progress: true name: Create Bundle jobs: bundle: name: Bundle ${{ matrix.platform }} runs-on: ${{ matrix.platform }} if: github.repository == 'napari/napari' env: GITHUB_TOKEN: ${{ github.token }} DISPLAY: ":99.0" strategy: fail-fast: false matrix: include: - platform: ubuntu-18.04 python-version: "3.9" - platform: macos-latest python-version: "3.9" - platform: windows-latest python-version: "3.8" steps: - name: Checkout code uses: actions/checkout@v3 with: fetch-depth: 0 - name: Install Python uses: actions/setup-python@v4 with: python-version: ${{ matrix.python-version }} cache-dependency-path: setup.cfg - name: Install Dependencies run: | python -m pip install --upgrade pip python -m pip install -e '.[bundle_build]' - name: get tag / arch-suffix shell: bash run: | VER=`python bundle.py --version` echo "version=${VER}" >> $GITHUB_ENV echo "Version: $VER" ARCH_SUFFIX=`python bundle.py --arch` echo "arch-suffix=${ARCH_SUFFIX}" >> $GITHUB_ENV echo "Machine: ${ARCH_SUFFIX}" - name: Make Bundle (Linux) if: runner.os == 'Linux' run: | sudo apt-get update sudo apt-get install -y libdbus-1-3 libxkbcommon-x11-0 libxcb-icccm4 \ libxcb-image0 libxcb-keysyms1 libxcb-randr0 libxcb-render-util0 \ libxcb-xinerama0 libxcb-xfixes0 libxcb-shape0 libqt5gui5 xvfb-run --auto-servernum python bundle.py - name: Make Bundle (Windows & MacOS) if: runner.os != 'Linux' run: python bundle.py - name: Upload Artifact uses: actions/upload-artifact@v3 with: name: napari-${{ env.version }}-${{ runner.os }}-${{ env.arch-suffix }}.zip path: napari-${{ env.version }}-${{ runner.os }}-${{ env.arch-suffix }}.zip - name: Get Release if: startsWith(github.ref, 'refs/tags/v') id: get_release uses: bruceadams/get-release@v1.3.2 - name: Upload Release Asset if: startsWith(github.ref, 'refs/tags/v') uses: actions/upload-release-asset@v1 with: upload_url: ${{ steps.get_release.outputs.upload_url }} asset_path: napari-${{ env.version }}-${{ runner.os }}-${{ env.arch-suffix }}.zip asset_name: napari-${{ env.version }}-${{ runner.os }}-${{ env.arch-suffix }}.zip asset_content_type: application/zip - name: Upload Nightly Build Asset if: ${{ github.event_name == 'schedule' }} uses: WebFreak001/deploy-nightly@v2.0.0 env: GITHUB_TOKEN: ${{ secrets.GITHUB_TOKEN }} with: # nightly build release from https://api.github.com/repos/napari/napari/releases upload_url: https://uploads.github.com/repos/napari/napari/releases/34273071/assets{?name,label} release_id: 34273071 asset_path: napari-${{ env.version }}-${{ runner.os }}-${{ env.arch-suffix }}.zip asset_name: napari-${{ runner.os }}-${{ env.arch-suffix }}.zip asset_content_type: application/zip max_releases: 1 - name: Update latest tag uses: EndBug/latest-tag@latest if: ${{ github.event_name == 'schedule' }} with: description: latest code released from nightly build tag-name: latest env: GITHUB_TOKEN: ${{ secrets.GITHUB_TOKEN }} napari-0.5.0a1/.github/workflows/make_bundle_conda.yml000066400000000000000000000006511437041365600227550ustar00rootroot00000000000000name: Conda on: push: # Sequence of patterns matched against refs/tags tags: - "v*" # Push events to matching v*, i.e. v1.0, v20.15.10 schedule: - cron: "0 0 * * *" # workflow_dispatch: # go to napari/packaging to trigger manual runs jobs: packaging: uses: napari/packaging/.github/workflows/make_bundle_conda.yml@main secrets: inherit with: event_name: ${{ github.event_name }} napari-0.5.0a1/.github/workflows/make_release.yml000066400000000000000000000051351437041365600217620ustar00rootroot00000000000000on: push: # Sequence of patterns matched against refs/tags tags: - 'v*' # Push events to matching v*, i.e. v1.0, v20.15.10 name: Create Release jobs: build: name: Create Release runs-on: ubuntu-latest if: github.repository == 'napari/napari' steps: - name: Checkout code uses: actions/checkout@v3 - name: Install Python uses: actions/setup-python@v4 with: python-version: 3.9 cache-dependency-path: setup.cfg - name: Install Dependencies run: | python -m pip install --upgrade pip python -m pip install -e .[build] # need full install so we can build type stubs - name: Build Distribution run: make dist - name: Find Release Notes id: release_notes run: | TAG="${GITHUB_REF/refs\/tags\/v/}" # clean tag if [[ "$TAG" != *"rc"* ]]; then VER="${TAG/rc*/}" # remove pre-release identifier RELEASE_NOTES="$(cat docs/release/release_${VER//./_}.md)" # https://github.community/t5/GitHub-Actions/set-output-Truncates-Multiline-Strings/m-p/38372/highlight/true#M3322 RELEASE_NOTES="${RELEASE_NOTES//'%'/'%25'}" RELEASE_NOTES="${RELEASE_NOTES//$'\n'/'%0A'}" RELEASE_NOTES="${RELEASE_NOTES//$'\r'/'%0D'}" else RELEASE_NOTES="pre-release $TAG" fi echo "tag=${TAG}" >> $GITHUB_ENV # https://help.github.com/en/actions/reference/workflow-commands-for-github-actions echo "::set-output name=contents::$RELEASE_NOTES" - name: Create Release id: create_release uses: actions/create-release@latest env: GITHUB_TOKEN: ${{ secrets.GITHUB_TOKEN }} # This token is provided by Actions, you do not need to create your own token with: tag_name: ${{ github.ref }} release_name: ${{ env.tag }} body: ${{ steps.release_notes.outputs.contents }} draft: false prerelease: ${{ contains(github.ref, 'rc') }} - name: Upload Release Asset uses: actions/upload-release-asset@v1 env: GITHUB_TOKEN: ${{ secrets.GITHUB_TOKEN }} with: upload_url: ${{ steps.create_release.outputs.upload_url }} asset_path: ./dist/napari-${{ env.tag }}.tar.gz asset_name: napari-${{ env.tag }}.tar.gz asset_content_type: application/gzip - name: Publish PyPI Package uses: pypa/gh-action-pypi-publish@master with: user: __token__ password: ${{ secrets.pypi_password }} napari-0.5.0a1/.github/workflows/test_comprehensive.yml000066400000000000000000000124341437041365600232530ustar00rootroot00000000000000# The Comprehensive test suite, which will be run anytime anything is merged into main. # See test_pull_request.yml for the tests that will be run name: Comprehensive Test on: push: branches: - main - "v*x" tags: - "v*" # Push events to matching v*, i.e. v1.0, v20.15.10 # Allows you to run this workflow manually from the Actions tab workflow_dispatch: concurrency: group: comprehensive-test jobs: manifest: name: Check Manifest runs-on: ubuntu-latest steps: - uses: actions/checkout@v3 - name: Set up Python 3.8 uses: actions/setup-python@v4 with: python-version: 3.8 - name: Install dependencies run: | pip install --upgrade pip pip install check-manifest - name: Check Manifest run: check-manifest test: name: ${{ matrix.platform }} py${{ matrix.python }} ${{ matrix.toxenv }} ${{ matrix.MIN_REQ && 'min_req' }} runs-on: ${{ matrix.platform }} strategy: fail-fast: false matrix: platform: [ubuntu-latest, windows-latest] python: ["3.8", "3.9", "3.10"] backend: [pyqt5, pyside2] include: - python: 3.9 platform: macos-latest backend: pyqt5 # test with minimum specified requirements - python: 3.8 platform: ubuntu-18.04 backend: pyqt5 MIN_REQ: 1 # test with --async_only - python: 3.8 platform: ubuntu-18.04 toxenv: async-py38-linux-pyqt5 # test without any Qt backends - python: 3.8 platform: ubuntu-18.04 toxenv: headless-py38-linux steps: - name: Cancel Previous Runs uses: styfle/cancel-workflow-action@0.11.0 with: access_token: ${{ github.token }} - uses: actions/checkout@v3 - name: Set up Python ${{ matrix.python }} uses: actions/setup-python@v4 with: python-version: ${{ matrix.python }} cache: "pip" cache-dependency-path: setup.cfg - uses: tlambert03/setup-qt-libs@v1 # strategy borrowed from vispy for installing opengl libs on windows - name: Install Windows OpenGL if: runner.os == 'Windows' run: | git clone --depth 1 https://github.com/pyvista/gl-ci-helpers.git powershell gl-ci-helpers/appveyor/install_opengl.ps1 if (Test-Path -Path "C:\Windows\system32\opengl32.dll" -PathType Leaf) {Exit 0} else {Exit 1} - name: Install dependencies run: | pip install --upgrade pip pip install setuptools tox tox-gh-actions python tools/minreq.py # no-op if MIN_REQ is not set env: MIN_REQ: ${{ matrix.MIN_REQ }} # here we pass off control of environment creation and running of tests to tox # tox-gh-actions, installed above, helps to convert environment variables into # tox "factors" ... limiting the scope of what gets tested on each platform # The one exception is if the "toxenv" environment variable has been set, # in which case we are declaring one specific tox environment to run. # see tox.ini for more - name: Test with tox uses: aganders3/headless-gui@v1 with: run: python -m tox env: PLATFORM: ${{ matrix.platform }} BACKEND: ${{ matrix.backend }} TOXENV: ${{ matrix.toxenv }} NUMPY_EXPERIMENTAL_ARRAY_FUNCTION: ${{ matrix.MIN_REQ || 1 }} PYVISTA_OFF_SCREEN: True MIN_REQ: ${{ matrix.MIN_REQ }} - name: Coverage uses: codecov/codecov-action@v3 - name: Report Failures if: ${{ failure() }} uses: JasonEtco/create-an-issue@v2 env: GITHUB_TOKEN: ${{ secrets.GITHUB_TOKEN }} PLATFORM: ${{ matrix.platform }} PYTHON: ${{ matrix.python }} BACKEND: ${{ matrix.toxenv }} RUN_ID: ${{ github.run_id }} TITLE: "[test-bot] Comprehensive tests failing" with: filename: .github/TEST_FAIL_TEMPLATE.md update_existing: true test_pip_install: name: ubuntu-latest 3.9 pip install runs-on: ubuntu-latest steps: - uses: actions/checkout@v3 with: path: napari-from-github - name: Set up Python 3.9 uses: actions/setup-python@v4 with: python-version: 3.9 cache: "pip" cache-dependency-path: napari-from-github/setup.cfg - uses: tlambert03/setup-qt-libs@v1 - name: Install this commit run: | pip install --upgrade pip pip install ./napari-from-github[all,testing] - name: Test uses: aganders3/headless-gui@v1 with: run: python -m pytest --pyargs napari --color=yes test_examples: name: test examples runs-on: ubuntu-latest steps: - uses: actions/checkout@v3 - uses: actions/setup-python@v4 with: python-version: 3.9 - uses: tlambert03/setup-qt-libs@v1 - name: Install this commit run: | pip install --upgrade pip pip install setuptools tox tox-gh-actions - name: Test uses: aganders3/headless-gui@v1 with: run: tox -e py39-linux-pyside2-examples napari-0.5.0a1/.github/workflows/test_prereleases.yml000066400000000000000000000044051437041365600227150ustar00rootroot00000000000000# An "early warning" cron job that will install dependencies # with `pip install --pre` periodically to test for breakage # (and open an issue if a test fails) name: --pre Test on: schedule: - cron: '0 */12 * * *' # every 12 hours # Allows you to run this workflow manually from the Actions tab workflow_dispatch: jobs: test: name: ${{ matrix.platform }} py${{ matrix.python }} ${{ matrix.backend }} --pre runs-on: ${{ matrix.platform }} if: github.repository == 'napari/napari' strategy: fail-fast: false matrix: platform: [windows-latest, macos-latest, ubuntu-latest] python: [3.9] backend: [pyqt5, pyside2] steps: - uses: actions/checkout@v3 - name: Set up Python ${{ matrix.python }} uses: actions/setup-python@v4 with: python-version: ${{ matrix.python }} cache-dependency-path: setup.cfg - uses: tlambert03/setup-qt-libs@v1 - name: Install Windows OpenGL if: runner.os == 'Windows' run: | git clone --depth 1 https://github.com/pyvista/gl-ci-helpers.git powershell gl-ci-helpers/appveyor/install_opengl.ps1 if (Test-Path -Path "C:\Windows\system32\opengl32.dll" -PathType Leaf) {Exit 0} else {Exit 1} - name: Install dependencies run: | pip install --upgrade pip pip install setuptools tox tox-gh-actions - name: Test with tox # run tests using pip install --pre uses: aganders3/headless-gui@v1 with: run: python -m tox -v --pre env: PLATFORM: ${{ matrix.platform }} BACKEND: ${{ matrix.backend }} PYVISTA_OFF_SCREEN: True # required for opengl on windows # If something goes wrong, we can open an issue in the repo - name: Report Failures if: ${{ failure() }} uses: JasonEtco/create-an-issue@v2 env: GITHUB_TOKEN: ${{ secrets.GITHUB_TOKEN }} PLATFORM: ${{ matrix.platform }} PYTHON: ${{ matrix.python }} BACKEND: ${{ matrix.backend }} RUN_ID: ${{ github.run_id }} TITLE: '[test-bot] pip install --pre is failing' with: filename: .github/TEST_FAIL_TEMPLATE.md update_existing: true napari-0.5.0a1/.github/workflows/test_pull_requests.yml000066400000000000000000000146771437041365600233260ustar00rootroot00000000000000# Our minimal suite of tests that run on each pull request name: PR Test on: pull_request: branches: - main - "v*x" concurrency: group: test-${{ github.ref }} cancel-in-progress: true jobs: manifest: # make sure all necessary files will be bundled in the release name: Check Manifest timeout-minutes: 15 runs-on: ubuntu-latest steps: - uses: actions/checkout@v3 - uses: actions/setup-python@v4 with: python-version: "3.x" cache-dependency-path: setup.cfg cache: 'pip' - name: Install Dependencies run: pip install --upgrade pip - name: Install Napari dev run: pip install -e .[build] - name: Check Manifest run: | make typestubs make check-manifest localization_syntax: # make sure all necessary files will be bundled in the release name: Check l18n syntax runs-on: ubuntu-latest timeout-minutes: 2 steps: - uses: actions/checkout@v3 - uses: actions/setup-python@v4 with: python-version: "3.x" - name: Check localization formatting run: | pip install --upgrade pip semgrep # f"..." and f'...' are the same for semgrep semgrep --error --lang python --pattern 'trans._(f"...")' napari semgrep --error --lang python --pattern 'trans._($X.format(...))' napari test: name: ${{ matrix.platform }} ${{ matrix.python }} ${{ matrix.toxenv || matrix.backend }} ${{ matrix.MIN_REQ && 'min_req' }} runs-on: ${{ matrix.platform }} timeout-minutes: 40 strategy: fail-fast: false matrix: platform: [ubuntu-latest] python: ["3.8", "3.9", "3.10"] backend: [pyqt5, pyside2] include: # Windows py38 - python: 3.8 platform: windows-latest backend: pyqt5 - python: 3.8 platform: windows-latest backend: pyside2 - python: 3.9 platform: macos-latest backend: pyqt5 # minimum specified requirements - python: 3.8 platform: ubuntu-18.04 backend: pyqt5 MIN_REQ: 1 # test with --async_only - python: 3.8 platform: ubuntu-18.04 toxenv: async-pyqt5-py38-linux # test without any Qt backends - python: 3.8 platform: ubuntu-18.04 toxenv: headless-py38-linux - python: 3.9 platform: ubuntu-latest backend: pyqt6 - python: 3.9 platform: ubuntu-latest backend: pyside6 - python: '3.10' platform: ubuntu-latest backend: pyside6 steps: - name: Cancel Previous Runs uses: styfle/cancel-workflow-action@0.11.0 with: access_token: ${{ github.token }} - uses: actions/checkout@v3 - name: Set up Python ${{ matrix.python }} uses: actions/setup-python@v4 with: python-version: ${{ matrix.python }} cache: "pip" cache-dependency-path: setup.cfg - uses: tlambert03/setup-qt-libs@v1 # strategy borrowed from vispy for installing opengl libs on windows - name: Install Windows OpenGL if: runner.os == 'Windows' run: | git clone --depth 1 https://github.com/pyvista/gl-ci-helpers.git powershell gl-ci-helpers/appveyor/install_opengl.ps1 if (Test-Path -Path "C:\Windows\system32\opengl32.dll" -PathType Leaf) {Exit 0} else {Exit 1} # tox and tox-gh-actions will take care of the "actual" installation # of python dependendencies into a virtualenv. see tox.ini for more - name: Install dependencies run: | pip install --upgrade pip pip install setuptools tox tox-gh-actions python tools/minreq.py env: # tools/minreq.py sets all deps to their minumim stated versions # it is a no-op if MIN_REQ is not set MIN_REQ: ${{ matrix.MIN_REQ }} # here we pass off control of environment creation and running of tests to tox # tox-gh-actions, installed above, helps to convert environment variables into # tox "factors" ... limiting the scope of what gets tested on each platform # for instance, on ubuntu-latest with python 3.8, it would be equivalent to this command: # `tox -e py38-linux-pyqt,py38-linux-pyside` # see tox.ini for more - name: Test with tox # the longest is macos-latest 3.9 pyqt5 at ~30 minutes. timeout-minutes: 35 uses: aganders3/headless-gui@v1 with: run: python -m tox env: PLATFORM: ${{ matrix.platform }} BACKEND: ${{ matrix.backend }} TOXENV: ${{ matrix.toxenv }} NUMPY_EXPERIMENTAL_ARRAY_FUNCTION: ${{ matrix.MIN_REQ || 1 }} PYVISTA_OFF_SCREEN: True MIN_REQ: ${{ matrix.MIN_REQ }} - uses: actions/upload-artifact@v3 with: name: upload pytest timing reports as json path: | ./report-*.json - name: Coverage uses: codecov/codecov-action@v3 test_pip_install: name: ubuntu-latest 3.9 pip install runs-on: ubuntu-latest steps: - uses: actions/checkout@v3 with: path: napari-from-github - name: Set up Python 3.9 uses: actions/setup-python@v4 with: python-version: 3.9 cache: "pip" cache-dependency-path: napari-from-github/setup.cfg - uses: tlambert03/setup-qt-libs@v1 - name: Install this commit run: | pip install --upgrade pip pip install ./napari-from-github[all,testing] - name: Test uses: aganders3/headless-gui@v1 with: run: | python -m pytest --pyargs napari --color=yes python -m pytest --pyargs napari_builtins --color=yes test_examples: name: test examples runs-on: ubuntu-latest steps: - uses: actions/checkout@v3 - uses: actions/setup-python@v4 with: python-version: 3.9 cache-dependency-path: setup.cfg - uses: tlambert03/setup-qt-libs@v1 - name: Install this commit run: | pip install --upgrade pip pip install setuptools tox tox-gh-actions - name: Test uses: aganders3/headless-gui@v1 with: run: tox -e py39-linux-pyside2-examples napari-0.5.0a1/.github/workflows/test_translations.yml000066400000000000000000000015701437041365600231240ustar00rootroot00000000000000name: Test translations on: schedule: # * is a special character in YAML so you have to quote this string - cron: '0 1 * * *' workflow_dispatch: jobs: translations: name: Check missing translations runs-on: ubuntu-latest steps: - uses: actions/checkout@v3 - name: Set up Python 3.8 uses: actions/setup-python@v4 with: python-version: 3.9 cache-dependency-path: setup.cfg - name: Install napari run: | pip install -e .[all] pip install -e .[testing] - name: Run check run: | python -m pytest -Wignore tools/ --tb=short - uses: JasonEtco/create-an-issue@v2 if: ${{ failure() }} env: GITHUB_TOKEN: ${{ secrets.GITHUB_TOKEN }} with: filename: .github/missing_translations.md update_existing: true napari-0.5.0a1/.github/workflows/test_typing.yml000066400000000000000000000010171437041365600217110ustar00rootroot00000000000000name: Test typing on: pull_request: branches: - main jobs: typing: runs-on: ubuntu-latest steps: - uses: actions/checkout@v3 - uses: actions/setup-python@v4 with: python-version: 3.9 cache-dependency-path: setup.cfg - name: Install napari run: | pip install -r resources/requirements_mypy.txt SETUPTOOLS_ENABLE_FEATURES="legacy-editable" pip install -e .[all] - name: Run mypy on typed modules run: make typecheck napari-0.5.0a1/.github/workflows/test_vendored.yml000066400000000000000000000017771437041365600222220ustar00rootroot00000000000000name: Test vendored on: schedule: # * is a special character in YAML so you have to quote this string - cron: '0 2 * * *' jobs: vendor: name: Vendored runs-on: ubuntu-latest steps: - uses: actions/checkout@v3 - name: Set up Python 3.9 uses: actions/setup-python@v4 with: python-version: 3.9 - name: Run check id: check_v run: python tools/check_vendored_modules.py --ci - name: Create PR updating vendored modules uses: peter-evans/create-pull-request@v4 with: commit-message: Update vendored modules. branch: update-vendored-examples delete-branch: true title: "[Automatic] Update ${{ steps.check_v.outputs.vendored }} vendored module" body: | This PR is automatically created and updated by napari GitHub action cron to keep vendored modules up to date. It look like ${{ steps.check_v.outputs.vendored }} has a new version. napari-0.5.0a1/.gitignore000066400000000000000000000044121437041365600152120ustar00rootroot00000000000000# Byte-compiled / optimized / DLL files __pycache__/ *.py[cod] *$py.class # C extensions *.so # Distribution / packaging .Python build/ develop-eggs/ dist/ downloads/ eggs/ .eggs/ lib/ lib64/ parts/ sdist/ var/ wheels/ *.egg-info/ .installed.cfg *.egg MANIFEST .dmypy.json # PyInstaller # Usually these files are written by a python script from a template # before PyInstaller builds the exe, so as to inject date/other infos into it. *.manifest *.spec # Installer logs pip-log.txt pip-delete-this-directory.txt pip-wheel-metadata/ # Unit test / coverage reports htmlcov/ .tox/ .coverage .coverage.* .cache nosetests.xml coverage.xml *.cover .hypothesis/ .pytest_cache/ # Translations *.mo *.pot # Django stuff: *.log local_settings.py db.sqlite3 # Flask stuff: instance/ .webassets-cache # Scrapy stuff: .scrapy # Sphinx documentation docs/source/api/ docs/source/release/ docs/source/releases.rst docs/build/ docs/_tags # PyBuilder target/ # Jupyter Notebook .ipynb_checkpoints # pyenv .python-version # celery beat schedule file celerybeat-schedule # SageMath parsed files *.sage.py # Environments .env .venv env/ venv/ ENV/ env.bak/ venv.bak/ # Spyder project settings .spyderproject .spyproject # Rope project settings .ropeproject # mkdocs documentation /site # mypy .mypy_cache/ # Pycharm files .idea # Liclipse .project .pydevproject .settings/ # OS stuff .DS_store # Benchmarking results .asv/ # VSCode .vscode/ # emacs *~ \#*\# auto-save-list tramp .\#* *_flymake.* .projectile .dir-locals.el # these will get autogenerated _qt_resources*.py res.qrc # ignore all generated themed svgs napari/resources/themes # briefcase macOS/ linux/ windows/ napari/_version.py docs/api/napari* docs/_build # built in setup.py napari/view_layers.pyi napari/components/viewer_model.pyi # Autogenerated documentation docs/images/_autogenerated/ docs/guides/preferences.md docs/guides/_layer_events.md docs/guides/_viewer_events.md docs/guides/_layerlist_events.md # come from npe2 docs docs/plugins/_npe2_*.md napari/settings/napari.schema.json docs/jupyter_execute/ docs/.jupyter_cache/ docs/gallery/ # pytest reports in json format https://github.com/napari/napari/pull/4518 report*.json napari/resources/icons/_themes/ # perfmon tools/perfmon/*/traces-*.json github_cache.sqlite napari-0.5.0a1/.pre-commit-config.yaml000066400000000000000000000014361437041365600175060ustar00rootroot00000000000000repos: - repo: https://github.com/MarcoGorelli/absolufy-imports rev: v0.3.1 hooks: - id: absolufy-imports exclude: _vendor|vendored|examples - repo: https://github.com/hadialqattan/pycln rev: v2.1.3 hooks: - id: pycln - repo: https://github.com/psf/black rev: 22.12.0 hooks: - id: black pass_filenames: true exclude: _vendor|vendored|examples - repo: https://github.com/charliermarsh/ruff-pre-commit rev: v0.0.237 hooks: - id: ruff exclude: _vendor|vendored - repo: https://github.com/seddonym/import-linter rev: v1.7.0 hooks: - id: import-linter stages: [manual] - repo: https://github.com/python-jsonschema/check-jsonschema rev: 0.21.0 hooks: - id: check-github-workflows napari-0.5.0a1/CITATION.cff000066400000000000000000000065271437041365600151250ustar00rootroot00000000000000# YAML 1.2 # Metadata for citation of this software according to the CFF format (https://citation-file-format.github.io/) cff-version: 1.0.3 message: If you use this software, please cite it using these metadata. title: 'napari: a multi-dimensional image viewer for Python' doi: 10.5281/zenodo.3555620 authors: - given-names: Nicholas family-names: Sofroniew affiliation: Chan Zuckerberg Initiative orcid: "https://orcid.org/0000-0002-3426-0914" - given-names: Talley family-names: Lambert affiliation: Harvard Medical School orcid: "https://orcid.org/0000-0002-2409-0181" - given-names: Kira family-names: Evans affiliation: Chan Zuckerberg Initiative - given-names: Juan family-names: Nunez-Iglesias affiliation: Biomedicine Discovery Institute, Monash University - given-names: Grzegorz family-names: Bokota affiliation: University of Warsaw, Faculty of Mathematics, Informatics, and Mechanics orcid: https://orcid.org/0000-0002-5470-1676 - given-names: Philip family-names: Winston affiliation: Tobeva Software - given-names: Gonzalo family-names: Peña-Castellanos affiliation: Quansight, Inc. / SIHSA Ltda - given-names: Kevin family-names: Yamauchi affiliation: Iber Lab - ETH Zürich - given-names: Matthias family-names: Bussonnier orcid: "http://orcid.org/0000-0002-7636-8632" affiliation: Quansight Labs - given-names: Draga family-names: Doncila Pop - given-names: Ahmet family-names: Can Solak affiliation: Chan Zuckerberg Biohub - given-names: Ziyang family-names: Liu affiliation: Chan Zuckerberg Initiative Foundation - given-names: Pam family-names: Wadhwa affiliation: Quansight Labs - given-names: Alister family-names: Burt affiliation: MRC-LMB - given-names: Genevieve family-names: Buckley affiliation: Monash University orcid: https://orcid.org/0000-0003-2763-492X - given-names: Andrew family-names: Sweet affiliation: Chan Zuckerberg Initiative - given-names: Lukasz family-names: Migas affiliation: Delft University of Technology - given-names: Volker family-names: Hilsenstein affiliation: EMBL Heidelberg, Germany orcid: https://orcid.org/0000-0002-2255-2960 - given-names: Lorenzo family-names: Gaifas affiliation: Gutsche Lab - University of Grenoble orcid: https://orcid.org/0000-0003-4875-9422 - given-names: Jordão family-names: Bragantini affiliation: Chan Zuckerberg Biohub - given-names: Jaime family-names: Rodríguez-Guerra affiliation: Quansight Labs - given-names: Hector family-names: Muñoz affiliation: University of California, Los Angeles orcid: https://orcid.org/0000-0001-7851-2549 - given-names: Jeremy family-names: Freeman affiliation: Chan Zuckerberg Initiative - given-names: Peter family-names: Boone - given-names: Alan family-names: Lowe name-particle: R affiliation: UCL & The Alan Turing Institute - given-names: Christoph family-names: Gohlke affiliation: University of California, Irvine - given-names: Loic family-names: Royer affiliation: Chan Zuckerberg Biohub - given-names: Andrea family-names: Pierré affiliation: Brown University orcid: "https://orcid.org/0000-0003-4501-5428" - given-names: Hagai family-names: Har-Gil affiliation: Tel Aviv University, Israel - given-names: Abigail family-names: McGovern affiliation: Monash University repository-code: https://github.com/napari/napari license: BSD-3-Clause napari-0.5.0a1/EULA.md000066400000000000000000002002141437041365600142700ustar00rootroot00000000000000## Notice of Third Party Software Licenses napari may be [installed][napari_installers] through a variety of methods. Particularly, the bundled installers may include third party software packages or tools licensed under different terms. These licenses may be accessed from within the resulting napari installation or https://napari.org. [napari_installers]: https://napari.org/stable/#installation Intel® OpenMP ``` Intel Simplified Software License (Version August 2021) Use and Redistribution. You may use and redistribute the software (the "Software"), without modification, provided the following conditions are met: * Redistributions must reproduce the above copyright notice and the following terms of use in the Software and in the documentation and/or other materials provided with the distribution. * Neither the name of Intel nor the names of its suppliers may be used to endorse or promote products derived from this Software without specific prior written permission. * No reverse engineering, decompilation, or disassembly of this Software is permitted. No other licenses. Except as provided in the preceding section, Intel grants no licenses or other rights by implication, estoppel or otherwise to, patent, copyright, trademark, trade name, service mark or other intellectual property licenses or rights of Intel. Third party software. The Software may contain Third Party Software. "Third Party Software" is open source software, third party software, or other Intel software that may be identified in the Software itself or in the files (if any) listed in the "third-party-software.txt" or similarly named text file included with the Software. Third Party Software, even if included with the distribution of the Software, may be governed by separate license terms, including without limitation, open source software license terms, third party software license terms, and other Intel software license terms. Those separate license terms solely govern your use of the Third Party Software, and nothing in this license limits any rights under, or grants rights that supersede, the terms of the applicable license terms. DISCLAIMER. THIS SOFTWARE IS PROVIDED "AS IS" AND ANY EXPRESS OR IMPLIED WARRANTIES, INCLUDING, BUT NOT LIMITED TO, THE IMPLIED WARRANTIES OF MERCHANTABILITY, FITNESS FOR A PARTICULAR PURPOSE, AND NON-INFRINGEMENT ARE DISCLAIMED. THIS SOFTWARE IS NOT INTENDED FOR USE IN SYSTEMS OR APPLICATIONS WHERE FAILURE OF THE SOFTWARE MAY CAUSE PERSONAL INJURY OR DEATH AND YOU AGREE THAT YOU ARE FULLY RESPONSIBLE FOR ANY CLAIMS, COSTS, DAMAGES, EXPENSES, AND ATTORNEYS' FEES ARISING OUT OF ANY SUCH USE, EVEN IF ANY CLAIM ALLEGES THAT INTEL WAS NEGLIGENT REGARDING THE DESIGN OR MANUFACTURE OF THE SOFTWARE. LIMITATION OF LIABILITY. IN NO EVENT WILL INTEL BE LIABLE FOR ANY DIRECT, INDIRECT, INCIDENTAL, SPECIAL, EXEMPLARY, OR CONSEQUENTIAL DAMAGES (INCLUDING, BUT NOT LIMITED TO, PROCUREMENT OF SUBSTITUTE GOODS OR SERVICES; LOSS OF USE, DATA, OR PROFITS; OR BUSINESS INTERRUPTION) HOWEVER CAUSED AND ON ANY THEORY OF LIABILITY, WHETHER IN CONTRACT, STRICT LIABILITY, OR TORT (INCLUDING NEGLIGENCE OR OTHERWISE) ARISING IN ANY WAY OUT OF THE USE OF THIS SOFTWARE, EVEN IF ADVISED OF THE POSSIBILITY OF SUCH DAMAGE. No support. Intel may make changes to the Software, at any time without notice, and is not obligated to support, update or provide training for the Software. Termination. Your right to use the Software is terminated in the event of your breach of this license. Feedback. Should you provide Intel with comments, modifications, corrections, enhancements or other input ("Feedback") related to the Software, Intel will be free to use, disclose, reproduce, license or otherwise distribute or exploit the Feedback in its sole discretion without any obligations or restrictions of any kind, including without limitation, intellectual property rights or licensing obligations. Compliance with laws. You agree to comply with all relevant laws and regulations governing your use, transfer, import or export (or prohibition thereof) of the Software. Governing law. All disputes will be governed by the laws of the United States of America and the State of Delaware without reference to conflict of law principles and subject to the exclusive jurisdiction of the state or federal courts sitting in the State of Delaware, and each party agrees that it submits to the personal jurisdiction and venue of those courts and waives any objections. The United Nations Convention on Contracts for the International Sale of Goods (1980) is specifically excluded and will not apply to the Software. ``` Intel® Math Kernel Library ``` Intel End User License Agreement for Developer Tools (Version October 2021) IMPORTANT NOTICE - PLEASE READ AND AGREE BEFORE DOWNLOADING, INSTALLING, COPYING OR USING This Agreement is between you, or the company or other legal entity that you represent and warrant you have the legal authority to bind, (each, "You" or "Your") and Intel Corporation and its subsidiaries (collectively, "Intel") regarding Your use of the Materials. By downloading, installing, copying or using the Materials, You agree to be bound by the terms of this Agreement. If You do not agree to the terms of this Agreement, or do not have legal authority or required age to agree to them, do not download, install, copy or use the Materials. 1. LICENSE DEFINITIONS. A. "Cloud Provider" means a third party service provider offering a cloud-based platform, infrastructure, application or storage services, such as Microsoft Azure or Amazon Web Services, which You may only utilize to host the Materials subject to the restrictions set forth in Section 2.3 B. B. "Derivative Work" means a derivative work, as defined in 17 U.S.C. 101, of the Source Code. C. "Executable Code" means computer programming code in binary form suitable for machine execution by a processor without the intervening steps of interpretation or compilation. D. "Materials" mean the software, documentation, the software product serial number, and other collateral, including any updates, made available to You by Intel under this Agreement. Materials include Redistributables, Executable Code, Source Code, Sample Source Code, and Pre-Release Materials, but do not include Third Party Software. E. "Pre-Release Materials" mean the Materials, or portions of the Materials, that are identified (in the product release notes, on Intel's download website for the Materials or elsewhere) or labeled as pre-release, prototype, alpha or beta code and, as such, are deemed to be pre-release code (i) which may not be fully functional or tested and may contain bugs or errors; (ii) which Intel may substantially modify in its development of a production version; or (iii) for which Intel makes no assurances that it will ever develop or make a production version generally available. Pre-Release Materials are subject to the terms of Section 3.2. F. "Reciprocal Open Source Software" means any software that is subject to a license which requires that (i) it must be distributed in source code form; (ii) it must be licensed under the same open source license terms; and (iii) its derivative works must be licensed under the same open source license terms. Examples of this type of license are the GNU General Public License or the Mozilla Public License. G. "Redistributables" mean the files (if any) listed in the "redist.txt," "redist-rt.txt" or similarly-named text files that may be included in the Materials. Redistributables include Sample Source Code. H. "Sample Source Code" means those portions of the Materials that are Source Code and are identified as sample code. Sample Source Code may not have been tested or validated by Intel and is provided purely as a programming example. I. "Source Code" means the software portion of the Materials provided in human readable format. J. "Third Party Software" mean the files (if any) listed in the "third-party-software.txt" or other similarly-named text file that may be included in the Materials for the applicable software. Third Party Software is subject to the terms of Section 2.2. K. "Your Product" means one or more applications, products or projects developed by or for You using the Materials. 2. LICENSE GRANTS. 2.1 License to the Materials. Subject to the terms and conditions of this Agreement, Intel grants You a non-exclusive, worldwide, non-assignable, non-sublicensable, limited right and license under its copyrights, to: A. reproduce internally a reasonable number of copies of the Materials for Your personal or business use; B. use the Materials solely for Your personal or business use to develop Your Product, in accordance with the documentation included as part of the Materials; C. modify or create Derivative Works only of the Redistributables, or any portions, that are provided to You in Source Code; D. distribute (directly and through Your distributors, resellers, and other channel partners, if applicable), the Redistributables, including any modifications to or Derivative Works of the Redistributables or any portions made pursuant to Section 2.1.C subject to the following conditions: (1) Any distribution of the Redistributables must only be as part of Your Product which must add significant primary functionality different than that of the Redistributables themselves; (2) You must only distribute the Redistributables originally provided to You by Intel only in Executable Code subject to a license agreement that prohibits reverse engineering, decompiling or disassembling the Redistributables; (3) This distribution right includes a limited right to sublicense only the Intel copyrights in the Redistributables and only to the extent necessary to perform, display, and distribute the Redistributables (including Your modifications and Derivative Works of the Redistributables provided in Source Code) solely as incorporated in Your Product; and (4) You: (i) will be solely responsible to Your customers for any update, support obligation or other obligation or liability which may arise from the distribution of Your Product, (ii) will not make any statement that Your Product is "certified" or that its performance is guaranteed by Intel or its suppliers, (iii) will not use Intel's or its suppliers' names or trademarks to market Your Product, (iv) will comply with any additional restrictions which are included in the text files with the Redistributables and in Section 3 below, (v) will indemnify, hold harmless, and defend Intel and its suppliers from and against any claims or lawsuits, costs, damages, and expenses, including attorney's fees, that arise or result from (a) Your modifications or Derivative Works of the Materials or (b) Your distribution of Your Product. 2.2 Third Party Software. Third Party Software, even if included with the distribution of the Materials, may be governed by separate license terms, including without limitation, third party license terms, open source software notices and terms, and/or other Intel software license terms. These separate license terms solely govern Your use of the Third Party Software. 2.3 Third Party Use. A. If You are an entity, Your contractors may use the Materials under the license specified in Section 2, provided: (i) their use of the Materials is solely on behalf of and in support of Your business, (ii) they agree to the terms and conditions of this Agreement, and (iii) You are solely responsible for their use, misuse or disclosure of the Materials. B. You may utilize a Cloud Provider to host the Materials for You, provided: (i) the Cloud Provider may only host the Materials for Your exclusive use and may not use the Materials for any other purpose whatsoever, including the restriction set forth in Section 3.1(xi); (ii) the Cloud Provider's use of the Materials must be solely on behalf of and in support of Your Product, and (iii) You will indemnify, hold harmless, and defend Intel and its suppliers from and against any claims or lawsuits, costs, damages, and expenses, including attorney's fees, that arise or result from Your Cloud Provider's use, misuse or disclosure of the Materials. 3. LICENSE CONDITIONS. 3.1 Restrictions. Except as expressly provided in this Agreement, You may NOT: (i) use, reproduce, disclose, distribute, or publicly display the Materials; (ii) share, publish, rent or lease the Materials to any third party; (iii) assign this Agreement or transfer the Materials; (iv) modify, adapt, or translate the Materials in whole or in part; (v) reverse engineer, decompile, or disassemble the Materials, or otherwise attempt to derive the source code for the software; (vi) work around any technical limitations in the Materials; (vii) distribute, sublicense or transfer any Source Code, modifications or Derivative Works of any Source Code to any third party; (viii) remove, minimize, block or modify any notices of Intel or its suppliers in the Materials; (ix) include the Redistributables in malicious, deceptive, or unlawful programs or products or use the Materials in any way that is against the law; (x) modify, create a Derivative Work, link, or distribute the Materials so that any part of it becomes Reciprocal Open Source Software; (xi) use the Materials directly or indirectly for SaaS services or service bureau purposes (i.e., a service that allows use of or access to the Materials by a third party as part of that service, such as the salesforce.com service business model). 3.2 Pre-Release Materials. If You receive Pre-Release Materials, You may reproduce a reasonable number of copies and use the Pre-Release Materials for evaluation and testing purposes only. You may not (i) modify or incorporate the Pre-Release Materials into Your Product; (ii) continue to use the Pre-Release Materials once a commercial version is released; or (iii) disclose to any third party any benchmarks, performance results, or other information relating to the Pre-Release Materials. Intel may waive these restrictions in writing at its sole discretion; however, if You decide to use the Pre-Release Materials in Your Product (even with Intel's waiver), You acknowledge and agree that You are fully responsible for any and all issues that result from such use. 3.3 Safety-Critical, and Life-Saving Applications; Indemnity. The Materials may provide information relevant to safety-critical applications ("Safety-Critical Applications") to allow compliance with functional safety standards or requirements. You acknowledge and agree that safety is Your responsibility. To the extent You use the Materials to create, or as part of, products used in Safety-Critical Applications, it is Your responsibility to design, manage, and ensure that there are system-level safeguards to anticipate, monitor, and control system failures, and You agree that You are solely responsible for all applicable regulatory standards and safety-related requirements concerning Your use of the Materials in Safety Critical Applications. Should You use the Materials for Safety-Critical Applications or in any type of a system or application in which the failure of the Materials could create a situation where personal injury or death may occur (e.g., medical systems, life-sustaining or life-saving systems) ("Life-Saving Applications"), You agree to indemnify, defend, and hold Intel and its representatives harmless against any claims or lawsuits, costs, damages, and expenses, including reasonable attorney fees, arising in any way out of Your use of the Materials in Safety-Critical Applications or Life-Saving Applications and claims of product liability, personal injury or death associated with those applications; even if such claims allege that Intel was negligent or strictly liable regarding the design or manufacture of the Materials or its failure to warn regarding the Materials. 3.4 Media Format Codecs and Digital Rights Management. You acknowledge and agree that Your use of the Materials or distribution of the Redistributables with Your Product as permitted by this Agreement may require You to procure license(s) from third parties that may hold intellectual property rights applicable to any media decoding, encoding or transcoding technology (e.g., the use of an audio or video codec) and/or digital rights management capabilities of the Materials, if any. Should any such additional licenses be required, You are solely responsible for obtaining any such licenses and agree to obtain any such licenses at Your own expense. 4. DATA COLLECTION AND PRIVACY. 4.1 Data Collection. The Materials may generate and collect anonymous data and/or provisioning data about the Materials and/or the development environment and transmit the data to Intel as a one-time event during installation. Optional data may also be collected by the Materials, however, You will be provided notice of the request to collect optional data and no optional data will be collected without Your consent. All data collection by Intel is performed pursuant to relevant privacy laws, including notice and consent requirements. 4.2 Intel's Privacy Notice. Intel is committed to respecting Your privacy. To learn more about Intel's privacy practices, please visit http://www.intel.com/privacy. 5. OWNERSHIP. Title to the Materials and all copies remain with Intel or its suppliers. The Materials are protected by intellectual property rights, including without limitation, United States copyright laws and international treaty provisions. You will not remove any copyright or other proprietary notices from the Materials. Except as expressly provided herein, no license or right is granted to You directly or by implication, inducement, estoppel or otherwise; specifically, Intel does not grant any express or implied right to You under Intel patents, copyrights, trademarks, or trade secrets. 6. NO WARRANTY AND NO SUPPORT. 6.1 No Warranty. Disclaimer. Intel disclaims all warranties of any kind and the terms and remedies provided in this Agreement are instead of any other warranty or condition, express, implied or statutory, including those regarding merchantability, fitness for any particular purpose, non-infringement or any warranty arising out of any course of dealing, usage of trade, proposal, specification or sample. Intel does not assume (and does not authorize any person to assume on its behalf) any liability. 6.2 No Support; Priority Support. Intel may make changes to the Materials, or to items referenced therein, at any time without notice, but is not obligated to support, update or provide training for the Materials under the terms of this Agreement. Intel offers free community and paid priority support options. More information on these support options can be found at: https://software.intel.com/content/www/us/en/develop/support/priority-support.html. 7. LIMITATION OF LIABILITY. 7.1 Intel will not be liable for any of the following losses or damages (whether such losses or damages were foreseen, foreseeable, known or otherwise): (i) loss of revenue; (ii) loss of actual or anticipated profits; (iii) loss of the use of money; (iv) loss of anticipated savings; (v) loss of business; (vi) loss of opportunity; (vii) loss of goodwill; (viii) loss of use of the Materials; (ix) loss of reputation; (x) loss of, damage to, or corruption of data; or (xi) any indirect, incidental, special or consequential loss of damage however caused (including loss or damage of the type specified in this Section 7). 7.2 Intel's total cumulative liability to You, including for direct damages for claims relating to this Agreement, and whether for breach of contract, negligence, or for any other reason, will not exceed $100. 7.3 You acknowledge that the limitations of liability provided in this Section 7 are an essential part of this Agreement. You agree that the limitations of liability provided in this Agreement with respect to Intel will be conveyed to and made binding upon any customer of Yours that acquires the Redistributables. 8. USER SUBMISSIONS. Should you provide Intel with comments, modifications, corrections, enhancements or other input ("Feedback") related to the Materials, Intel will be free to use, disclose, reproduce, license or otherwise distribute or exploit the Feedback in its sole discretion without any obligations or restrictions of any kind, including without limitation, intellectual property rights or licensing obligations. If You wish to provide Intel with information that You intend to be treated as confidential information, Intel requires that such confidential information be provided pursuant to a non-disclosure agreement ("NDA"); please contact Your Intel representative to ensure the proper NDA is in place. 9. NON-DISCLOSURE. Information provided by Intel to You may include information marked as confidential. You must treat such information as confidential under the terms of the applicable NDA between Intel and You. If You have not entered into an NDA with Intel, You must not disclose, distribute or make use of any information marked as confidential, except as expressly authorized in writing by Intel. Intel retains all rights in and to its confidential information specifications, designs, engineering details, discoveries, inventions, patents, copyrights, trademarks, trade secrets, and other proprietary rights relating to the Materials. Any breach by You of the confidentiality obligations provided for in this Section 9 will cause irreparable injury to Intel for which money damages may be inadequate to compensate Intel for losses arising from such a breach. Intel may obtain equitable relief, including injunctive relief, if You breach or threaten to breach Your confidentiality obligations. 10. TERM AND TERMINATION. This Agreement becomes effective on the date You accept this Agreement and will continue until terminated as provided for in this Agreement. The term for any Pre-Release Materials terminates upon release of a commercial version. This Agreement will terminate if You are in breach of any of its terms and conditions. Upon termination, You will promptly destroy the Materials and all copies. In the event of termination of this Agreement, Your license to any Redistributables distributed by You in accordance with the terms and conditions of this Agreement, prior to the effective date of such termination, will survive any such termination of this Agreement. Sections 1, 2.1.D(4)(v), 2.2, 2.3.A(iii), 2.3.B(iii), 3.3, 5, 6, 7, 8, 9, 10 (with respect to these survival provisions in the last sentence), and 12 will survive expiration or termination of this Agreement. 11. U.S. GOVERNMENT RESTRICTED RIGHTS. The technical data and computer software covered by this license is a "Commercial Item," as such term is defined by the FAR 2.101 (48 C.F.R. 2.101) and is "commercial computer software" and "commercial computer software documentation" as specified under FAR 12.212 (48 C.F.R. 12.212) or DFARS 227.7202 (48 C.F.R. 227.7202), as applicable. This commercial computer software and related documentation is provided to end users for use by and on behalf of the U.S. Government with only those rights as are granted to all other end users pursuant to the terms and conditions of this Agreement. 12. GENERAL PROVISIONS. 12.1 ENTIRE AGREEMENT. This Agreement contains the complete and exclusive agreement and understanding between the parties concerning the subject matter of this Agreement, and supersedes all prior and contemporaneous proposals, agreements, understanding, negotiations, representations, warranties, conditions, and communications, oral or written, between the parties relating to the same subject matter. Each party acknowledges and agrees that in entering into this Agreement it has not relied on, and will not be entitled to rely on, any oral or written representations, warranties, conditions, understanding, or communications between the parties that are not expressly set forth in this Agreement. The express provisions of this Agreement control over any course of performance, course of dealing, or usage of the trade inconsistent with any of the provisions of this Agreement. The provisions of this Agreement will prevail notwithstanding any different, conflicting, or additional provisions that may appear on any purchase order, acknowledgement, invoice, or other writing issued by either party in connection with this Agreement. No modification or amendment to this Agreement will be effective unless in writing and signed by authorized representatives of each party, and must specifically identify this Agreement by its title and version (e.g., "Intel End User License Agreement for Developer Tools (Version October 2021)"); except that Intel may make changes to this Agreement as it distributes new versions of the Materials. When changes are made, Intel will make a new version of the Agreement available on its website. If You received a copy of this Agreement translated into another language, the English language version of this Agreement will prevail in the event of any conflict between versions. 12.2 EXPORT. You acknowledge that the Materials and all related technical information are subject to export controls and you agree to comply with all laws and regulations of the United States and other applicable governments governing export, re-export, import, transfer, distribution, and use of the Materials. In particular, but without limitation, the Materials may not be exported or re-exported (i) into any U.S. embargoed countries or (ii) to any person or entity listed on a denial order published by the U.S. government or any other applicable governments. By using the Materials, You represent and warrant that You are not located in any such country or on any such list. You also agree that You will not use the Materials for, or sell or transfer them to a third party who is known or suspected to be involved in, any purposes prohibited by the U.S. government or other applicable governments, including, without limitation, the development, design, manufacture, or production of nuclear, missile, chemical or biological weapons. 12.3 GOVERNING LAW, JURISDICTION, AND VENUE. All disputes arising out of or related to this Agreement, whether based on contract, tort, or any other legal or equitable theory, will in all respects be governed by, and construed and interpreted under, the laws of the United States of America and the State of Delaware, without reference to conflict of laws principles. The parties agree that the United Nations Convention on Contracts for the International Sale of Goods (1980) is specifically excluded from and will not apply to this Agreement. All disputes arising out of or related to this Agreement, whether based on contract, tort, or any other legal or equitable theory, will be subject to the exclusive jurisdiction of the courts of the State of Delaware or of the Federal courts sitting in that State. Each party submits to the personal jurisdiction of those courts and waives all objections to that jurisdiction and venue for those disputes. 12.4 SEVERABILITY. The parties intend that if a court holds that any provision or part of this Agreement is invalid or unenforceable under applicable law, the court will modify the provision to the minimum extent necessary to make it valid and enforceable, or if it cannot be made valid and enforceable, the parties intend that the court will sever and delete the provision or part from this Agreement. Any change to or deletion of a provision or part of this Agreement under this Section will not affect the validity or enforceability of the remainder of this Agreement, which will continue in full force and effect. ``` UCRT (Redistributable files for Windows SDK) ``` MICROSOFT SOFTWARE LICENSE TERMS MICROSOFT WINDOWS SOFTWARE DEVELOPMENT KIT (SDK) FOR WINDOWS 10 _______________________________________________________________________________________________________ These license terms are an agreement between Microsoft Corporation (or based on where you live, one of its affiliates) and you. Please read them. They apply to the software named above, which includes the media on which you received it, if any. The terms also apply to any Microsoft • APIs (i.e., APIs included with the installation of the SDK or APIs accessed by installing extension packages or service to use with the SDK), • updates, • supplements, • internet-based services, and • support services for this software, unless other terms accompany those items. If so, those terms apply. By using the software, you accept these terms. If you do not accept them, do not use the software. As described below, using some features also operates as your consent to the transmission of certain standard computer information for Internet-based services. ________________________________________________________________________________________________ If you comply with these license terms, you have the rights below. 1. INSTALLATION AND USE RIGHTS. a. You may install and use any number of copies of the software on your devices to design, develop and test your programs that run on a Microsoft operating system. Further, you may install, use and/or deploy via a network management system or as part of a desktop image, any number of copies of the software on computer devices within your internal corporate network to design, develop and test your programs that run on a Microsoft operating system. Each copy must be complete, including all copyright and trademark notices. You must require end users to agree to terms that protect the software as much as these license terms. b. Utilities. The software contains certain components that are identified in the Utilities List located at http://go.microsoft.com/fwlink/?LinkId=524839. Depending on the specific edition of the software, the number of Utility files you receive with the software may not be equal to the number of Utilities listed in the Utilities List. Except as otherwise provided on the Utilities List for specific files, you may copy and install the Utilities you receive with the software on to other third party machines. These Utilities may only be used to debug and deploy your programs and databases you have developed with the software. You must delete all the Utilities installed onto a third party machine within the earlier of (i) when you have finished debugging or deploying your programs; or (ii) thirty (30) days after installation of the Utilities onto that machine. We may add additional files to this list from time to time. c. Build Services and Enterprise Build Servers.  You may install and use any number of copies of the software onto your build machines or servers, solely for the purpose of: i. Compiling, building, verifying and archiving your programs; ii. Creating and configuring build systems internal to your organization to support your internal build environment; or iii. Enabling a service for third parties to design, develop and test programs or services that run on a Microsoft operating system. d. Included Microsoft Programs. The software contains other Microsoft programs. The license terms with those programs apply to your use of them. e. Third Party Notices. The software may include third party code that Microsoft, not the third party, licenses to you under this agreement. Notices, if any, for the third party code are included for your information only. Notices, if any, for this third party code are included with the software and may be located at http://aka.ms/thirdpartynotices. f. 2. ADDITIONAL LICENSING REQUIREMENTS AND/OR USE RIGHTS. a. Distributable Code. The software contains code that you are permitted to distribute in programs you develop if you comply with the terms below. i. Right to Use and Distribute. The code and test files listed below are “Distributable Code”. • REDIST.TXT Files. You may copy and distribute the object code form of code listed in REDIST.TXT files plus the files listed on the REDIST.TXT list located at http://go.microsoft.com/fwlink/?LinkId=524842. Depending on the specific edition of the software, the number of REDIST files you receive with the software may not be equal to the number of REDIST files listed in the REDIST.TXT List. We may add additional files to the list from time to time. • Third Party Distribution. You may permit distributors of your programs to copy and distribute the Distributable Code as part of those programs. ii. Distribution Requirements. For any Distributable Code you distribute, you must • Add significant primary functionality to it in your programs; • For any Distributable Code having a filename extension of .lib, distribute only the results of running such Distributable Code through a linker with your program; • Distribute Distributable Code included in a setup program only as part of that setup program without modification; • Require distributors and external end users to agree to terms that protect it at least as much as this agreement; • For Distributable Code from the Windows Performance Toolkit portions of the software, distribute the unmodified software package as a whole with your programs, with the exception of the KernelTraceControl.dll and the WindowsPerformanceRecorderControl.dll which can be distributed with your programs; • Display your valid copyright notice on your programs; and • Indemnify, defend, and hold harmless Microsoft from any claims, including attorneys’ fees, related to the distribution or use of your programs. iii. Distribution Restrictions. You may not • Alter any copyright, trademark or patent notice in the Distributable Code; • Use Microsoft’s trademarks in your programs’ names or in a way that suggests your programs come from or are endorsed by Microsoft; • Distribute partial copies of the Windows Performance Toolkit portion of the software package with the exception of the KernelTraceControl.dll and the WindowsPerformanceRecorderControl.dll which can be distributed with your programs; • Distribute Distributable Code to run on a platform other than the Microsoft operating system platform; • Include Distributable Code in malicious, deceptive or unlawful programs; or • Modified or distribute the source code of any Distributable Code so that any part of it becomes subject to an Excluded License. And Excluded License is on that requir3es, as a condition of use, modification or distribution, that ▪ The code be disclosed or distributed in source code form; or ▪ Others have the right to modify it. b. Additional Rights and Restrictions for Features made Available with the Software. i. Windows App Requirements. If you intend to make your program available in the Windows Store, the program must comply with the Certification Requirements as defined and described in the App Developer Agreement, currently available at: https://msdn.microsoft.com/en-us/library/windows/apps/hh694058.aspx. ii. Bing Maps. The software may include features that retrieve content such as maps, images and other data through the Bing Maps (or successor branded) application programming interface (the “Bing Maps API”) to create reports displaying data on top of maps, aerial and hybrid imagery. If these features are included, you may use these features to create and view dynamic or static documents only in conjunction with and through methods and means of access integrated in the software. You may not otherwise copy, store, archive, or create a database of the entity information including business names, addresses and geocodes available through the Bing Maps API. You may not use the Bing Maps API to provide sensor based guidance/routing, nor use any Road Traffic Data or Bird’s Eye Imager (or associated metadata) even if available through the Bing Maps API for any purpose. Your use of the Bing Maps API and associated content is also subject to the additional terms and conditions at http://go.microsoft.com/fwlink/?LinkId=21969. iii. Additional Mapping APIs. The software may include application programming interfaces that provide maps and other related mapping features and services that are not provided by Bing (the “Additional Mapping APIs”). These Additional Mapping APIs are subject to additional terms and conditions and may require payment of fees to Microsoft and/or third party providers based on the use or volume of use of such Additional Mapping APIs. These terms and conditions will be provided when you obtain any necessary license keys to use such Additional Mapping APIs or when you review or receive documentation related to the use of such Additional Mapping APIs. iv. Push Notifications. The Microsoft Push Notification Service may not be used to send notifications that are mission critical or otherwise could affect matters of life or death, including without limitation critical notifications related to a medical device or condition. MICROSOFT EXPRESSLY DISCLAIMS ANY WARRANTIES THAT THE USE OF THE MICROSOFT PUSH NOTIFICATION SERVICE OR DELIVERY OF MICROSOFT PUSH NOTIFICATION SERVICE NOTIFICATIONS WILL BE UNINTERRUPTED, ERROR FREE, OR OTHERWISE GUARANTEED TO OCCUR ON A REAL-TIME BASIS. v. Speech namespace API. Using speech recognition functionality via the Speech namespace APIs in a program requires the support of a speech recognition service. The service may require network connectivity at the time of recognition (e.g., when using a predefined grammar). In addition, the service may also collect speech-related data in order to provide and improve the service. The speech-related data may include, for example, information related to grammar size and string phrases in a grammar. vi. Also, in order for a user to use speech recognition on the phone they must first accept certain terms of use. The terms of use notify the user that data related to their use of the speech recognition service will be collected and used to provide and improve the service. If a user does not accept the terms of use and speech recognition is attempted by the application, the operation will not work and an error will be returned to the application. vii. PlayReady Support. The software may include the Windows Emulator, which contains Microsoft’s PlayReady content access technology. Content owners use Microsoft PlayReady content access technology to protect their intellectual property, including copyrighted content. This software uses PlayReady technology to access PlayReady-protected content and/or WMDRM-protected content. Microsoft may decide to revoke the software’s ability to consume PlayReady-protected content for reasons including but not limited to (i) if a breach or potential breach of PlayReady technology occurs, (ii) proactive robustness enhancement, and (iii) if Content owners require the revocation because the software fails to properly enforce restrictions on content usage. Revocation should not affect unprotected content or content protected by other content access technologies. Content owners may require you to upgrade PlayReady to access their content. If you decline an upgrade, you will not be able to access content that requires the upgrade and may not be able to install other operating system updates or upgrades. viii. Package Managers. The software may include package managers, like NuGet, that give you the option to download other Microsoft and third party software packages to use with your application. Those packages are under their own licenses, and not this agreement. Microsoft does not distribute, license or provide any warranties for any of the third party packages. ix. Font Components. While the software is running, you may use its fonts to display and print content. You may only embed fonts in content as permitted by the embedding restrictions in the fonts; and temporarily download them to a printer or other output device to help print content. x. Notice about the H.264/AVD Visual Standard, and the VC-1 Video Standard. This software may include H.264/MPEG-4 AVC and/or VD-1 decoding technology. MPEG LA, L.L.C. requires this notice: c. THIS PRODUCT IS LICENSED UNDER THE AVC AND THE VC-1 PATENT PORTFOLIO LICENSES FOR THE PERSONAL AND NON-COMMERCIAL USE OF A CONSUMER TO (i) ENCODE VIDEO IN COMPLIANCE WITH THE ABOVE STANDARDS (“VIDEO STANDARDS”) AND/OR (ii) DECODE AVC, AND VC-1 VIDEO THAT WAS ENCODED BY A CONSUMER ENGAGED IN A PERSONAL AND NON-COMMERCIAL ACTIVITY AND/OR WAS OBTAINED FROM A VIDEO PROVIDER LICENSED TO PROVIDE SUCH VIDEO. NONE OF THE LICENSES EXTEND TO ANY OTHER PRODUCT REGARDLESS OF WHETHER SUCH PRODUCT IS INCLUDED WITH THIS SOFTWARE IN A SINGLE ARTICLE. NO LICENSE IS GRANTED OR SHALL BE IMPLIED FOR ANY OTHER USE. ADDITIONAL INFORMATION MAY BE OBTAINED FROM MPEG LA, L.L.C. SEE WWW.MPEGLA.COM. d. For clarification purposes, this notice does not limit or inhibit the use of the software for normal business uses that are personal to that business which do not include (i) redistribution of the software to third parties, or (ii) creation of content with the VIDEO STANDARDS compliant technologies for distribution to third parties. e. INTERNET-BASED SERVICES. Microsoft provides Internet-based services with the software. It may change or cancel them at any time. f. Consent for Internet-Based Services. The software features described below and in the privacy statement at http://go.microsoft.com/fwlink/?LinkId=521839 connect to Microsoft or service provider computer systems over the Internet. In some cases, you will not receive a separate notice when they connect. In some cases, you may switch off these features or not use them as described in the applicable product documentation. By using these features, you consent to the transmission of this information. Microsoft does not use the information to identify or contact you. i. Computer Information. The following features use Internet protocols, which send to the appropriate systems computer information, such as your Internet protocol address, the type of operating system, browser, and name and version of the software you are using, and the language code of the device where you installed the software. Microsoft uses this information to make the Internet-based services available to you. • Software Use and Performance. This software collects info about your hardware and how you use the software and automatically sends error reports to Microsoft.  These reports include information about problems that occur in the software.  Reports might unintentionally contain personal information. For example, a report that contains a snapshot of computer memory might include your name. Part of a document you were working on could be included as well, but this information in reports or any info collected about hardware or your software use will not be used to identify or contact you. • Digital Certificates. The software uses digital certificates. These digital certificates confirm the identity of Internet users sending X.509 standard encryption information. They also can be used to digitally sign files and macros to verify the integrity and origin of the file contents. The software retrieves certificates and updates certificate revocation lists using the Internet, when available. • Windows Application Certification Kit. To ensure you have the latest certification tests, when launched this software periodically checks a Windows Application Certification Kit file on download.microsft.com to see if an update is available.  If an update is found, you are prompted and provided a link to a web site where you can download the update. You may use the Windows Application Certification Kit solely to test your programs before you submit them for a potential Microsoft Windows Certification and for inclusion on the Microsoft Windows Store. The results you receive are for informational purposes only. Microsoft has no obligation to either (i) provide you with a Windows Certification for your programs and/or ii) include your program in the Microsoft Windows Store. • Microsoft Digital Rights Management for Silverlight. • If you use Silverlight to access content that has been protected with Microsoft Digital Rights Management (DRM), in order to let you play the content, the software may automatically • request media usage rights from a rights server on the Internet and • download and install available DRM Updates. • For more information about this feature, including instructions for turning the Automatic Updates off, go to http://go.microsoft.com/fwlink/?LinkId=147032. 1. Web Content Features. Features in the software can retrieve related content from Microsoft and provide it to you. To provide the content, these features send to Microsoft the type of operating system, name and version of the software you are using, type of browser and language code of the device where you installed the software. Examples of these features are clip art, templates, online training, online assistance, help and Appshelp. You may choose not to use these web content features. ii. Use of Information. We may use nformation collected about software use and performance to provide and improve Microsoft software and services as further described in Microsoft’s Privacy Statement available at: https://go.microsoft.com/fwlink/?LinkID=521839. We may also share it with others, such as hardware and software vendors. They may use the information to improve how their products run with Microsoft software. iii. Misuse of Internet-based Services. You may not use these services in any way that could harm them or impair anyone else’s use of them. You may not use the services to try to gain unauthorized access to any service, data, account or network by any means. 3. YOUR COMPLIANCE WITH PRIVACY AND DATA PROTECTION LAWS. a. Personal Information Definition. "Personal Information" means any information relating to an identified or identifiable natural person; an identifiable natural person is one who can be identified, directly or indirectly, in particular by reference to an identifier such as a name, an identification number, location data, an online identifier or to one or more factors specific to the physical, physiological, genetic, mental, economic, cultural or social identity of that natural person. b. Collecting Personal Information using Packaged and Add-on APIs. If you use any API to collect personal information from the software, you must comply with all laws and regulations applicable to your use of the data accessed through APIs including without limitation laws related to privacy, biometric data, data protection, and confidentiality of communications. Your use of the software is conditioned upon implementing and maintaining appropriate protections and measures for your applications and services, and that includes your responsibility to the data obtained through the use of APIs. For the data you obtained through any APIs, you must: i. obtain all necessary consents before collecting and using data and only use the data for the limited purposes to which the user consented, including any consent to changes in use; ii. In the event you’re storing data, ensure that data is kept up to date and implement corrections, restrictions to data, or the deletion of data as updated through packaged or add-on APIs or upon user request if required by applicable law; iii. implement proper retention and deletion policies, including deleting all data when as directed by your users or as required by applicable law; and iv. maintain and comply with a written statement available to your customers that describes your privacy practices regarding data and information you collect, use and that you share with any third parties. c. Location Framework. The software may contain a location framework component or APIs that enable support of location services in programs. Programs that receive device location must comply with the requirements related to the Location Service APIs as described in the Microsoft Store Policies (https://docs.microsoft.com/en-us/legal/windows/agreements/store-policies). If you choose to collect device location data outside of the control of Windows system settings, you must obtain legally sufficient consent for your data practices, and such practices must comply with all other applicable laws and regulations.  d. Security. If your application or service collects, stores or transmits personal information, it must do so securely, by using modern cryptography methods. 4. BACKUP COPY. You may make one backup copy of the software. You may use it only to reinstall the software. 5. DOCUMENTATION. Any person that has valid access to your computer or internal network may copy and use the documentation for your internal, reference purposes. 6. SCOPE OF LICENSE. The software is licensed, not sold. This agreement only gives you some rights to use the software. Microsoft reserves all other rights. Unless applicable law gives you more rights despite this limitation, you may use the software only as expressly permitted in this agreement. In doing so, you must comply with any technical limitations in the software that only allow you to use it in certain ways. You may not • Except for the Microsoft .NET Framework, you must obtain Microsoft's prior written approval to disclose to a third party the results of any benchmark test of the software. • work around any technical limitations in the software; • reverse engineer, decompile or disassemble the software, except and only to the extent that applicable law expressly permits, despite this limitation; • make more copies of the software than specified in this agreement or allowed by applicable law, despite this limitation; • publish the software for others to copy; • rent, lease or lend the software; • transfer the software or this agreement to any third party; or • use the software for commercial software hosting services. 7. EXPORT RESTRICTIONS. The software is subject to United States export laws and regulations. You must comply with all domestic and international export laws and regulations that apply to the software. These laws include restrictions on destinations, end users and end use. For additional information, see www.microsoft.com/exporting. 8. SUPPORT SERVICES. Because this software is “as is,” we may not provide support services for it. 9. ENTIRE AGREEMENT. This agreement, and the terms for supplements, updates, Internet-based services and support services that you use, are the entire agreement for the software and support services. 10. INDEPENDENT PARTIES. Microsoft and you are independent contractors. Nothing in this agreement shall be construed as creating an employer-employee relationship, processor-subprocessor relationship, a partnership, or a joint venture between the parties. 11. APPLICABLE LAW AND PLACE TO RESOLVE DISPUTES. If you acquired the software in the United States or Canada, the laws of the state or province where you live (or, if a business, where your principal place of business is located) govern the interpretation of this agreement, claims for its breach, and all other claims (including consumer protection, unfair competition, and tort claims), regardless of conflict of laws principles. If you acquired the software in any other country, its laws apply. If U.S. federal jurisdiction exists, you and Microsoft consent to exclusive jurisdiction and venue in the federal court in King County, Washington for all disputes heard in court. If not, you and Microsoft consent to exclusive jurisdiction and venue in the Superior Court of King County, Washington for all disputes heard in court. 12. LEGAL EFFECT. This agreement describes certain legal rights. You may have other rights under the laws of your country. You may also have rights with respect to the party from whom you acquired the software. This agreement does not change your rights under the laws of your country if the laws of your country do not permit it to do so. 13. DISCLAIMER OF WARRANTY. The software is licensed “as-is.” You bear the risk of using it. Microsoft gives no express warranties, guarantees or conditions. You may have additional consumer rights or statutory guarantees under your local laws which this agreement cannot change. To the extent permitted under your local laws, Microsoft excludes the implied warranties of merchantability, fitness for a particular purpose and non-infringement. FOR AUSTRALIA – You have statutory guarantees under the Australian Consumer Law and nothing in these terms is intended to affect those rights. 14. LIMITATION ON AND EXCLUSION OF REMEDIES AND DAMAGES. You can recover from Microsoft and its suppliers only direct damages up to U.S. $5.00. You cannot recover any other damages, including consequential, lost profits, special, indirect or incidental damages. This limitation applies to • anything related to the software, services, content (including code) on third party Internet sites, or third party programs; and • claims for breach of contract, breach of warranty, guarantee or condition, strict liability, negligence, or other tort to the extent permitted by applicable law. It also applies even if Microsoft knew or should have known about the possibility of the damages. The above limitation or exclusion may not apply to you because your country may not allow the exclusion or limitation of incidental, consequential or other damages. Please note: As this software is distributed in Quebec, Canada, some of the clauses in this agreement are provided below in French. Remarque : Ce logiciel étant distribué au Québec, Canada, certaines des clauses dans ce contrat sont fournies ci-dessous en français. EXONÉRATION DE GARANTIE. Le logiciel visé par une licence est offert « tel quel ». Toute utilisation de ce logiciel est à votre seule risque et péril. Microsoft n’accorde aucune autre garantie expresse. Vous pouvez bénéficier de droits additionnels en vertu du droit local sur la protection des consommateurs, que ce contrat ne peut modifier. La ou elles sont permises par le droit locale, les garanties implicites de qualité marchande, d’adéquation à un usage particulier et d’absence de contrefaçon sont exclues. LIMITATION DES DOMMAGES-INTÉRÊTS ET EXCLUSION DE RESPONSABILITÉ POUR LES DOMMAGES. Vous pouvez obtenir de Microsoft et de ses fournisseurs une indemnisation en cas de dommages directs uniquement à hauteur de 5,00 $ US. Vous ne pouvez prétendre à aucune indemnisation pour les autres dommages, y compris les dommages spéciaux, indirects ou accessoires et pertes de bénéfices. Crete limitation concern: • tout ce qui est relié au logiciel, aux services ou au contenu (y compris le code) figurant sur des sites Internet tiers ou dans des programmes tiers ; et • les réclamations au titre de violation de contrat ou de garantie, ou au titre de responsabilité stricte, de négligence ou d’une autre faute dans la limite autorisée par la loi en vigueur. Elle s’applique également, même si Microsoft connaissait ou devrait connaître l’éventualité d’un tel dommage. Si votre pays n’autorise pas l’exclusion ou la limitation de responsabilité pour les dommages indirects, accessoires ou de quelque nature que ce soit, il se peut que la limitation ou l’exclusion ci-dessus ne s’appliquera pas à votre égard. EFFET JURIDIQUE. Le présent contrat décrit certains droits juridiques. Vous pourriez avoir d’autres droits prévus par les lois de votre pays. Le présent contrat ne modifie pas les droits que vous confèrent les lois de votre pays si celles-ci ne le permettent pas. *************** EULAID:WIN10SDK.RTM.AUG_2018_en-US ************************************************************************* ``` Microsoft Visual C++ 2019 Runtime ``` MICROSOFT SOFTWARE LICENSE TERMS MICROSOFT VISUAL C++ 2019 RUNTIME These license terms are an agreement between Microsoft Corporation (or based on where you live, one of its affiliates) and you. They apply to the software named above. The terms also apply to any Microsoft services or updates for the software, except to the extent those have different terms. IF YOU COMPLY WITH THESE LICENSE TERMS, YOU HAVE THE RIGHTS BELOW. - INSTALLATION AND USE RIGHTS. - You may install and use any number of copies of the software. - TERMS FOR SPECIFIC COMPONENTS. - MICROSOFT PLATFORMS. The software may include components from Microsoft Windows; Microsoft Windows Server; Microsoft SQL Server; Microsoft Exchange; Microsoft Office; and Microsoft SharePoint. These components are governed by separate agreements and their own product support policies, as described in the Microsoft “Licenses” folder accompanying the software, except that, if license terms for those components are also included in the associated installation directory, those license terms control. - THIRD PARTY COMPONENTS.  The software may include third party components with separate legal notices or governed by other agreements, as may be described in the ThirdPartyNotices file(s) accompanying the software.  - SCOPE OF LICENSE. The software is licensed, not sold. This agreement only gives you some rights to use the software. Microsoft reserves all other rights. Unless applicable law gives you more rights despite this limitation, you may use the software only as expressly permitted in this agreement. In doing so, you must comply with any technical limitations in the software that only allow you to use it in certain ways. You may not - work around any technical limitations in the software; - reverse engineer, decompile or disassemble the software, or otherwise attempt to derive the source code for the software except, and only to the extent required by third party licensing terms governing the use of certain open source components that may be included in the software; - remove, minimize, block or modify any notices of Microsoft or its suppliers in the software; - use the software in any way that is against the law; or - share, publish, rent or lease the software, or provide the software as a stand-alone offering for others to use, or transfer the software or this agreement to any third party. - EXPORT RESTRICTIONS. You must comply with all domestic and international export laws and regulations that apply to the software, which include restrictions on destinations, end users, and end use. For further information on export restrictions, visit www.microsoft.com/exporting. - SUPPORT SERVICES. Because this software is “as is,” we may not provide support services for it. - ENTIRE AGREEMENT. This agreement, and the terms for supplements, updates, Internet-based services and support services that you use, are the entire agreement for the software and support services. - APPLICABLE LAW. If you acquired the software in the United States, Washington law applies to interpretation of and claims for breach of this agreement, and the laws of the state where you live apply to all other claims. If you acquired the software in any other country, its laws apply. - CONSUMER RIGHTS; REGIONAL VARIATIONS. This agreement describes certain legal rights. You may have other rights, including consumer rights, under the laws of your state or country. Separate and apart from your relationship with Microsoft, you may also have rights with respect to the party from which you acquired the software. This agreement does not change those other rights if the laws of your state or country do not permit it to do so. For example, if you acquired the software in one of the below regions, or mandatory country law applies, then the following provisions apply to you: - AUSTRALIA. You have statutory guarantees under the Australian Consumer Law and nothing in this agreement is intended to affect those rights. - CANADA. If you acquired this software in Canada, you may stop receiving updates by turning off the automatic update feature, disconnecting your device from the Internet (if and when you re-connect to the Internet, however, the software will resume checking for and installing updates), or uninstalling the software. The product documentation, if any, may also specify how to turn off updates for your specific device or software. - GERMANY AND AUSTRIA. (i) WARRANTY. The properly licensed software will perform substantially as described in any Microsoft materials that accompany the software. However, Microsoft gives no contractual guarantee in relation to the licensed software. (ii) LIMITATION OF LIABILITY. In case of intentional conduct, gross negligence, claims based on the Product Liability Act, as well as, in case of death or personal or physical injury, Microsoft is liable according to the statutory law. - Subject to the foregoing clause (ii), Microsoft will only be liable for slight negligence if Microsoft is in breach of such material contractual obligations, the fulfillment of which facilitate the due performance of this agreement, the breach of which would endanger the purpose of this agreement and the compliance with which a party may constantly trust in (so-called "cardinal obligations"). In other cases of slight negligence, Microsoft will not be liable for slight negligence. - DISCLAIMER OF WARRANTY. THE SOFTWARE IS LICENSED “AS-IS.” YOU BEAR THE RISK OF USING IT. MICROSOFT GIVES NO EXPRESS WARRANTIES, GUARANTEES OR CONDITIONS. TO THE EXTENT PERMITTED UNDER YOUR LOCAL LAWS, MICROSOFT EXCLUDES THE IMPLIED WARRANTIES OF MERCHANTABILITY, FITNESS FOR A PARTICULAR PURPOSE AND NON-INFRINGEMENT. - LIMITATION ON AND EXCLUSION OF DAMAGES. YOU CAN RECOVER FROM MICROSOFT AND ITS SUPPLIERS ONLY DIRECT DAMAGES UP TO U.S. $5.00. YOU CANNOT RECOVER ANY OTHER DAMAGES, INCLUDING CONSEQUENTIAL, LOST PROFITS, SPECIAL, INDIRECT OR INCIDENTAL DAMAGES. This limitation applies to (a) anything related to the software, services, content (including code) on third party Internet sites, or third party applications; and (b) claims for breach of contract, breach of warranty, guarantee or condition, strict liability, negligence, or other tort to the extent permitted by applicable law. It also applies even if Microsoft knew or should have known about the possibility of the damages. The above limitation or exclusion may not apply to you because your country may not allow the exclusion or limitation of incidental, consequential or other damages. ``` napari-0.5.0a1/LICENSE000066400000000000000000000027421437041365600142330ustar00rootroot00000000000000BSD 3-Clause License Copyright (c) 2018, Napari All rights reserved. Redistribution and use in source and binary forms, with or without modification, are permitted provided that the following conditions are met: * Redistributions of source code must retain the above copyright notice, this list of conditions and the following disclaimer. * Redistributions in binary form must reproduce the above copyright notice, this list of conditions and the following disclaimer in the documentation and/or other materials provided with the distribution. * Neither the name of the copyright holder nor the names of its contributors may be used to endorse or promote products derived from this software without specific prior written permission. THIS SOFTWARE IS PROVIDED BY THE COPYRIGHT HOLDERS AND CONTRIBUTORS "AS IS" AND ANY EXPRESS OR IMPLIED WARRANTIES, INCLUDING, BUT NOT LIMITED TO, THE IMPLIED WARRANTIES OF MERCHANTABILITY AND FITNESS FOR A PARTICULAR PURPOSE ARE DISCLAIMED. IN NO EVENT SHALL THE COPYRIGHT HOLDER OR CONTRIBUTORS BE LIABLE FOR ANY DIRECT, INDIRECT, INCIDENTAL, SPECIAL, EXEMPLARY, OR CONSEQUENTIAL DAMAGES (INCLUDING, BUT NOT LIMITED TO, PROCUREMENT OF SUBSTITUTE GOODS OR SERVICES; LOSS OF USE, DATA, OR PROFITS; OR BUSINESS INTERRUPTION) HOWEVER CAUSED AND ON ANY THEORY OF LIABILITY, WHETHER IN CONTRACT, STRICT LIABILITY, OR TORT (INCLUDING NEGLIGENCE OR OTHERWISE) ARISING IN ANY WAY OUT OF THE USE OF THIS SOFTWARE, EVEN IF ADVISED OF THE POSSIBILITY OF SUCH DAMAGE. napari-0.5.0a1/MANIFEST.in000066400000000000000000000012171437041365600147600ustar00rootroot00000000000000include LICENSE include *.cff graft napari/_vendor recursive-include napari *.pyi recursive-include napari _tests/*.py recursive-include napari_builtins _tests/*.py recursive-include napari *.pyi recursive-include napari *.png *.svg *.qss *.gif *.ico *.icns recursive-include napari *.yaml # explicit excludes to keep check-manifest happy and remind us that # these things are not being included unless we ask recursive-exclude tools * recursive-exclude napari *.pyc exclude napari/benchmarks/* recursive-exclude resources * recursive-exclude binder * recursive-exclude examples * exclude bundle.py exclude dockerfile exclude EULA.md exclude Singularity napari-0.5.0a1/Makefile000066400000000000000000000022361437041365600146640ustar00rootroot00000000000000.PHONY: typestubs pre watch dist settings-schema typestubs: python -m napari.utils.stubgen # note: much faster to run mypy as daemon, # dmypy run -- ... # https://mypy.readthedocs.io/en/stable/mypy_daemon.html typecheck: mypy napari/settings napari/types.py napari/plugins check-manifest: pip install -U check-manifest check-manifest dist: typestubs check-manifest pip install -U build python -m build settings-schema: python -m napari.settings._napari_settings pre: pre-commit run -a # If the first argument is "watch"... ifeq (watch,$(firstword $(MAKECMDGOALS))) # use the rest as arguments for "watch" WATCH_ARGS := $(wordlist 2,$(words $(MAKECMDGOALS)),$(MAKECMDGOALS)) # ...and turn them into do-nothing targets $(eval $(WATCH_ARGS):;@:) endif # examples: # make watch ~/Desktop/Untitled.png # make watch -- -w animation # -- is required for passing flags to napari watch: @echo "running: napari $(WATCH_ARGS)" @echo "Save any file to restart napari\nCtrl-C to stop..\n" && \ watchmedo auto-restart -R \ --ignore-patterns="*.pyc*" -D \ --signal SIGKILL \ napari -- $(WATCH_ARGS) || \ echo "please run 'pip install watchdog[watchmedo]'" napari-0.5.0a1/README.md000066400000000000000000000167521437041365600145130ustar00rootroot00000000000000# napari ### multi-dimensional image viewer for python [![napari on Binder](https://mybinder.org/badge_logo.svg)](https://mybinder.org/v2/gh/napari/napari/main?urlpath=%2Fdesktop) [![image.sc forum](https://img.shields.io/badge/dynamic/json.svg?label=forum&url=https%3A%2F%2Fforum.image.sc%2Ftags%2Fnapari.json&query=%24.topic_list.tags.0.topic_count&colorB=brightgreen&suffix=%20topics&logo=data:image/png;base64,iVBORw0KGgoAAAANSUhEUgAAAA4AAAAOCAYAAAAfSC3RAAABPklEQVR42m3SyyqFURTA8Y2BER0TDyExZ+aSPIKUlPIITFzKeQWXwhBlQrmFgUzMMFLKZeguBu5y+//17dP3nc5vuPdee6299gohUYYaDGOyyACq4JmQVoFujOMR77hNfOAGM+hBOQqB9TjHD36xhAa04RCuuXeKOvwHVWIKL9jCK2bRiV284QgL8MwEjAneeo9VNOEaBhzALGtoRy02cIcWhE34jj5YxgW+E5Z4iTPkMYpPLCNY3hdOYEfNbKYdmNngZ1jyEzw7h7AIb3fRTQ95OAZ6yQpGYHMMtOTgouktYwxuXsHgWLLl+4x++Kx1FJrjLTagA77bTPvYgw1rRqY56e+w7GNYsqX6JfPwi7aR+Y5SA+BXtKIRfkfJAYgj14tpOF6+I46c4/cAM3UhM3JxyKsxiOIhH0IO6SH/A1Kb1WBeUjbkAAAAAElFTkSuQmCC)](https://forum.image.sc/tag/napari) [![License](https://img.shields.io/pypi/l/napari.svg)](https://github.com/napari/napari/raw/main/LICENSE) [![Build Status](https://api.cirrus-ci.com/github/Napari/napari.svg)](https://cirrus-ci.com/napari/napari) [![Code coverage](https://codecov.io/gh/napari/napari/branch/main/graph/badge.svg)](https://codecov.io/gh/napari/napari) [![Supported Python versions](https://img.shields.io/pypi/pyversions/napari.svg)](https://python.org) [![Python package index](https://img.shields.io/pypi/v/napari.svg)](https://pypi.org/project/napari) [![Python package index download statistics](https://img.shields.io/pypi/dm/napari.svg)](https://pypistats.org/packages/napari) [![Development Status](https://img.shields.io/pypi/status/napari.svg)](https://en.wikipedia.org/wiki/Software_release_life_cycle#Alpha) [![Code style: black](https://img.shields.io/badge/code%20style-black-000000.svg)](https://github.com/python/black) [![DOI](https://zenodo.org/badge/144513571.svg)](https://zenodo.org/badge/latestdoi/144513571) **napari** is a fast, interactive, multi-dimensional image viewer for Python. It's designed for browsing, annotating, and analyzing large multi-dimensional images. It's built on top of Qt (for the GUI), vispy (for performant GPU-based rendering), and the scientific Python stack (numpy, scipy). We're developing **napari** in the open! But the project is in an **alpha** stage, and there will still likely be **breaking changes** with each release. You can follow progress on [this repository](https://github.com/napari/napari), test out new versions as we release them, and contribute ideas and code. We're working on [tutorials](https://napari.org/tutorials/), but you can also quickly get started by looking below. ## installation It is recommended to install napari into a virtual environment, like this: ```sh conda create -y -n napari-env -c conda-forge python=3.9 conda activate napari-env python -m pip install "napari[all]" ``` If you prefer conda over pip, you can replace the last line with: `conda install -c conda-forge napari` See here for the full [installation guide](https://napari.org/tutorials/fundamentals/installation.html), including how to [install napari as a bundled app](https://napari.org/tutorials/fundamentals/installation.html#install-as-a-bundled-app). ## simple example (The examples below require the `scikit-image` package to run. We just use data samples from this package for demonstration purposes. If you change the examples to use your own dataset, you may not need to install this package.) From inside an IPython shell, you can open up an interactive viewer by calling ```python from skimage import data import napari viewer = napari.view_image(data.cells3d(), channel_axis=1, ndisplay=3) ``` ![napari viewer with a multichannel image of cells displayed as two image layers: nuclei and membrane.](https://github.com/napari/docs/blob/main/docs/images/multichannel_cells.png) To use napari from inside a script, use `napari.run()`: ```python from skimage import data import napari viewer = napari.view_image(data.cells3d(), channel_axis=1, ndisplay=3) napari.run() # start the "event loop" and show the viewer ``` ## features Check out the scripts in our [`examples` folder](examples) to see some of the functionality we're developing! **napari** supports six main different layer types, `Image`, `Labels`, `Points`, `Vectors`, `Shapes`, and `Surface`, each corresponding to a different data type, visualization, and interactivity. You can add multiple layers of different types into the viewer and then start working with them, adjusting their properties. All our layer types support n-dimensional data and the viewer provides the ability to quickly browse and visualize either 2D or 3D slices of the data. **napari** also supports bidirectional communication between the viewer and the Python kernel, which is especially useful when launching from jupyter notebooks or when using our built-in console. Using the console allows you to interactively load and save data from the viewer and control all the features of the viewer programmatically. You can extend **napari** using custom shortcuts, key bindings, and mouse functions. ## tutorials For more details on how to use `napari` checkout our [tutorials](https://napari.org/tutorials/). These are still a work in progress, but we'll be updating them regularly. ## mission, values, and roadmap For more information about our plans for `napari` you can read our [mission and values statement](https://napari.org/community/mission_and_values.html), which includes more details on our vision for supporting a plugin ecosystem around napari. You can see details of [the project roadmap here](https://napari.org/roadmaps/index.html). ## contributing Contributions are encouraged! Please read our [contributing guide](https://napari.org/developers/contributing.html) to get started. Given that we're in an early stage, you may want to reach out on our [Github Issues](https://github.com/napari/napari/issues) before jumping in. ## code of conduct `napari` has a [Code of Conduct](https://napari.org/community/code_of_conduct.html) that should be honored by everyone who participates in the `napari` community. ## governance You can learn more about how the `napari` project is organized and managed from our [governance model](https://napari.org/community/governance.html), which includes information about, and ways to contact the [@napari/steering-council and @napari/core-devs](https://napari.org/community/team.html#current-core-developers). ## citing napari If you find `napari` useful please cite [this repository](https://github.com/napari/napari) using its DOI as follows: > napari contributors (2019). napari: a multi-dimensional image viewer for python. [doi:10.5281/zenodo.3555620](https://zenodo.org/record/3555620) Note this DOI will resolve to all versions of napari. To cite a specific version please find the DOI of that version on our [zenodo page](https://zenodo.org/record/3555620). The DOI of the latest version is in the badge at the top of this page. ## help We're a community partner on the [image.sc forum](https://forum.image.sc/tags/napari) and all help and support requests should be posted on the forum with the tag `napari`. We look forward to interacting with you there. Bug reports should be made on our [github issues](https://github.com/napari/napari/issues/new?template=bug_report.md) using the bug report template. If you think something isn't working, don't hesitate to reach out - it is probably us and not you! ## institutional and funding partners ![CZI logo](https://chanzuckerberg.com/wp-content/themes/czi/img/logo.svg) napari-0.5.0a1/Singularity000066400000000000000000000001371437041365600154570ustar00rootroot00000000000000BootStrap: docker From: ghcr.io/napari/napari:main %post date +"%Y-%m-%d-%H%M" > /last_update napari-0.5.0a1/asv.conf.json000066400000000000000000000036441437041365600156400ustar00rootroot00000000000000{ // The version of the config file format. Do not change, unless // you know what you are doing. "version": 1, // The name of the project being benchmarked "project": "napari", // The project's homepage "project_url": "http://napari.org/", // The URL or local path of the source code repository for the // project being benchmarked "repo": ".", // Install using default qt install "build_command": ["python -V"], // skip build stage "install_command": ["in-dir={env_dir} python -m pip install {build_dir}[all,testing]"], "uninstall_command": ["in-dir={env_dir} python -m pip uninstall -y {project}"], // List of branches to benchmark "branches": ["main"], // The tool to use to create environments. "environment_type": "virtualenv", // timeout in seconds for installing any dependencies in environment "install_timeout": 600, // the base URL to show a commit for the project. "show_commit_url": "http://github.com/napari/napari/commit/", // The Pythons you'd like to test against. "pythons": ["3.9"], // The directory (relative to the current directory) to cache the Python // environments in. "env_dir": ".asv/env", // The directory (relative to the current directory) that raw benchmark // results are stored in. "results_dir": ".asv/results", // The directory (relative to the current directory) that the html tree // should be written to. "html_dir": ".asv/html", // The directory (relative to the current directory) where the benchamrks // are stored "benchmark_dir": "napari/benchmarks", // The number of characters to retain in the commit hashes. "hash_length": 8, // `asv` will cache results of the recent builds in each // environment, making them faster to install next time. This is // the number of builds to keep, per environment. "build_cache_size": 2, } napari-0.5.0a1/binder/000077500000000000000000000000001437041365600144645ustar00rootroot00000000000000napari-0.5.0a1/binder/Desktop/000077500000000000000000000000001437041365600160755ustar00rootroot00000000000000napari-0.5.0a1/binder/Desktop/napari.desktop000077500000000000000000000002211437041365600207400ustar00rootroot00000000000000[Desktop Entry] Version=1.0 Type=Application Name=napari 0.4.x Exec=napari Icon=/home/jovyan/napari/resources/icon.ico Path=/home/jovyan/Desktop napari-0.5.0a1/binder/apt.txt000066400000000000000000000002271437041365600160120ustar00rootroot00000000000000dbus-x11 xfce4 xfce4-panel xfce4-session xfce4-settings xorg xubuntu-icon-theme libxss1 libpci3 libasound2 fonts-ubuntu qutebrowser htop nano libgles2 napari-0.5.0a1/binder/environment.yml000066400000000000000000000016731437041365600175620ustar00rootroot00000000000000channels: - conda-forge # Used by jupyter-desktop-server dependencies: # See: https://github.com/conda-forge/napari-feedstock/blob/master/recipe/meta.yaml - python >=3.8 # dependencies matched to pip - appdirs >=1.4.4 - cachey >=0.2.1 - certifi >=2020.6.20 - dask >=2.1.0 - imageio >=2.5.0 - importlib-metadata >=1.5.0 # not needed for py>37 but keeping for noarch - jsonschema >=3.2.0 - magicgui >=0.2.6 - napari-console >=0.0.4 - napari-plugin-engine >=0.1.9 - napari-svg >=0.1.4 - numpy >=1.18.5 - numpydoc >=0.9.2 - pillow - pint >=0.17 - psutil >=5.0 - pyopengl >=3.1.0 - pyyaml >=5.1 - pydantic >=1.8.1 - qtpy >=1.7.0 - scipy >=1.2.0 - superqt >=0.2.2 - tifffile >=2020.2.16 - typing_extensions - toolz >=0.10.0 - tqdm >=4.56.0 - vispy >=0.6.4 - wrapt >=1.11.1 # additional dependencies for convenience in conda-forge - fsspec - pyqt - scikit-image - zarr # Required for desktop view on mybinder.org - websockify - pip: - jupyter-desktop-server napari-0.5.0a1/binder/postBuild000077500000000000000000000005411437041365600163570ustar00rootroot00000000000000#!/bin/bash set -euo pipefail cp -r binder/Desktop ${HOME}/Desktop # Apply our Xfce settings mkdir -p ${HOME}/.config/xfce4/xfconf/xfce-perchannel-xml cp binder/xsettings.xml ${HOME}/.config/xfce4/xfconf/xfce-perchannel-xml/ cp binder/xfce4-panel.xml ${HOME}/.config/xfce4/xfconf/xfce-perchannel-xml/ # Install napari pip install ${HOME}/ --no-deps napari-0.5.0a1/binder/xfce4-panel.xml000066400000000000000000000027241437041365600173210ustar00rootroot00000000000000 napari-0.5.0a1/binder/xsettings.xml000066400000000000000000000033221437041365600172360ustar00rootroot00000000000000 napari-0.5.0a1/bundle.py000066400000000000000000000223241437041365600150470ustar00rootroot00000000000000import configparser import os import platform import re import shutil import subprocess import sys import time from contextlib import contextmanager from pathlib import Path import tomlkit APP = 'napari' # EXTRA_REQS will be added to the bundle, in addition to those specified in # setup.cfg. To add additional packages to the bundle, or to override any of # the packages listed here or in `setup.cfg, use the `--add` command line # argument with a series of "pip install" style strings when running this file. # For example, the following will ADD ome-zarr, and CHANGE the version of # PySide2: # python bundle.py --add 'PySide2==5.15.0' 'ome-zarr' # This is now defined in setup.cfg "options.extras_require.bundle_run" # EXTRA_REQS = [] WINDOWS = os.name == 'nt' MACOS = sys.platform == 'darwin' LINUX = sys.platform.startswith("linux") HERE = os.path.abspath(os.path.dirname(__file__)) PYPROJECT_TOML = os.path.join(HERE, 'pyproject.toml') SETUP_CFG = os.path.join(HERE, 'setup.cfg') ARCH = (platform.machine() or "generic").lower().replace("amd64", "x86_64") if WINDOWS: BUILD_DIR = os.path.join(HERE, 'windows') APP_DIR = os.path.join(BUILD_DIR, APP, 'src') EXT, OS = 'msi', 'Windows' elif LINUX: BUILD_DIR = os.path.join(HERE, 'linux') APP_DIR = os.path.join(BUILD_DIR, APP, f'{APP}.AppDir') EXT, OS = 'AppImage', 'Linux' elif MACOS: BUILD_DIR = os.path.join(HERE, 'macOS') APP_DIR = os.path.join(BUILD_DIR, APP, f'{APP}.app') EXT, OS = 'dmg', 'macOS' with open(os.path.join(HERE, "napari", "_version.py")) as f: match = re.search(r'version\s?=\s?\'([^\']+)', f.read()) if match: VERSION = match.groups()[0].split('+')[0] @contextmanager def patched_toml(): parser = configparser.ConfigParser() parser.read(SETUP_CFG) requirements = parser.get("options", "install_requires").splitlines() requirements = [r.split('#')[0].strip() for r in requirements if r] with open(PYPROJECT_TOML) as f: original_toml = f.read() toml = tomlkit.parse(original_toml) # Initialize EXTRA_REQS from setup.cfg 'options.extras_require.bundle_run' bundle_run = parser.get("options.extras_require", "bundle_run") EXTRA_REQS = [ requirement.split('#')[0].strip() for requirement in bundle_run.splitlines() if requirement ] # parse command line arguments if '--add' in sys.argv: for item in sys.argv[sys.argv.index('--add') + 1 :]: if item.startswith('-'): break EXTRA_REQS.append(item) for item in EXTRA_REQS: _base = re.split('<|>|=', item, maxsplit=1)[0] for r in requirements: if r.startswith(_base): requirements.remove(r) break if _base.lower().startswith('pyqt5'): try: i = next(x for x in requirements if x.startswith('PySide')) requirements.remove(i) except StopIteration: pass requirements += EXTRA_REQS toml['tool']['briefcase']['app'][APP]['requires'] = requirements toml['tool']['briefcase']['version'] = VERSION print("patching pyproject.toml to version: ", VERSION) print( "patching pyproject.toml requirements to:", *toml['tool']['briefcase']['app'][APP]['requires'], sep="\n ", ) if MACOS: # Workaround https://github.com/napari/napari/issues/2965 # Pin revisions to releases _before_ they switched to static libs revision = { (3, 6): '11', (3, 7): '5', (3, 8): '4', (3, 9): '1', }[sys.version_info[:2]] app_table = toml['tool']['briefcase']['app'][APP] app_table.add('macOS', tomlkit.table()) app_table['macOS']['support_revision'] = revision print( "patching pyproject.toml to pin support package to revision:", revision, ) with open(PYPROJECT_TOML, 'w') as f: f.write(tomlkit.dumps(toml)) try: yield finally: with open(PYPROJECT_TOML, 'w') as f: f.write(original_toml) @contextmanager def patched_dmgbuild(): if not MACOS: yield else: from dmgbuild import core with open(core.__file__) as f: src = f.read() with open(core.__file__, 'w') as f: f.write( src.replace( "shutil.rmtree(os.path.join(mount_point, '.Trashes'), True)", "shutil.rmtree(os.path.join(mount_point, '.Trashes'), True);time.sleep(30)", ) ) print("patched dmgbuild.core") try: yield finally: # undo with open(core.__file__, 'w') as f: f.write(src) def add_site_packages_to_path(): # on mac, make sure the site-packages folder exists even before the user # has pip installed, so it is in sys.path on the first run # (otherwise, newly installed plugins will not be detected until restart) if MACOS: pkgs_dir = os.path.join( APP_DIR, 'Contents', 'Resources', 'Support', 'lib', f'python{sys.version_info.major}.{sys.version_info.minor}', 'site-packages', ) os.makedirs(pkgs_dir) print("created site-packages at", pkgs_dir) # on windows, briefcase uses a _pth file to determine the sys.path at # runtime. https://docs.python.org/3/using/windows.html#finding-modules # We update that file with the eventual location of pip site-packages elif WINDOWS: py = "".join(map(str, sys.version_info[:2])) python_dir = os.path.join(BUILD_DIR, APP, 'src', 'python') pth = os.path.join(python_dir, f'python{py}._pth') with open(pth, "a") as f: # Append 'hello' at the end of file f.write(".\\\\Lib\\\\site-packages\n") print("added bundled site-packages to", pth) pkgs_dir = os.path.join(python_dir, 'Lib', 'site-packages') os.makedirs(pkgs_dir) print("created site-packages at", pkgs_dir) with open(os.path.join(pkgs_dir, 'readme.txt'), 'w') as f: f.write("this is where plugin packages will go") def patch_wxs(): # must run after briefcase create fname = os.path.join(BUILD_DIR, APP, f'{APP}.wxs') if os.path.exists(fname): with open(fname) as f: source = f.read() with open(fname, 'w') as f: f.write(source.replace('pythonw.exe', 'python.exe')) print("patched pythonw.exe -> python.exe") def patch_python_lib_location(): # must run after briefcase create support = os.path.join( BUILD_DIR, APP, APP + ".app", "Contents", "Resources", "Support" ) python_resources = os.path.join(support, "Python", "Resources") if os.path.exists(python_resources): return os.makedirs(python_resources, exist_ok=True) for subdir in ("bin", "lib"): orig = os.path.join(support, subdir) dest = os.path.join(python_resources, subdir) os.symlink("../../" + subdir, dest) print("symlinking", orig, "to", dest) def add_sentinel_file(): if MACOS: (Path(APP_DIR) / "Contents" / "MacOS" / ".napari_is_bundled").touch() elif LINUX: (Path(APP_DIR) / "usr" / "bin" / ".napari_is_bundled").touch() elif WINDOWS: (Path(APP_DIR) / "python" / ".napari_is_bundled").touch() else: print("!!! Sentinel files not yet implemented in", sys.platform) def patch_environment_variables(): os.environ["ARCH"] = ARCH def make_zip(): import glob import zipfile artifact = glob.glob(os.path.join(BUILD_DIR, f"*.{EXT}"))[0] dest = f'napari-{VERSION}-{OS}-{ARCH}.zip' with zipfile.ZipFile(dest, 'w', zipfile.ZIP_DEFLATED) as zf: zf.write(artifact, arcname=os.path.basename(artifact)) print("created zipfile: ", dest) return dest def clean(): shutil.rmtree(BUILD_DIR, ignore_errors=True) def bundle(): clean() if LINUX: patch_environment_variables() # smoke test, and build resources subprocess.check_call([sys.executable, '-m', APP, '--info']) # the briefcase calls need to happen while the pyproject toml is patched with patched_toml(), patched_dmgbuild(): # create cmd = ['briefcase', 'create', '-v'] + ( ['--no-docker'] if LINUX else [] ) subprocess.check_call(cmd) time.sleep(0.5) add_site_packages_to_path() add_sentinel_file() if WINDOWS: patch_wxs() elif MACOS: patch_python_lib_location() # build cmd = ['briefcase', 'build', '-v'] + (['--no-docker'] if LINUX else []) subprocess.check_call(cmd) # package cmd = ['briefcase', 'package', '-v'] cmd += ['--no-sign'] if MACOS else (['--no-docker'] if LINUX else []) subprocess.check_call(cmd) # compress dest = make_zip() clean() return dest if __name__ == "__main__": if '--clean' in sys.argv: clean() sys.exit() if '--version' in sys.argv: print(VERSION) sys.exit() if '--arch' in sys.argv: print(ARCH) sys.exit() print('created', bundle()) napari-0.5.0a1/codecov.yml000066400000000000000000000016201437041365600153650ustar00rootroot00000000000000ignore: - napari/_version.py - napari/resources - napari/benchmarks coverage: status: project: default: false library: target: auto paths: ['!.*/_tests/.*'] threshold: 1% qt: target: auto paths: ['napari/_qt/.*', '!.*/_tests/.*'] threshold: 1% layers: target: auto paths: [ 'napari/layers/.*', '!.*/_tests/.*' ] threshold: 1% utils: target: auto paths: [ 'napari/utils/.*', '!.*/_tests/.*' ] threshold: 2% tests: target: auto paths: ['.*/_tests/.*'] threshold: 1% # coverage can drop by up to 1% while still posting success patch: default: threshold: 1% target: 0% codecov: notify: after_n_builds: 11 comment: require_changes: true # if true: only post the PR comment if coverage changes after_n_builds: 11napari-0.5.0a1/dockerfile000066400000000000000000000044441437041365600152610ustar00rootroot00000000000000FROM --platform=linux/amd64 ubuntu:22.04 AS napari # if you change the Ubuntu version, remember to update # the APT definitions for Xpra below so it reflects the # new codename (e.g. 20.04 was focal, 22.04 had jammy) # below env var required to install libglib2.0-0 non-interactively ENV TZ=America/Los_Angeles ARG DEBIAN_FRONTEND=noninteractive # install python resources + graphical libraries used by qt and vispy RUN apt-get update && \ apt-get install -qqy \ build-essential \ python3.9 \ python3-pip \ git \ mesa-utils \ libgl1-mesa-glx \ libglib2.0-0 \ libfontconfig1 \ libxrender1 \ libdbus-1-3 \ libxkbcommon-x11-0 \ libxi6 \ libxcb-icccm4 \ libxcb-image0 \ libxcb-keysyms1 \ libxcb-randr0 \ libxcb-render-util0 \ libxcb-xinerama0 \ libxcb-xinput0 \ libxcb-xfixes0 \ libxcb-shape0 \ && apt-get clean # install napari release version RUN pip3 install napari[all] # copy examples COPY examples /tmp/examples ENTRYPOINT ["python3", "-m", "napari"] ######################################################### # Extend napari with a preconfigured Xpra server target # ######################################################### FROM napari AS napari-xpra # Install Xpra and dependencies RUN apt-get install -y wget gnupg2 apt-transport-https && \ wget -O - https://xpra.org/gpg.asc | apt-key add - && \ echo "deb https://xpra.org/ jammy main" > /etc/apt/sources.list.d/xpra.list RUN apt-get update && \ apt-get install -yqq \ xpra \ xvfb \ xterm \ sshfs && \ apt-get clean && \ rm -rf /var/lib/apt/lists/* ENV DISPLAY=:100 ENV XPRA_PORT=9876 ENV XPRA_START="python3 -m napari" ENV XPRA_EXIT_WITH_CLIENT="yes" ENV XPRA_XVFB_SCREEN="1920x1080x24+32" EXPOSE 9876 CMD echo "Launching napari on Xpra. Connect via http://localhost:$XPRA_PORT"; \ xpra start \ --bind-tcp=0.0.0.0:$XPRA_PORT \ --html=on \ --start="$XPRA_START" \ --exit-with-client="$XPRA_EXIT_WITH_CLIENT" \ --daemon=no \ --xvfb="/usr/bin/Xvfb +extension Composite -screen 0 $XPRA_XVFB_SCREEN -nolisten tcp -noreset" \ --pulseaudio=no \ --notifications=no \ --bell=no \ $DISPLAY ENTRYPOINT [] napari-0.5.0a1/examples/000077500000000000000000000000001437041365600150375ustar00rootroot00000000000000napari-0.5.0a1/examples/3D_paths.py000066400000000000000000000017251437041365600170630ustar00rootroot00000000000000""" 3D Paths ======== Display two vectors layers ontop of a 4-D image layer. One of the vectors layers is 3D and "sliced" with a different set of vectors appearing on different 3D slices. Another is 2D and "broadcast" with the same vectors appearing on each slice. .. tags:: visualization-advanced, layers """ import numpy as np from skimage import data import napari blobs = data.binary_blobs( length=128, blob_size_fraction=0.05, n_dim=3, volume_fraction=0.05 ) viewer = napari.Viewer(ndisplay=3) viewer.add_image(blobs.astype(float)) # sample vector coord-like data path = np.array([np.array([[0, 0, 0], [0, 10, 10], [0, 5, 15], [20, 5, 15], [56, 70, 21], [127, 127, 127]]), np.array([[0, 0, 0], [0, 10, 10], [0, 5, 15], [0, 5, 15], [0, 70, 21], [0, 127, 127]])]) print('Path', path.shape) layer = viewer.add_shapes( path, shape_type='path', edge_width=4, edge_color=['red', 'blue'] ) if __name__ == '__main__': napari.run() napari-0.5.0a1/examples/3Dimage_plane_rendering.py000066400000000000000000000026531437041365600221040ustar00rootroot00000000000000""" 3D image plane rendering ======================== Display one 3D image layer and display it as a plane with a simple widget for modifying plane parameters. .. tags:: visualization-advanced, gui, layers """ import numpy as np from skimage import data import napari from napari.utils.translations import trans viewer = napari.Viewer(ndisplay=3) # add a 3D image blobs = data.binary_blobs( length=64, volume_fraction=0.1, n_dim=3 ).astype(np.float32) image_layer = viewer.add_image( blobs, rendering='mip', name='volume', blending='additive', opacity=0.25 ) # add the same 3D image and render as plane # plane should be in 'additive' blending mode or depth looks all wrong plane_parameters = { 'position': (32, 32, 32), 'normal': (0, 1, 0), 'thickness': 10, } plane_layer = viewer.add_image( blobs, rendering='average', name='plane', depiction='plane', blending='additive', opacity=0.5, plane=plane_parameters ) viewer.axes.visible = True viewer.camera.angles = (45, 45, 45) viewer.camera.zoom = 5 viewer.text_overlay.text = trans._( """ shift + click and drag to move the plane press 'x', 'y' or 'z' to orient the plane along that axis around the cursor press 'o' to orient the plane normal along the camera view direction press and hold 'o' then click and drag to make the plane normal follow the camera """ ) viewer.text_overlay.visible = True if __name__ == '__main__': napari.run() napari-0.5.0a1/examples/3d_kymograph_.py000066400000000000000000000120601437041365600201360ustar00rootroot00000000000000""" 3D Kymographs ============= This example demonstrates that the volume rendering capabilities of napari can also be used to render 2d timelapse acquisitions as kymographs. .. tags:: experimental """ from itertools import product import numpy as np from tqdm import tqdm import napari try: from omero.gateway import BlitzGateway except ModuleNotFoundError: print("Could not import BlitzGateway which is") print("required to download the sample datasets.") print("Please install omero-py:") print("https://pypi.org/project/omero-py/") exit(-1) def IDR_fetch_image(image_id: int, progressbar: bool = True) -> np.ndarray: """ Download the image with id image_id from the IDR Will fetch all image planes corresponding to separate timepoints/channels/z-slices and return a numpy array with dimension order (t,z,y,x,c) Displaying download progress can be disabled by passing False to progressbar. """ conn = BlitzGateway( host="ws://idr.openmicroscopy.org/omero-ws", username="public", passwd="public", secure=True, ) conn.connect() conn.c.enableKeepAlive(60) idr_img = conn.getObject("Image", image_id) idr_pixels = idr_img.getPrimaryPixels() _ = idr_img nt, nz, ny, nx, nc = ( _.getSizeT(), _.getSizeZ(), _.getSizeY(), _.getSizeX(), _.getSizeC(), ) plane_indices = list(product(range(nz), range(nc), range(nt))) idr_plane_iterator = idr_pixels.getPlanes(plane_indices) if progressbar: idr_plane_iterator = tqdm(idr_plane_iterator, total=len(plane_indices)) _tmp = np.asarray(list(idr_plane_iterator)) _tmp = _tmp.reshape((nz, nc, nt, ny, nx)) # the following line reorders the axes (no summing, despite the name) return np.einsum("jmikl", _tmp) description = """ 3D-Kymographs in Napari ======================= About ===== This example demonstrates that the volume rendering capabilities of napari can also be used to render 2d timelapse acquisitions as kymographs. Kymographs, also called space-time images, are a powerful tool to visualize the dynamics of processes. The most common way to visualize kymographs is to pick a single line through a 2D image and visualize the time domain along a second axes. Napari is not limited to 2D visualization an by harnessing its volume volume rendering capabilities, we can create a 3D kymograph, a powerful visualization that provides an overview of the complete spatial and temporal data from a single view. Using napari's grid mode we can juxtapose multiple such 3D kymographs to highlight the differences in cell dynamics under different siRNA treatments. The selected samples are from the Mitocheck screen and demonstrate siRNA knockdowns of several genes. The date is timelapse fluorescence microscopy of HeLa cells, with GFP- tagged histone revealing the chromosomes. In the juxtaposed kymographs the reduced branching for the mitotitic phenotypes caused by INCENP, AURKB and KIF11 knockdown compared to TMPRSS11A knockdown is immediately obvious. Data Source =========== The samples to demonstrate this is downloaded from IDR: https://idr.openmicroscopy.org/webclient/?show=screen-1302 Reference ========= The data comes from the Mitocheck screen: Phenotypic profiling of the human genome by time-lapse microscopy reveals cell division genes. Neumann B, Walter T, Hériché JK, Bulkescher J, Erfle H, Conrad C, Rogers P, Poser I, Held M, Liebel U, Cetin C, Sieckmann F, Pau G, Kabbe R, Wünsche A, Satagopam V, Schmitz MH, Chapuis C, Gerlich DW, Schneider R, Eils R, Huber W, Peters JM, Hyman AA, Durbin R, Pepperkok R, Ellenberg J. Nature. 2010 Apr 1;464(7289):721-7. doi: 10.1038/nature08869. Acknowledgements ================ Beate Neumann (EMBL) for helpful advice on mitotic phenotypes. """ print(description) samples = ( {"IDRid": 2864587, "description": "AURKB knockdown", "vol": None}, {"IDRid": 2862565, "description": "KIF11 knockdown", "vol": None}, {"IDRid": 2867896, "description": "INCENP knockdown", "vol": None}, {"IDRid": 1486532, "description": "TMPRSS11A knockdown", "vol": None}, ) print("-------------------------------------------------------") print("Sample datasets will require ~490 MB download from IDR.") answer = input("Press Enter to proceed, 'n' to cancel: ") if answer.lower().startswith('n'): print("User cancelled download. Exiting.") exit(0) print("-------------------------------------------------------") for s in samples: print(f"Downloading sample {s['IDRid']}.") print(f"Description: {s['description']}") s["vol"] = np.squeeze(IDR_fetch_image(s["IDRid"])) v = napari.Viewer(ndisplay=3) scale = (5, 1, 1) # "stretch" time domain for s in samples: v.add_image( s["vol"], name=s['description'], scale=scale, blending="opaque" ) v.grid.enabled = True # show the volumes in grid mode v.axes.visible = True # magenta error shows time direction # set an oblique view angle onto the kymograph grid v.camera.center = (440, 880, 1490) v.camera.angles = (-20, 23, -50) v.camera.zoom = 0.17 napari.run() napari-0.5.0a1/examples/README.rst000066400000000000000000000000011437041365600165150ustar00rootroot00000000000000 napari-0.5.0a1/examples/action_manager.py000066400000000000000000000071471437041365600203710ustar00rootroot00000000000000""" Action manager ============== .. tags:: gui, experimental """ from random import shuffle import numpy as np from skimage import data import napari from napari._qt.widgets.qt_viewer_buttons import QtViewerPushButton from napari.components import ViewerModel from napari.utils.action_manager import action_manager def rotate45(viewer: napari.Viewer): """ Rotate layer 0 of the viewer by 45º Parameters ---------- viewer : napari.Viewer active (unique) instance of the napari viewer Notes ----- The `viewer` parameter needs to be named `viewer`, the action manager will infer that we need an instance of viewer. """ angle = np.pi / 4 from numpy import cos, sin r = np.array([[cos(angle), -sin(angle)], [sin(angle), cos(angle)]]) layer = viewer.layers[0] layer.rotate = layer.rotate @ r # create the viewer with an image viewer = napari.view_image(data.astronaut(), rgb=True) layer_buttons = viewer.window.qt_viewer.layerButtons # Button do not need to do anything, just need to be pretty; all the action # binding and (un) binding will be done with the action manager, idem for # setting the tooltip. rot_button = QtViewerPushButton('warning') layer_buttons.layout().insertWidget(3, rot_button) def register_action(): # Here we pass ViewerModel as the KeymapProvider as we want it to handle the shortcuts. # we could also pass none and bind the shortcuts at the window level – though we # are trying to not change the KeymapProvider API too much for now. # we give an action name to the action for configuration purposes as we need # it to be storable in json. # By convention (may be enforce later), we do give an action name which is iprefixed # by the name of the package it is defined in, here napari, action_manager.register_action( name='napari:rotate45', command=rotate45, description='Rotate layer 0 by 45deg', keymapprovider=ViewerModel, ) def bind_shortcut(): # note that the tooltip of the corresponding button will be updated to # remove the shortcut. action_manager.unbind_shortcut('napari:reset_view') # Control-R action_manager.bind_shortcut('napari:rotate45', 'Control-R') def bind_button(): action_manager.bind_button('napari:rotate45', rot_button) # we can all bind_shortcut or register_action or bind_button in any order; # this let us configure shortcuts even if plugins are loaded / unloaded. callbacks = [register_action, bind_shortcut, bind_button] shuffle(callbacks) for c in callbacks: print('calling', c) c() # We can set the action manager in debug mode, to help us figure out which # button is triggering which action. This will update the tooltips of the buttons # to include the name of the action in between square brackets. action_manager._debug(True) # Let's also modify some existing shortcuts, by unbinding a few existing actions, # and rebinding them with new shortcuts; below we change the add and select mode # to be the = (same as + key on US Keyboards but without modifiers) and - keys. # unbinding returns the old key if it exists; but we don't use it. # in practice you likely don't need to modify the shortcuts this way as it will # be implemented in settings, though you could imagine a plugin that would # allow toggling between many keymaps. settings = { 'napari:activate_points_add_mode' : '=', 'napari:activate_points_select_mode': '-', } for action, key in settings.items(): _old_shortcut = action_manager.unbind_shortcut(action) action_manager.bind_shortcut(action, key) if __name__ == '__main__': napari.run() napari-0.5.0a1/examples/add-points-3d.py000066400000000000000000000012251437041365600177570ustar00rootroot00000000000000""" Add points 3D ============= Display a labels layer above of an image layer using the add_labels and add_image APIs, then add points in 3D .. tags:: visualization-nD """ from scipy import ndimage as ndi from skimage import data import napari blobs = data.binary_blobs( length=128, volume_fraction=0.1, n_dim=3 )[::2].astype(float) labeled = ndi.label(blobs)[0] viewer = napari.Viewer(ndisplay=3) viewer.add_image(blobs, name='blobs', scale=(2, 1, 1)) viewer.add_labels(labeled, name='blob ID', scale=(2, 1, 1)) pts = viewer.add_points() viewer.camera.angles = (0, -65, 85) pts.mode = 'add' if __name__ == '__main__': napari.run() napari-0.5.0a1/examples/add_3D_image.py000066400000000000000000000006141437041365600176320ustar00rootroot00000000000000""" Add 3D image ============ Display a 3D image layer using the :meth:`add_image` API. .. tags:: visualization-nD, layers """ from skimage import data import napari blobs = data.binary_blobs(length=64, volume_fraction=0.1, n_dim=3).astype( float ) viewer = napari.Viewer(ndisplay=3) # add the volume viewer.add_image(blobs, scale=[3, 1, 1]) if __name__ == '__main__': napari.run() napari-0.5.0a1/examples/add_grayscale_image.py000066400000000000000000000007121437041365600213350ustar00rootroot00000000000000""" Add grayscale image =================== Display one grayscale image using the add_image API. .. tags:: visualization-basic """ import numpy as np from skimage import data import napari # simulating a grayscale image here for testing contrast limits adjustments image = data.astronaut().mean(-1) * 100 + 100 image += np.random.rand(*image.shape) * 3000 viewer = napari.view_image(image.astype(np.uint16)) if __name__ == '__main__': napari.run() napari-0.5.0a1/examples/add_image.py000066400000000000000000000004401437041365600173010ustar00rootroot00000000000000""" Add image ========= Display one image using the :func:`view_image` API. .. tags:: visualization-basic """ from skimage import data import napari # create the viewer with an image viewer = napari.view_image(data.astronaut(), rgb=True) if __name__ == '__main__': napari.run() napari-0.5.0a1/examples/add_image_transformed.py000066400000000000000000000005561437041365600217150ustar00rootroot00000000000000""" Add image transformed ===================== Display one image and transform it using the :func:`view_image` API. .. tags:: visualization-basic """ from skimage import data import napari # create the viewer with an image and transform (rotate) it viewer = napari.view_image(data.astronaut(), rgb=True, rotate=45) if __name__ == '__main__': napari.run() napari-0.5.0a1/examples/add_labels.py000066400000000000000000000016071437041365600174670ustar00rootroot00000000000000""" Add labels ========== Display a labels layer above of an image layer using the ``add_labels`` and ``add_image`` APIs .. tags:: layers, visualization-basic """ from skimage import data from skimage.filters import threshold_otsu from skimage.measure import label from skimage.morphology import closing, remove_small_objects, square from skimage.segmentation import clear_border import napari image = data.coins()[50:-50, 50:-50] # apply threshold thresh = threshold_otsu(image) bw = closing(image > thresh, square(4)) # remove artifacts connected to image border cleared = remove_small_objects(clear_border(bw), 20) # label image regions label_image = label(cleared) # initialise viewer with coins image viewer = napari.view_image(image, name='coins', rgb=False) # add the labels label_layer = viewer.add_labels(label_image, name='segmentation') if __name__ == '__main__': napari.run() napari-0.5.0a1/examples/add_labels_with_features.py000066400000000000000000000027551437041365600224250ustar00rootroot00000000000000""" Add labels with features ======================== Display a labels layer with various features .. tags:: layers, analysis """ import numpy as np from skimage import data from skimage.filters import threshold_otsu from skimage.measure import label from skimage.morphology import closing, remove_small_objects, square from skimage.segmentation import clear_border import napari image = data.coins()[50:-50, 50:-50] # apply threshold thresh = threshold_otsu(image) bw = closing(image > thresh, square(4)) # remove artifacts connected to image border cleared = remove_small_objects(clear_border(bw), 20) # label image regions label_image = label(cleared) # initialise viewer with coins image viewer = napari.view_image(image, name='coins', rgb=False) # get the size of each coin (first element is background area) label_areas = np.bincount(label_image.ravel())[1:] # split coins into small or large size_range = max(label_areas) - min(label_areas) small_threshold = min(label_areas) + (size_range / 2) coin_sizes = np.where(label_areas > small_threshold, 'large', 'small') label_features = { 'row': ['none'] + ['top'] * 4 + ['bottom'] * 4, # background is row: none 'size': ['none'] + list(coin_sizes), # background is size: none } color = {1: 'white', 2: 'blue', 3: 'green', 4: 'red', 5: 'yellow'} # add the labels label_layer = viewer.add_labels( label_image, name='segmentation', features=label_features, color=color, ) if __name__ == '__main__': napari.run() napari-0.5.0a1/examples/add_multiscale_image.py000066400000000000000000000011221437041365600215210ustar00rootroot00000000000000""" Add multiscale image ==================== Displays a multiscale image .. tags:: visualization-advanced """ import numpy as np from skimage import data from skimage.transform import pyramid_gaussian import napari # create multiscale from astronaut image base = np.tile(data.astronaut(), (8, 8, 1)) multiscale = list( pyramid_gaussian(base, downscale=2, max_layer=4, multichannel=True) ) print('multiscale level shapes: ', [p.shape[:2] for p in multiscale]) # add image multiscale viewer = napari.view_image(multiscale, multiscale=True) if __name__ == '__main__': napari.run() napari-0.5.0a1/examples/add_points.py000066400000000000000000000022421437041365600175350ustar00rootroot00000000000000""" Add points ========== Display a points layer on top of an image layer using the ``add_points`` and ``add_image`` APIs .. tags:: visualization-basic """ import numpy as np from skimage import data from skimage.color import rgb2gray import napari # add the image viewer = napari.view_image(rgb2gray(data.astronaut())) # add the points points = np.array([[100, 100], [200, 200], [333, 111]]) size = np.array([10, 20, 20]) viewer.add_points(points, size=size) # unselect the image layer viewer.layers.selection.discard(viewer.layers[0]) # adjust some of the points layer attributes layer = viewer.layers[1] # change the layer name layer.name = 'points' # change the layer visibility layer.visible = False layer.visible = True # select the layer viewer.layers.selection.add(layer) # deselect the layer viewer.layers.selection.remove(layer) # or: viewer.layers.selection.discard(layer) # change the layer opacity layer.opacity = 0.9 # change the layer point symbol using an alias layer.symbol = '+' # change the layer point out_of_slice_display status layer.out_of_slice_display = True # change the layer mode layer.mode = 'add' if __name__ == '__main__': napari.run() napari-0.5.0a1/examples/add_points_on_nD_shapes.py000066400000000000000000000034421437041365600222200ustar00rootroot00000000000000""" Add points on nD shapes ======================= Add points on nD shapes in 3D using a mouse callback .. tags:: visualization-nD """ import numpy as np import napari # Create rectangles in 4D data = [ [ [0, 50, 75, 75], [0, 50, 125, 75], [0, 100, 125, 125], [0, 100, 75, 125] ], [ [0, 10, 75, 75], [0, 10, 125, 75], [0, 40, 125, 125], [0, 40, 75, 125] ], [ [1, 100, 75, 75], [1, 100, 125, 75], [1, 50, 125, 125], [1, 50, 75, 125] ] ] shapes_data = np.array(data) # add an empty 4d points layer viewer = napari.view_points(ndim=4, size=3) points_layer = viewer.layers[0] # add the shapes layer to the viewer features = {'index': [0, 1, 2]} for shape_type, mult in {('ellipse', 1), ('rectangle', -1)}: shapes_layer = viewer.add_shapes( shapes_data * mult, face_color=['magenta', 'green', 'blue'], edge_color='white', blending='additive', features=features, text='index', shape_type=shape_type, ) @shapes_layer.mouse_drag_callbacks.append def on_click(layer, event): shape_index, intersection_point = layer.get_index_and_intersection( event.position, event.view_direction, event.dims_displayed ) if (shape_index is not None) and (intersection_point is not None): points_layer.add(intersection_point) for d in data: viewer.add_points(np.array(d)) # set the viewer to 3D rendering mode with the first two rectangles in view viewer.dims.ndisplay = 3 viewer.dims.set_point(axis=0, value=0) viewer.camera.angles = (70, 30, 150) viewer.camera.zoom = 2.5 if __name__ == '__main__': napari.run() napari-0.5.0a1/examples/add_points_with_features.py000066400000000000000000000036651437041365600225000ustar00rootroot00000000000000""" Add points with features ======================== Display a points layer on top of an image layer using the ``add_points`` and ``add_image`` APIs .. tags:: visualization-basic """ import numpy as np from skimage import data from skimage.color import rgb2gray import napari # add the image viewer = napari.view_image(rgb2gray(data.astronaut())) # add the points points = np.array([[100, 100], [200, 200], [333, 111]]) # create features for each point features = { 'confidence': np.array([1, 0.5, 0]), 'good_point': np.array([True, False, False]) } # define the color cycle for the face_color annotation face_color_cycle = ['blue', 'green'] # create a points layer where the face_color is set by the good_point feature # and the edge_color is set via a color map (grayscale) on the confidence # feature. points_layer = viewer.add_points( points, features=features, size=20, edge_width=7, edge_width_is_relative=False, edge_color='confidence', edge_colormap='gray', face_color='good_point', face_color_cycle=face_color_cycle ) # set the edge_color mode to colormap points_layer.edge_color_mode = 'colormap' # bind a function to toggle the good_point annotation of the selected points @viewer.bind_key('t') def toggle_point_annotation(viewer): selected_points = list(points_layer.selected_data) if len(selected_points) > 0: good_point = points_layer.features['good_point'] good_point[selected_points] = ~good_point[selected_points] points_layer.features['good_point'] = good_point # we need to manually refresh since we did not use the Points.features # setter to avoid changing the color map if all points get toggled to # the same class, we set update_colors=False (only re-colors the point # using the previously-determined color mapping). points_layer.refresh_colors(update_color_mapping=False) if __name__ == '__main__': napari.run() napari-0.5.0a1/examples/add_points_with_multicolor_text.py000066400000000000000000000025461437041365600241140ustar00rootroot00000000000000""" Add points with multicolor text =============================== Display a points layer on top of an image layer with text using multiple face colors mapped from features for the points and text. .. tags:: visualization-basic """ import numpy as np import napari # add the image with three points viewer = napari.view_image(np.zeros((400, 400))) points = np.array([[100, 100], [200, 300], [333, 111]]) # create features for each point features = { 'confidence': np.array([1, 0.5, 0]), 'good_point': np.array([True, False, False]), } # define the color cycle for the points face and text colors color_cycle = ['blue', 'green'] text = { 'string': 'Confidence is {confidence:.2f}', 'size': 20, 'color': {'feature': 'good_point', 'colormap': color_cycle}, 'translation': np.array([-30, 0]), } # create a points layer where the face_color is set by the good_point feature # and the edge_color is set via a color map (grayscale) on the confidence # feature points_layer = viewer.add_points( points, features=features, text=text, size=20, edge_width=7, edge_width_is_relative=False, edge_color='confidence', edge_colormap='gray', face_color='good_point', face_color_cycle=color_cycle, ) # set the edge_color mode to colormap points_layer.edge_color_mode = 'colormap' if __name__ == '__main__': napari.run() napari-0.5.0a1/examples/add_points_with_text.py000066400000000000000000000024061437041365600216360ustar00rootroot00000000000000""" Add points with text ==================== Display a points layer on top of an image layer using the ``add_points`` and ``add_image`` APIs .. tags:: visualization-basic """ import numpy as np import napari # add the image viewer = napari.view_image(np.zeros((400, 400))) # add the points points = np.array([[100, 100], [200, 300], [333, 111]]) # create features for each point features = { 'confidence': np.array([1, 0.5, 0]), 'good_point': np.array([True, False, False]), } # define the color cycle for the face_color annotation face_color_cycle = ['blue', 'green'] text = { 'string': 'Confidence is {confidence:.2f}', 'size': 20, 'color': 'green', 'translation': np.array([-30, 0]), } # create a points layer where the face_color is set by the good_point feature # and the edge_color is set via a color map (grayscale) on the confidence # feature. points_layer = viewer.add_points( points, features=features, text=text, size=20, edge_width=7, edge_width_is_relative=False, edge_color='confidence', edge_colormap='gray', face_color='good_point', face_color_cycle=face_color_cycle, ) # set the edge_color mode to colormap points_layer.edge_color_mode = 'colormap' if __name__ == '__main__': napari.run() napari-0.5.0a1/examples/add_shapes.py000066400000000000000000000042541437041365600175110ustar00rootroot00000000000000""" Add shapes ========== Display one shapes layer ontop of one image layer using the ``add_shapes`` and ``add_image`` APIs. When the window is closed it will print the coordinates of your shapes. .. tags:: visualization-basic """ import numpy as np from skimage import data import napari # add the image viewer = napari.view_image(data.camera(), name='photographer') # create a list of polygons polygons = [ np.array([[11, 13], [111, 113], [22, 246]]), np.array( [ [505, 60], [402, 71], [383, 42], [251, 95], [212, 59], [131, 137], [126, 187], [191, 204], [171, 248], [211, 260], [273, 243], [264, 225], [430, 173], [512, 160], ] ), np.array( [ [310, 382], [229, 381], [209, 401], [221, 411], [258, 411], [300, 412], [306, 435], [268, 434], [265, 454], [298, 461], [307, 461], [307, 507], [349, 510], [352, 369], [330, 366], [330, 366], ] ), ] # add polygons layer = viewer.add_shapes( polygons, shape_type='polygon', edge_width=1, edge_color='coral', face_color='royalblue', name='shapes', ) # shapes of each type can also be added via their respective add_ method # e.g. for the polygons above: # layer = viewer.add_shapes(name='shapes') # create empty layer # layer.add_polygons( # polygons, # edge_width=1, # edge_color='coral', # face_color='royalblue', # ) # change some attributes of the layer layer.selected_data = set(range(layer.nshapes)) layer.current_edge_width = 5 layer.selected_data = set() # add an ellipse to the layer ellipse = np.array([[59, 222], [110, 289], [170, 243], [119, 176]]) layer.add( ellipse, shape_type='ellipse', edge_width=5, edge_color='coral', face_color='purple', ) # To save layers to svg: # viewer.layers.save('viewer.svg', plugin='svg') if __name__ == '__main__': napari.run() napari-0.5.0a1/examples/add_shapes_with_features.py000066400000000000000000000036711437041365600224440ustar00rootroot00000000000000""" Add shapes with features ======================== Display one shapes layer ontop of one image layer using the ``add_shapes`` and ``add_image`` APIs. When the window is closed it will print the coordinates of your shapes. .. tags:: visualization-basic """ import numpy as np from skimage import data import napari # add the image viewer = napari.view_image(data.camera(), name='photographer') # create a list of polygons polygons = [ np.array([[11, 13], [111, 113], [22, 246]]), np.array( [ [505, 60], [402, 71], [383, 42], [251, 95], [212, 59], [131, 137], [126, 187], [191, 204], [171, 248], [211, 260], [273, 243], [264, 225], [430, 173], [512, 160], ] ), np.array( [ [310, 382], [229, 381], [209, 401], [221, 411], [258, 411], [300, 412], [306, 435], [268, 434], [265, 454], [298, 461], [307, 461], [307, 507], [349, 510], [352, 369], [330, 366], [330, 366], ] ), ] # create features features = { 'likelihood': [0.2, 0.5, 1], 'class': ['sky', 'person', 'building'], } face_color_cycle = ['blue', 'magenta', 'green'] # add polygons layer = viewer.add_shapes( polygons, features=features, shape_type='polygon', edge_width=1, edge_color='likelihood', edge_colormap='gray', face_color='class', face_color_cycle=face_color_cycle, name='shapes', ) # change some attributes of the layer layer.selected_data = set(range(layer.nshapes)) layer.current_edge_width = 5 layer.selected_data = set() # To save layers to svg: # viewer.layers.save('viewer.svg', plugin='svg') if __name__ == '__main__': napari.run() napari-0.5.0a1/examples/add_shapes_with_text.py000066400000000000000000000025541437041365600216110ustar00rootroot00000000000000""" Add shapes with text ==================== Display one shapes layer ontop of one image layer using the ``add_shapes`` and ``add_image`` APIs. When the window is closed it will print the coordinates of your shapes. .. tags:: visualization-basic """ import numpy as np from skimage import data import napari # add the image viewer = napari.view_image(data.camera(), name='photographer') # create a list of polygons polygons = [ np.array([[225, 146], [283, 146], [283, 211], [225, 211]]), np.array([[67, 182], [167, 182], [167, 268], [67, 268]]), np.array([[111, 336], [220, 336], [220, 240], [111, 240]]), ] # create features features = { 'likelihood': [21.23423, 51.2315, 100], 'class': ['hand', 'face', 'camera'], } edge_color_cycle = ['blue', 'magenta', 'green'] text = { 'string': '{class}: {likelihood:0.1f}%', 'anchor': 'upper_left', 'translation': [-5, 0], 'size': 8, 'color': 'green', } # add polygons shapes_layer = viewer.add_shapes( polygons, features=features, shape_type='polygon', edge_width=3, edge_color='class', edge_color_cycle=edge_color_cycle, face_color='transparent', text=text, name='shapes', ) # change some attributes of the layer shapes_layer.opacity = 1 # To save layers to svg: # viewer.layers.save('viewer.svg', plugin='svg') if __name__ == '__main__': napari.run() napari-0.5.0a1/examples/add_surface_2D.py000066400000000000000000000005701437041365600202000ustar00rootroot00000000000000""" Add surface 2D ============== Display a 2D surface .. tags:: visualization-basic """ import numpy as np import napari data = np.array([[0, 0], [0, 20], [10, 0], [10, 10]]) faces = np.array([[0, 1, 2], [1, 2, 3]]) values = np.linspace(0, 1, len(data)) # add the surface viewer = napari.view_surface((data, faces, values)) if __name__ == '__main__': napari.run() napari-0.5.0a1/examples/add_vectors.py000066400000000000000000000020051437041365600177030ustar00rootroot00000000000000""" Add vectors =========== This example generates an image of vectors Vector data is an array of shape (N, 4) Each vector position is defined by an (x, y, x-proj, y-proj) element where * x and y are the center points * x-proj and y-proj are the vector projections at each center .. tags:: visualization-basic """ import numpy as np from skimage import data import napari # create the viewer and window viewer = napari.Viewer() layer = viewer.add_image(data.camera(), name='photographer') # sample vector coord-like data n = 200 pos = np.zeros((n, 2, 2), dtype=np.float32) phi_space = np.linspace(0, 4 * np.pi, n) radius_space = np.linspace(0, 100, n) # assign x-y position pos[:, 0, 0] = radius_space * np.cos(phi_space) + 300 pos[:, 0, 1] = radius_space * np.sin(phi_space) + 256 # assign x-y projection pos[:, 1, 0] = 2 * radius_space * np.cos(phi_space) pos[:, 1, 1] = 2 * radius_space * np.sin(phi_space) # add the vectors layer = viewer.add_vectors(pos, edge_width=3) if __name__ == '__main__': napari.run() napari-0.5.0a1/examples/add_vectors_color_by_angle.py000066400000000000000000000025231437041365600227460ustar00rootroot00000000000000""" Add vectors color by angle ========================== This example generates a set of vectors in a spiral pattern. The color of the vectors is mapped to their 'angle' feature. .. tags:: visualization-advanced """ import numpy as np from skimage import data import napari # create the viewer and window viewer = napari.Viewer() layer = viewer.add_image(data.camera(), name='photographer') # sample vector coord-like data n = 300 pos = np.zeros((n, 2, 2), dtype=np.float32) phi_space = np.linspace(0, 4 * np.pi, n) radius_space = np.linspace(0, 100, n) # assign x-y position pos[:, 0, 0] = radius_space * np.cos(phi_space) + 300 pos[:, 0, 1] = radius_space * np.sin(phi_space) + 256 # assign x-y projection pos[:, 1, 0] = 2 * radius_space * np.cos(phi_space) pos[:, 1, 1] = 2 * radius_space * np.sin(phi_space) # make the angle feature, range 0-2pi angle = np.mod(phi_space, 2 * np.pi) # create a feature that is true for all angles > pi pos_angle = angle > np.pi # create the features dictionary. features = { 'angle': angle, 'pos_angle': pos_angle, } # add the vectors layer = viewer.add_vectors( pos, edge_width=3, features=features, edge_color='angle', edge_colormap='husl', name='vectors' ) # set the edge color mode to colormap layer.edge_color_mode = 'colormap' if __name__ == '__main__': napari.run() napari-0.5.0a1/examples/add_vectors_image.py000066400000000000000000000021121437041365600210440ustar00rootroot00000000000000""" Add vectors image ================= This example generates an image of vectors Vector data is an array of shape (N, M, 2) Each vector position is defined by an (x-proj, y-proj) element where * x-proj and y-proj are the vector projections at each center * each vector is centered on a pixel of the NxM grid .. tags:: visualization-basic """ import numpy as np import napari # create the viewer and window viewer = napari.Viewer() n = 20 m = 40 image = 0.2 * np.random.random((n, m)) + 0.5 layer = viewer.add_image(image, contrast_limits=[0, 1], name='background') # sample vector image-like data # n x m grid of slanted lines # random data on the open interval (-1, 1) pos = np.zeros(shape=(n, m, 2), dtype=np.float32) rand1 = 2 * (np.random.random_sample(n * m) - 0.5) rand2 = 2 * (np.random.random_sample(n * m) - 0.5) # assign projections for each vector pos[:, :, 0] = rand1.reshape((n, m)) pos[:, :, 1] = rand2.reshape((n, m)) # add the vectors vect = viewer.add_vectors(pos, edge_width=0.2, length=2.5) print(image.shape, pos.shape) if __name__ == '__main__': napari.run() napari-0.5.0a1/examples/affine_transforms.py000066400000000000000000000033561437041365600211260ustar00rootroot00000000000000""" Affine transforms ================= Display an image and its corners before and after an affine transform .. tags:: visualization-advanced """ import numpy as np import scipy.ndimage as ndi import napari # Create a random image image = np.random.random((5, 5)) # Define an affine transform affine = np.array([[1, -1, 4], [2, 3, 2], [0, 0, 1]]) # Define the corners of the image, including in homogeneous space corners = np.array([[0, 0], [4, 0], [0, 4], [4, 4]]) corners_h = np.concatenate([corners, np.ones((4, 1))], axis=1) viewer = napari.Viewer() # Add the original image and its corners viewer.add_image(image, name='background', colormap='red', opacity=.5) viewer.add_points(corners_h[:, :-1], size=0.5, opacity=.5, face_color=[0.8, 0, 0, 0.8], name='bg corners') # Add another copy of the image, now with a transform, and add its transformed corners viewer.add_image(image, colormap='blue', opacity=.5, name='moving', affine=affine) viewer.add_points((corners_h @ affine.T)[:, :-1], size=0.5, opacity=.5, face_color=[0, 0, 0.8, 0.8], name='mv corners') # Note how the transformed corner points remain at the corners of the transformed image # Now add the a regridded version of the image transformed with scipy.ndimage.affine_transform # Note that we have to use the inverse of the affine as scipy does ‘pull’ (or ‘backward’) resampling, # transforming the output space to the input to locate data, but napari does ‘push’ (or ‘forward’) direction, # transforming input to output. scipy_affine = ndi.affine_transform(image, np.linalg.inv(affine), output_shape=(10, 25), order=5) viewer.add_image(scipy_affine, colormap='green', opacity=.5, name='scipy') # Reset the view viewer.reset_view() if __name__ == '__main__': napari.run() napari-0.5.0a1/examples/annotate-2d.py000066400000000000000000000007551437041365600175340ustar00rootroot00000000000000""" Annotate 2D =========== Display one points layer ontop of one image layer using the ``add_points`` and ``add_image`` APIs .. tags:: analysis """ import numpy as np from skimage import data import napari print("click to add points; close the window when finished.") viewer = napari.view_image(data.astronaut(), rgb=True) points = viewer.add_points(np.zeros((0, 2))) points.mode = 'add' if __name__ == '__main__': napari.run() print("you clicked on:") print(points.data) napari-0.5.0a1/examples/annotate_segmentation_with_text.py000066400000000000000000000063551437041365600241070ustar00rootroot00000000000000""" Annotate segmentation with text =============================== Perform a segmentation and annotate the results with bounding boxes and text .. tags:: analysis """ import numpy as np from skimage import data from skimage.filters import threshold_otsu from skimage.measure import label, regionprops_table from skimage.morphology import closing, remove_small_objects, square from skimage.segmentation import clear_border import napari def segment(image): """Segment an image using an intensity threshold determined via Otsu's method. Parameters ---------- image : np.ndarray The image to be segmented Returns ------- label_image : np.ndarray The resulting image where each detected object labeled with a unique integer. """ # apply threshold thresh = threshold_otsu(image) bw = closing(image > thresh, square(4)) # remove artifacts connected to image border cleared = remove_small_objects(clear_border(bw), 20) # label image regions label_image = label(cleared) return label_image def make_bbox(bbox_extents): """Get the coordinates of the corners of a bounding box from the extents Parameters ---------- bbox_extents : list (4xN) List of the extents of the bounding boxes for each of the N regions. Should be ordered: [min_row, min_column, max_row, max_column] Returns ------- bbox_rect : np.ndarray The corners of the bounding box. Can be input directly into a napari Shapes layer. """ minr = bbox_extents[0] minc = bbox_extents[1] maxr = bbox_extents[2] maxc = bbox_extents[3] bbox_rect = np.array( [[minr, minc], [maxr, minc], [maxr, maxc], [minr, maxc]] ) bbox_rect = np.moveaxis(bbox_rect, 2, 0) return bbox_rect def circularity(perimeter, area): """Calculate the circularity of the region Parameters ---------- perimeter : float the perimeter of the region area : float the area of the region Returns ------- circularity : float The circularity of the region as defined by 4*pi*area / perimeter^2 """ circularity = 4 * np.pi * area / (perimeter ** 2) return circularity # load the image and segment it image = data.coins()[50:-50, 50:-50] label_image = segment(image) # create the features dictionary features = regionprops_table( label_image, properties=('label', 'bbox', 'perimeter', 'area') ) features['circularity'] = circularity( features['perimeter'], features['area'] ) # create the bounding box rectangles bbox_rects = make_bbox([features[f'bbox-{i}'] for i in range(4)]) # specify the display parameters for the text text_parameters = { 'string': 'label: {label}\ncirc: {circularity:.2f}', 'size': 12, 'color': 'green', 'anchor': 'upper_left', 'translation': [-3, 0], } # initialise viewer with coins image viewer = napari.view_image(image, name='coins', rgb=False) # add the labels label_layer = viewer.add_labels(label_image, name='segmentation') shapes_layer = viewer.add_shapes( bbox_rects, face_color='transparent', edge_color='green', features=features, text=text_parameters, name='bounding box', ) if __name__ == '__main__': napari.run() napari-0.5.0a1/examples/bbox_annotator.py000066400000000000000000000072511437041365600204350ustar00rootroot00000000000000""" bbox annotator ============== .. tags:: gui """ import numpy as np import pandas as pd from magicgui.widgets import ComboBox, Container from skimage import data import napari # set up the categorical annotation values and text display properties box_annotations = ['person', 'sky', 'camera'] text_feature = 'box_label' features = pd.DataFrame({ text_feature: pd.Series([], dtype=pd.CategoricalDtype(box_annotations)) }) text_color = 'green' text_size = 20 # create the GUI for selecting the values def create_label_menu(shapes_layer, label_feature, labels): """Create a label menu widget that can be added to the napari viewer dock Parameters ---------- shapes_layer : napari.layers.Shapes a napari shapes layer label_feature : str the name of the shapes feature to use the displayed text labels : List[str] list of the possible text labels values. Returns ------- label_widget : magicgui.widgets.Container the container widget with the label combobox """ # Create the label selection menu label_menu = ComboBox(label='text label', choices=labels) label_widget = Container(widgets=[label_menu]) def update_label_menu(): """This is a callback function that updates the label menu when the default features of the Shapes layer change """ new_label = str(shapes_layer.feature_defaults[label_feature][0]) if new_label != label_menu.value: label_menu.value = new_label shapes_layer.events.feature_defaults.connect(update_label_menu) def set_selected_features_to_default(): """This is a callback that updates the feature values of the currently selected shapes. This is a side-effect of the deprecated current_properties setter, but does not occur when modifying feature_defaults.""" indices = list(shapes_layer.selected_data) default_value = shapes_layer.feature_defaults[label_feature][0] shapes_layer.features[label_feature][indices] = default_value shapes_layer.events.features() shapes_layer.events.feature_defaults.connect(set_selected_features_to_default) shapes_layer.events.features.connect(shapes_layer.refresh_text) def label_changed(value: str): """This is a callback that update the default features on the Shapes layer when the label menu selection changes """ shapes_layer.feature_defaults[label_feature] = value shapes_layer.events.feature_defaults() label_menu.changed.connect(label_changed) return label_widget # create a stack with the camera image shifted in each slice n_slices = 5 base_image = data.camera() image = np.zeros((n_slices, base_image.shape[0], base_image.shape[1]), dtype=base_image.dtype) for slice_idx in range(n_slices): shift = 1 + 10 * slice_idx image[slice_idx, ...] = np.pad(base_image, ((0, 0), (shift, 0)), mode='constant')[:, :-shift] # create a viewer with a fake t+2D image viewer = napari.view_image(image) # create an empty shapes layer initialized with # text set to display the box label text_kwargs = { 'string': text_feature, 'size': text_size, 'color': text_color } shapes = viewer.add_shapes( face_color='black', features=features, text=text_kwargs, ndim=3 ) # create the label section gui label_widget = create_label_menu( shapes_layer=shapes, label_feature=text_feature, labels=box_annotations ) # add the label selection gui to the viewer as a dock widget viewer.window.add_dock_widget(label_widget, area='right', name='label_widget') # set the shapes layer mode to adding rectangles shapes.mode = 'add_rectangle' if __name__ == '__main__': napari.run() napari-0.5.0a1/examples/clipboard_.py000066400000000000000000000023261437041365600175120ustar00rootroot00000000000000""" Clipboard ========= Copy screenshot of the canvas or the whole viewer to clipboard. .. tags:: gui """ from qtpy.QtWidgets import QPushButton, QVBoxLayout, QWidget from skimage import data import napari # create the viewer with an image viewer = napari.view_image(data.moon()) class Grabber(QWidget): def __init__(self) -> None: super().__init__() self.copy_canvas_btn = QPushButton("Copy Canvas to Clipboard", self) self.copy_canvas_btn.setToolTip("Copy screenshot of the canvas to clipboard.") self.copy_viewer_btn = QPushButton("Copy Viewer to Clipboard", self) self.copy_viewer_btn.setToolTip("Copy screenshot of the entire viewer to clipboard.") layout = QVBoxLayout(self) layout.addWidget(self.copy_canvas_btn) layout.addWidget(self.copy_viewer_btn) def create_grabber_widget(): """Create widget""" widget = Grabber() # connect buttons widget.copy_canvas_btn.clicked.connect(lambda: viewer.window.qt_viewer.clipboard()) widget.copy_viewer_btn.clicked.connect(lambda: viewer.window.clipboard()) return widget widget = create_grabber_widget() viewer.window.add_dock_widget(widget) if __name__ == '__main__': napari.run() napari-0.5.0a1/examples/clipping_planes_interactive_.py000066400000000000000000000152641437041365600233240ustar00rootroot00000000000000""" Clipping planes interactive =========================== Display a 3D image (plus labels) with a clipping plane and interactive controls for moving the plane .. tags:: experimental """ import numpy as np from scipy import ndimage from skimage import data from vispy.geometry import create_sphere import napari viewer = napari.Viewer(ndisplay=3) # VOLUME and LABELS blobs = data.binary_blobs( length=64, volume_fraction=0.1, n_dim=3 ).astype(float) labeled = ndimage.label(blobs)[0] plane_parameters = { 'position': (32, 32, 32), 'normal': (1, 1, 1), 'enabled': True } volume_layer = viewer.add_image( blobs, rendering='mip', name='volume', experimental_clipping_planes=[plane_parameters], ) labels_layer = viewer.add_labels( labeled, name='labels', blending='translucent', experimental_clipping_planes=[plane_parameters], ) # POINTS points_layer = viewer.add_points( np.random.rand(20, 3) * 64, size=5, experimental_clipping_planes=[plane_parameters], ) # SPHERE mesh = create_sphere(method='ico') sphere_vert = mesh.get_vertices() * 20 sphere_vert += 32 surface_layer = viewer.add_surface( (sphere_vert, mesh.get_faces()), experimental_clipping_planes=[plane_parameters], ) # SHAPES shapes_data = np.random.rand(3, 4, 3) * 64 shapes_layer = viewer.add_shapes( shapes_data, face_color=['magenta', 'green', 'blue'], experimental_clipping_planes=[plane_parameters], ) # VECTORS vectors = np.zeros((20, 2, 3)) vectors[:, 0] = 32 vectors[:, 1] = (np.random.rand(20, 3) - 0.5) * 32 vectors_layer = viewer.add_vectors( vectors, experimental_clipping_planes=[plane_parameters], ) def point_in_bounding_box(point, bounding_box): if np.all(point > bounding_box[0]) and np.all(point < bounding_box[1]): return True return False @viewer.mouse_drag_callbacks.append def shift_plane_along_normal(viewer, event): """Shift a plane along its normal vector on mouse drag. This callback will shift a plane along its normal vector when the plane is clicked and dragged. The general strategy is to 1) find both the plane normal vector and the mouse drag vector in canvas coordinates 2) calculate how far to move the plane in canvas coordinates, this is done by projecting the mouse drag vector onto the (normalised) plane normal vector 3) transform this drag distance (canvas coordinates) into data coordinates 4) update the plane position It will also add a point to the points layer for a 'click-not-drag' event. """ # get layers from viewer volume_layer = viewer.layers['volume'] # Calculate intersection of click with data bounding box near_point, far_point = volume_layer.get_ray_intersections( event.position, event.view_direction, event.dims_displayed, ) # Calculate intersection of click with plane through data intersection = volume_layer.experimental_clipping_planes[0].intersect_with_line( line_position=near_point, line_direction=event.view_direction ) # Check if click was on plane by checking if intersection occurs within # data bounding box. If so, exit early. if not point_in_bounding_box(intersection, volume_layer.extent.data): return # Get plane parameters in vispy coordinates (zyx -> xyz) plane_normal_data_vispy = np.array(volume_layer.experimental_clipping_planes[0].normal)[[2, 1, 0]] plane_position_data_vispy = np.array(volume_layer.experimental_clipping_planes[0].position)[[2, 1, 0]] # Get transform which maps from data (vispy) to canvas # note that we're using a private attribute here, which may not be present in future napari versions visual2canvas = viewer.window._qt_viewer.layer_to_visual[volume_layer].node.get_transform( map_from="visual", map_to="canvas" ) # Find start and end positions of plane normal in canvas coordinates plane_normal_start_canvas = visual2canvas.map(plane_position_data_vispy) plane_normal_end_canvas = visual2canvas.map(plane_position_data_vispy + plane_normal_data_vispy) # Calculate plane normal vector in canvas coordinates plane_normal_canv = (plane_normal_end_canvas - plane_normal_start_canvas)[[0, 1]] plane_normal_canv_normalised = ( plane_normal_canv / np.linalg.norm(plane_normal_canv) ) # Disable interactivity during plane drag volume_layer.interactive = False labels_layer.interactive = False labels_layer.interactive = False points_layer.interactive = False surface_layer.interactive = False shapes_layer.interactive = False vectors_layer.interactive = False # Store original plane position and start position in canvas coordinates original_plane_position = volume_layer.experimental_clipping_planes[0].position start_position_canv = event.pos yield while event.type == "mouse_move": # Get end position in canvas coordinates end_position_canv = event.pos # Calculate drag vector in canvas coordinates drag_vector_canv = end_position_canv - start_position_canv # Project the drag vector onto the plane normal vector # (in canvas coorinates) drag_projection_on_plane_normal = np.dot( drag_vector_canv, plane_normal_canv_normalised ) # Update position of plane according to drag vector # only update if plane position is within data bounding box drag_distance_data = drag_projection_on_plane_normal / np.linalg.norm(plane_normal_canv) updated_position = original_plane_position + drag_distance_data * np.array( volume_layer.experimental_clipping_planes[0].normal) if point_in_bounding_box(updated_position, volume_layer.extent.data): volume_layer.experimental_clipping_planes[0].position = updated_position labels_layer.experimental_clipping_planes[0].position = updated_position points_layer.experimental_clipping_planes[0].position = updated_position surface_layer.experimental_clipping_planes[0].position = updated_position shapes_layer.experimental_clipping_planes[0].position = updated_position vectors_layer.experimental_clipping_planes[0].position = updated_position yield # Re-enable volume_layer.interactive = True labels_layer.interactive = True points_layer.interactive = True surface_layer.interactive = True shapes_layer.interactive = True vectors_layer.interactive = True viewer.axes.visible = True viewer.camera.angles = (45, 45, 45) viewer.camera.zoom = 5 viewer.text_overlay.update(dict( text='Drag the clipping plane surface to move it along its normal.', font_size=20, visible=True, )) if __name__ == '__main__': napari.run() napari-0.5.0a1/examples/concentric-spheres.py000066400000000000000000000006751437041365600212170ustar00rootroot00000000000000""" Concentric spheres ================== Display concentric spheres in 3D. .. tags:: visualization-nD """ import numpy as np from skimage import morphology import napari b0 = morphology.ball(5) b1 = morphology.ball(10) b0p = np.pad(b0, 5) viewer = napari.Viewer(ndisplay=3) # viewer.add_labels(b0) viewer.add_labels(b0p) viewer.add_labels(b1 * 2) viewer.add_points([[10, 10, 10]], size=1) if __name__ == '__main__': napari.run() napari-0.5.0a1/examples/cursor_position.py000066400000000000000000000012011437041365600206440ustar00rootroot00000000000000""" Cursor position =============== Add small data to examine cursor positions .. tags:: interactivity """ import numpy as np import napari viewer = napari.Viewer() image = np.array([[1, 0, 0, 1], [0, 0, 1, 1], [1, 0, 3, 0], [0, 2, 0, 0]], dtype=int) viewer.add_labels(image) points = np.array([[0, 0], [2, 0], [1, 3]]) viewer.add_points(points, size=0.25) rect = np.array([[0, 0], [3, 1]]) viewer.add_shapes(rect, shape_type='rectangle', edge_width=0.1) vect = np.array([[[3, 2], [-1, 1]]]) viewer.add_vectors(vect, edge_width=0.1) if __name__ == '__main__': napari.run() napari-0.5.0a1/examples/cursor_ray.py000066400000000000000000000033241437041365600176030ustar00rootroot00000000000000""" Cursor ray ========== Depict a ray through a layer in 3D to demonstrate interactive 3D functionality .. tags:: interactivity """ import numpy as np import napari sidelength_data = 64 n_points = 10 # data to depict an empty volume, its bounding box and points along a ray # through the volume volume = np.zeros(shape=(sidelength_data, sidelength_data, sidelength_data)) bounding_box = np.array( [ [0, 0, 0], [1, 0, 0], [0, 1, 0], [1, 1, 0], [0, 0, 1], [1, 0, 1], [0, 1, 1], [1, 1, 1], ] ) * sidelength_data points = np.zeros(shape=(n_points, 3)) # point sizes point_sizes = np.linspace(0.5, 2, n_points, endpoint=True) # point colors green = [0, 1, 0, 1] magenta = [1, 0, 1, 1] point_colors = np.linspace(green, magenta, n_points, endpoint=True) # create viewer and add layers for each piece of data viewer = napari.Viewer(ndisplay=3) bounding_box_layer = viewer.add_points( bounding_box, face_color='cornflowerblue', name='bounding box' ) ray_layer = viewer.add_points( points, face_color=point_colors, size=point_sizes, name='cursor ray' ) volume_layer = viewer.add_image(volume, blending='additive') # callback function, called on mouse click when volume layer is active @volume_layer.mouse_drag_callbacks.append def on_click(layer, event): near_point, far_point = layer.get_ray_intersections( event.position, event.view_direction, event.dims_displayed ) if (near_point is not None) and (far_point is not None): ray_points = np.linspace(near_point, far_point, n_points, endpoint=True) if ray_points.shape[1] != 0: ray_layer.data = ray_points if __name__ == '__main__': napari.run() napari-0.5.0a1/examples/custom_key_bindings.py000066400000000000000000000020431437041365600214470ustar00rootroot00000000000000""" Custom key bindings =================== Display one 4-D image layer using the ``add_image`` API .. tags:: gui """ from skimage import data import napari blobs = data.binary_blobs( length=128, blob_size_fraction=0.05, n_dim=2, volume_fraction=0.25 ).astype(float) viewer = napari.view_image(blobs, name='blobs') @viewer.bind_key('a') def accept_image(viewer): msg = 'this is a good image' viewer.status = msg print(msg) next(viewer) @viewer.bind_key('r') def reject_image(viewer): msg = 'this is a bad image' viewer.status = msg print(msg) next(viewer) def next(viewer): blobs = data.binary_blobs( length=128, blob_size_fraction=0.05, n_dim=2, volume_fraction=0.25 ).astype(float) viewer.layers[0].data = blobs @napari.Viewer.bind_key('w') def hello(viewer): # on press viewer.status = 'hello world!' yield # on release viewer.status = 'goodbye world :(' # change viewer title viewer.title = 'quality control images' if __name__ == '__main__': napari.run() napari-0.5.0a1/examples/custom_mouse_functions.py000066400000000000000000000042751437041365600222330ustar00rootroot00000000000000""" Custom mouse functions ====================== Display one 4-D image layer using the ``add_image`` API .. tags:: gui """ import numpy as np from scipy import ndimage as ndi from skimage import data from skimage.morphology import binary_dilation, binary_erosion import napari np.random.seed(1) viewer = napari.Viewer() blobs = data.binary_blobs(length=128, volume_fraction=0.1, n_dim=2) labeled = ndi.label(blobs)[0] labels_layer = viewer.add_labels(labeled, name='blob ID') @viewer.mouse_drag_callbacks.append def get_event(viewer, event): print(event) @viewer.mouse_drag_callbacks.append def get_ndisplay(viewer, event): if 'Alt' in event.modifiers: print('viewer display ', viewer.dims.ndisplay) @labels_layer.mouse_drag_callbacks.append def get_connected_component_shape(layer, event): data_coordinates = layer.world_to_data(event.position) cords = np.round(data_coordinates).astype(int) val = layer.get_value(data_coordinates) if val is None: return if val != 0: data = layer.data binary = data == val if 'Shift' in event.modifiers: binary_new = binary_erosion(binary) data[binary] = 0 else: binary_new = binary_dilation(binary) data[binary_new] = val size = np.sum(binary_new) layer.data = data msg = ( f'clicked at {cords} on blob {val} which is now {size} pixels' ) else: msg = f'clicked at {cords} on background which is ignored' print(msg) # Handle click or drag events separately @labels_layer.mouse_drag_callbacks.append def click_drag(layer, event): print('mouse down') dragged = False yield # on move while event.type == 'mouse_move': print(event.position) dragged = True yield # on release if dragged: print('drag end') else: print('clicked!') # Handle click or drag events separately @labels_layer.mouse_double_click_callbacks.append def on_second_click_of_double_click(layer, event): print('Second click of double_click', event.position) print('note that a click event was also triggered', event.type) if __name__ == '__main__': napari.run() napari-0.5.0a1/examples/dask_nD_image.py000066400000000000000000000012331437041365600201150ustar00rootroot00000000000000""" Dask nD image ============= Display a dask array .. tags:: visualization-nD """ try: from dask import array as da except ModuleNotFoundError: raise ModuleNotFoundError( """This example uses a dask array but dask is not installed. To install try 'pip install dask'.""" ) from None import numpy as np from skimage import data import napari blobs = da.stack( [ data.binary_blobs( length=128, blob_size_fraction=0.05, n_dim=3, volume_fraction=f ) for f in np.linspace(0.05, 0.5, 10) ], axis=0, ) viewer = napari.view_image(blobs.astype(float)) if __name__ == '__main__': napari.run() napari-0.5.0a1/examples/dev/000077500000000000000000000000001437041365600156155ustar00rootroot00000000000000napari-0.5.0a1/examples/dev/demo_shape_creation.py000066400000000000000000000054241437041365600221640ustar00rootroot00000000000000import argparse from timeit import default_timer import numpy as np import napari def create_sample_coords(n_polys=3000, n_vertices=32): """random circular polygons with given number of vertices""" center = np.random.randint(0, 1000, (n_polys, 2)) radius = ( 1000 / np.sqrt(n_polys) * np.random.uniform(0.9, 1.1, (n_polys, n_vertices)) ) phi = np.linspace(0, 2 * np.pi, n_vertices, endpoint=False) rays = np.stack([np.sin(phi), np.cos(phi)], 1) radius = radius.reshape((-1, n_vertices, 1)) rays = rays.reshape((1, -1, 2)) center = center.reshape((-1, 1, 2)) coords = center + radius * rays return coords def time_me(label, func): # print(f'{name} start') t = default_timer() res = func() t = default_timer() - t print(f"{label}: {t:.4f} s") return res if __name__ == "__main__": parser = argparse.ArgumentParser(description="") parser.add_argument( "-n", "--n_polys", type=int, default=5000, help='number of polygons to show', ) parser.add_argument( "-t", "--type", type=str, default="path", choices=['path', 'path_concat', 'polygon', 'rectangle', 'ellipse'], ) parser.add_argument( "-c", "--concat", action="store_true", help='concatenate all coordinates to a single mesh', ) parser.add_argument( "-v", "--view", action="store_true", help='show napari viewer' ) parser.add_argument( "--properties", action="store_true", help='add dummy shape properties' ) args = parser.parse_args() coords = create_sample_coords(args.n_polys) if args.type == 'rectangle': coords = coords[:, [4, 20]] elif args.type == 'ellipse': coords = coords[:, [0, 8, 16,22]] elif args.type == 'path_concat': args.type = 'path' coords = coords.reshape((1, -1, 2)) print(f'number of polygons: {len(coords)}') print(f'layer type: {args.type}') print(f'properties: {args.properties}') properties = { 'class': (['A', 'B', 'C', 'D'] * (len(coords) // 4 + 1))[ : len(coords) ], } color_cycle = ['blue', 'magenta', 'green'] kwargs = dict( shape_type=args.type, properties=properties if args.properties else None, face_color='class' if args.properties else [1,1,1,1], face_color_cycle=color_cycle, edge_color='class' if args.properties else [1,1,1,1], edge_color_cycle=color_cycle, ) layer = time_me( "time to create layer", lambda: napari.layers.Shapes(coords, **kwargs), ) if args.view: # add the image viewer = napari.Viewer() viewer.add_layer(layer) napari.run() napari-0.5.0a1/examples/dev/grin.svg000066400000000000000000000015411437041365600172760ustar00rootroot00000000000000napari-0.5.0a1/examples/dev/gui_notifications.py000066400000000000000000000011221437041365600217000ustar00rootroot00000000000000import warnings import napari from napari._qt.widgets.qt_viewer_buttons import QtViewerPushButton def raise_(): x = 1 y = 'a string' import something_that_does_not_exist return something_that_does_not_exist.fun(x, y) def warn_(): warnings.warn("warning!") viewer = napari.Viewer() layer_buttons = viewer.window._qt_viewer.layerButtons err_btn = QtViewerPushButton('warning', 'new Error', raise_) warn_btn = QtViewerPushButton('warning', 'new Warn', warn_) layer_buttons.layout().insertWidget(3, warn_btn) layer_buttons.layout().insertWidget(3, err_btn) napari.run() napari-0.5.0a1/examples/dev/gui_notifications_threaded.py000066400000000000000000000013361437041365600235470ustar00rootroot00000000000000import time import warnings import napari from napari._qt.widgets.qt_viewer_buttons import QtViewerPushButton from napari.qt import thread_worker @thread_worker(start_thread=True) def make_warning(*_): time.sleep(0.05) warnings.warn('Warning in another thread') @thread_worker(start_thread=True) def make_error(*_): time.sleep(0.05) raise ValueError("Error in another thread") viewer = napari.Viewer() layer_buttons = viewer.window.qt_viewer.layerButtons err_btn = QtViewerPushButton(None, 'warning', 'new Error', make_error) warn_btn = QtViewerPushButton(None, 'warning', 'new Warn', make_warning) layer_buttons.layout().insertWidget(3, warn_btn) layer_buttons.layout().insertWidget(3, err_btn) napari.run() napari-0.5.0a1/examples/dev/leaking_check.py000066400000000000000000000027531437041365600207450ustar00rootroot00000000000000import gc import os import weakref import numpy as np import objgraph import psutil import qtpy import napari process = psutil.Process(os.getpid()) viewer = napari.Viewer() print("mem", process.memory_info().rss) for _ in range(0): print(viewer.add_image(np.random.random((60, 1000, 1000))).name) for _ in range(2): print(viewer.add_labels((np.random.random((2, 1000, 1000)) * 10).astype(np.uint8)).name) print("mem", process.memory_info().rss) # napari.run() print("controls", viewer.window.qt_viewer.controls.widgets) li = weakref.ref(viewer.layers[0]) data_li = weakref.ref(li()._data) controls = weakref.ref(viewer.window.qt_viewer.controls.widgets[li()]) objgraph.show_backrefs(li(), filename="base.png") del viewer.layers[0] qtpy.QtGui.QGuiApplication.processEvents() gc.collect() gc.collect() print(li()) objgraph.show_backrefs(li(), max_depth=10, filename="test.png", refcounts=True) objgraph.show_backrefs(controls(), max_depth=10, filename="controls.png", refcounts=True) objgraph.show_backrefs(data_li(), max_depth=10, filename="test_data.png") print("controls", viewer.window.qt_viewer.controls.widgets) print("controls", gc.get_referrers(controls())) print("controls", controls().parent()) #print("controls", controls().parent().indexOf(controls())) print(gc.get_referrers(li())) print(gc.get_referrers(li())[1]) print(gc.get_referrers(gc.get_referrers(gc.get_referrers(li())[0]))) res = gc.get_referrers(gc.get_referrers(gc.get_referrers(li())[0])[0]) print(res) #print(type(res[0])) napari-0.5.0a1/examples/dev/plot_2d_edge_meshes.py000066400000000000000000000022041437041365600220600ustar00rootroot00000000000000import matplotlib.pyplot as plt from matplotlib.patches import Polygon from napari.layers.shapes._shapes_utils import ( generate_2D_edge_meshes, ) fig, axes = plt.subplots(2, 3) # fig.set_figwidth(15) # fig.set_figheight(10) colors = iter(['red', 'green', 'blue', 'yellow']) itaxes = iter(axes.flatten()) sup = axes.flatten()[4] for closed in [False, True]: for beveled in [False, True]: ax = next(itaxes) c = next(colors) centers, offsets, triangles = generate_2D_edge_meshes( [[0, 3], [1, 0], [2, 3], [5, 0], [2.5, 5]], closed=closed, limit=3, bevel=beveled, ) points = centers + 0.3 * offsets for t in triangles: trp = points[t] ax.add_patch(Polygon(trp, ec='#000000', fc=c, alpha=0.2)) sup.add_patch(Polygon(trp, ec='#000000', fc=c, alpha=0.1)) ax.scatter(*(points).T) ax.scatter(*(centers).T) ax.set_aspect('equal') ax.set_title(f' {closed=}, {beveled=}') ax.set_xlim(-1, 6) ax.set_ylim(-1, 6) sup.set_xlim(-1, 6) sup.set_ylim(-1, 6) plt.show() napari-0.5.0a1/examples/dev/q_list_view.py000066400000000000000000000026211437041365600205150ustar00rootroot00000000000000"""Example of using low-level `QtListView` with SelectableEventedList :class:`napari.utils.events.SelectableEventedList` is a mutable sequence that emits events when modified. It also has a selection model (tracking which items are selected). :class:`napari._qt.containers.QtListView` adapts the `EventedList` to the QAbstractItemModel/QAbstractItemView interface used by the QtFramework. This allows you to create an interactive GUI view onto a python model that stays up to date, and can modify the python object... while maintining the python object as the single "source of truth". """ import napari from napari._qt.containers import QtListView from napari.qt import get_app from napari.utils.events import SelectableEventedList get_app() class MyObject: """generic object.""" def __init__(self, name) -> None: self.name = name def __str__(self): return self.name # create our evented list root = SelectableEventedList([MyObject(x) for x in 'abcdefg']) # create Qt view onto the list view = QtListView(root) # show the view view.show() # spy on events root.events.reordered.connect(lambda e: print("reordered to: ", e.value)) root.selection.events.changed.connect( lambda e: print( f"selection changed. added: {e.added}, removed: {e.removed}" ) ) root.selection.events._current.connect( lambda e: print(f"current item changed to: {e.value}") ) napari.run() napari-0.5.0a1/examples/dev/q_node_tree.py000066400000000000000000000037361437041365600204640ustar00rootroot00000000000000"""Example of using low-level QtNodeTreeView with Node and Group :class:`napari.utils.tree.Node` is a class that may be used as a mixin that allows an object to be a member of a "tree". :class:`napari.utils.tree.Group` is a (nestable) mutable sequence of Nodes, and is also itself a Node (this is the "composite" pattern): https://refactoring.guru/design-patterns/composite/python/example These two classes may be used to create tree-like data structures that behave like pure python lists of lists. This examples shows that :class:`napari._qt.containers.QtNodeTreeView` is capable of providing a basic GUI for any tree structure based on `napari.utils.tree.Group`. """ import napari from napari._qt.containers import QtNodeTreeView from napari.qt import get_app from napari.utils.tree import Group, Node get_app() # create a group of nodes. root = Group( [ Node(name='6'), Group( [ Node(name='1'), Group([Node(name='2'), Node(name='3')], name="g2"), Node(name='4'), Node(name='5'), Node(name='tip'), ], name="g1", ), Node(name='7'), Node(name='8'), Node(name='9'), ], name="root", ) # create Qt view onto the Group view = QtNodeTreeView(root) # show the view view.show() # pretty __str__ makes nested tree structure more interpretable print(root) # root # ├──6 # ├──g1 # │ ├──1 # │ ├──g2 # │ │ ├──2 # │ │ └──3 # │ ├──4 # │ ├──5 # │ └──tip # ├──7 # ├──8 # └──9 # spy on events root.events.reordered.connect(lambda e: print("reordered to: ", e.value)) root.selection.events.changed.connect( lambda e: print( f"selection changed. added: {e.added}, removed: {e.removed}" ) ) root.selection.events._current.connect( lambda e: print(f"current item changed to: {e.value}") ) napari.run() napari-0.5.0a1/examples/dev/slicing/000077500000000000000000000000001437041365600172455ustar00rootroot00000000000000napari-0.5.0a1/examples/dev/slicing/README.md000066400000000000000000000031371437041365600205300ustar00rootroot00000000000000# Slicing examples The examples in this directory are for developers to test various aspects of layer slicing. These are primarily designed to aid in the async effort ([NAP 4](../../../docs/naps/4-async-slicing.md)). ## Examples Examples using [pooch](https://pypi.org/project/pooch/) will cache data locally, with an [OS-dependant path](https://www.fatiando.org/pooch/latest/api/generated/pooch.os_cache.html?highlight=cache#pooch.os_cache). ### Examples of desirable behavior These are a set of examples which are easy and non-frustrating to interact in napari without async support. We want to ensure that these examples continue to be performant. * ebi_empiar_3D_with_labels.py [EMPIAR-10982](https://www.ebi.ac.uk/empiar/EMPIAR-10982/) * Real-world image & labels data (downloaded locally) * points_example_smlm.py * Real-world points data (downloaded locally) Additional examples from the main napari examples: * add_multiscale_image.py * Access to in-memory multi-scale data ### Examples of undesirable behavior These are a set of examples which currently cause undesirable behavior in napari, typically resulting in non-responsive user interface due to synchronous slicing on large or remote data. * random_shapes.py * A large number of shapes to stress slicing on a shapes layer * random_points.py * A large number of random points to stress slicing on a points layer * janelia_s3_n5_multiscale.py * Multi-scale remote image data in zarr format ## Performance monitoring The [perfmon](../../../tools/perfmon/README.md) tooling can be used to monitor the data access performance on these examples.napari-0.5.0a1/examples/dev/slicing/ebi_empiar_3D_with_labels.py000066400000000000000000000023311437041365600246150ustar00rootroot00000000000000import pooch from tifffile import imread import napari """ This data comes from the MitoNet Benchmarks. Six benchmark volumes of instance segmentation of mitochondria from diverse volume EM datasets Narayan K , Conrad RW DOI: https://dx.doi.org/10.6019/EMPIAR-10982 Data is stored at EMPIAR and can be explored here: https://www.ebi.ac.uk/empiar/EMPIAR-10982/ With respect to the napari async slicing work, this dataset is small enough that it performs well in synchronous mode. """ salivary_gland_em_path = pooch.retrieve( url='https://ftp.ebi.ac.uk/empiar/world_availability/10982/data/mito_benchmarks/salivary_gland/salivary_gland_em.tif', known_hash='222f50dd8fd801a84f118ce71bc735f5c54f1a3ca4d98315b27721ae499bff94', progressbar=True ) salivary_gland_mito_path = pooch.retrieve( url='https://ftp.ebi.ac.uk/empiar/world_availability/10982/data/mito_benchmarks/salivary_gland/salivary_gland_mito.tif', known_hash='95247d952a1dd0f7b37da1be95980b598b590e4777065c7cd877ab67cb63c5eb', progressbar=True ) salivary_gland_em = imread(salivary_gland_em_path) salivary_gland_mito = imread(salivary_gland_mito_path) viewer = napari.view_image(salivary_gland_em) viewer.add_labels(salivary_gland_mito) napari.run() napari-0.5.0a1/examples/dev/slicing/janelia_s3_n5_multiscale.py000066400000000000000000000017541437041365600244620ustar00rootroot00000000000000import dask.array as da import zarr import napari """ The sample data here is Interphase HeLa Cell [https://openorganelle.janelia.org/datasets/jrc_hela-3], from HHMI's OpenOrganelle [https://openorganelle.janelia.org]. The data are hosted by Open Data on AWS on S3. This tests access to multi-scale remote data. """ # access the root of the n5 container group = zarr.open(zarr.N5FSStore('s3://janelia-cosem-datasets/jrc_hela-2/jrc_hela-2.n5', anon=True)) # s0 (highest resolution) through s5 (lowest resolution) are available data = [] for i in range(0, 5): zarr_array = group[f'em/fibsem-uint16/s{i}'] data.append(da.from_zarr(zarr_array, chunks=zarr_array.chunks)) # This order presents a better visualization, but seems to break simple async (issue #5106) # viewer = napari.view_image(data, order=(1, 0, 2), contrast_limits=(18000, 40000), multiscale=True) viewer = napari.view_image(data, contrast_limits=(18000, 40000), multiscale=True) if __name__ == '__main__': napari.run() napari-0.5.0a1/examples/dev/slicing/points_example_smlm.py000066400000000000000000000016641437041365600237050ustar00rootroot00000000000000import csv import numpy as np import pooch import napari """ This data comes from the Neurocyto Lab's description of the ThunderSTORM format. This file format is used to represent single molecule localizations. With respect to the napari async slicing work, this dataset is small enough that it performs well in synchronous mode. If someone is interested, then you can use the uncertainty_xy attribute from the STORM data to change the point size. More information is available here: http://www.neurocytolab.org/tscolumns/ """ storm_path = pooch.retrieve( url='http://www.neurocytolab.org/wp-content/uploads/2018/06/ThunderSTORM_TS3D.csv', known_hash='665a28b2fad69dbfd902e4945df04667f876d33a91167614c280065212041a29', progressbar=True ) with open(storm_path) as csvfile: data = list(csv.reader(csvfile)) data = np.array(data[1:]).astype(float) data = data[:, 1:4] viewer = napari.view_points(data, size=50) napari.run() napari-0.5.0a1/examples/dev/slicing/profile_points.py000066400000000000000000000012151437041365600226520ustar00rootroot00000000000000import cProfile import io import pstats from pstats import SortKey import numpy as np from napari.layers import Points """ This script was useful for testing how the performance of Points._set_view_slice changed with different implementations during async development. """ np.random.seed(0) n = 65536 data = np.random.random((n, 2)) s = io.StringIO() reps = 100 # Profiling with cProfile.Profile() as pr: for _ in range(reps): layer = Points(data) layer._set_view_slice() sortby = SortKey.CUMULATIVE ps = pstats.Stats(pr, stream=s).sort_stats(sortby) ps.print_stats(0.05) print(s.getvalue()) # pr.dump_stats("result.pstat") napari-0.5.0a1/examples/dev/slicing/random_points.py000066400000000000000000000006621437041365600224770ustar00rootroot00000000000000import argparse import numpy as np import napari """ Stress the points layer by generating a large number of points. """ parser = argparse.ArgumentParser() parser.add_argument( "n", type=int, nargs='?', default=10_000_000, help="(default: %(default)s)" ) args = parser.parse_args() np.random.seed(0) n = args.n data = 1000 * np.random.rand(n, 3) viewer = napari.view_points(data) if __name__ == '__main__': napari.run() napari-0.5.0a1/examples/dev/slicing/random_shapes.py000066400000000000000000000040301437041365600224370ustar00rootroot00000000000000import os import numpy as np import napari """ This example generates many random shapes. There currently is a bug in triangulation that requires this additional step of sanitizing the shape data: https://github.com/orgs/napari/projects/18/views/2 """ # logging.getLogger().setLevel(0) def generate_shapes(filename): # image_data = np.squeeze(cells3d()[:, 1, :, :]) # delayed_image_data = DelayedArray(image_data, delay_s=1) # From https://github.com/napari/napari/blob/main/examples/nD_shapes.py # create one random polygon per "plane" shapes_per_slice = 1000 all_shapes = None np.random.seed(0) for _ in range(shapes_per_slice): planes = np.tile(np.arange(128).reshape((128, 1, 1)), (1, 5, 1)) corners = np.random.uniform(0, 128, size=(128, 5, 2)) shapes = np.concatenate((planes, corners), axis=2) if all_shapes is not None: all_shapes = np.concatenate((all_shapes, shapes), axis=0) else: all_shapes = shapes print('all_shapes', all_shapes.shape) from vispy.geometry.polygon import PolygonData good_shapes = [] for shape in all_shapes: # Use try/except to filter all bad shapes try: vertices, triangles = PolygonData( vertices=shape[:, 1:] ).triangulate() except AssertionError: pass else: good_shapes.append(shape) print(len(good_shapes)) np.savez(filename, shapes=good_shapes) test_filename = '/tmp/napari_example_shapes.npz' # Create the example shapes if they do not exist if not os.path.exists(test_filename): print( 'Shapes file does not exist yet. Generating shapes. This may take a couple of minutes...' ) generate_shapes(test_filename) # Load the shapes with np.load(test_filename) as data: shapes = data['shapes'] # Test shapes in viewer viewer = napari.Viewer() viewer.show() shapes_layer = viewer.add_shapes( np.array(shapes), shape_type='polygon', name='sliced', ) napari.run() napari-0.5.0a1/examples/dynamic-projections-dask.py000066400000000000000000000040601437041365600223120ustar00rootroot00000000000000""" Dynamic projections dask ======================== Using dask array operations, one can dynamically take arbitrary slices and computations of a source dask array and display the results in napari. When the computation takes one or more parameters, one can tie a UI to them using magicgui. .. tags:: visualization-advanced """ import dask.array as da import numpy as np from dask.array.lib.stride_tricks import sliding_window_view from skimage import data import napari ############################################################################## # Part 1: using code to view a specific value. blobs = data.binary_blobs(length=64, n_dim=3) blobs_dask = da.from_array(blobs, chunks=(1, 64, 64)) # original shape [60, 1, 1, 5, 64, 64], # use squeeze to remove singleton axes blobs_dask_windows = np.squeeze( sliding_window_view(blobs_dask, window_shape=(5, 64, 64)), axis=(1, 2), ) blobs_sum = np.sum(blobs_dask_windows, axis=1) viewer = napari.view_image(blobs_sum) if __name__ == '__main__': napari.run() ############################################################################## # Part 2: using magicgui to vary the slice thickness. from magicgui import magicgui # noqa: E402 def sliding_window_mean( arr: napari.types.ImageData, size: int = 1 ) -> napari.types.LayerDataTuple: window_shape = (size,) + (arr.shape[1:]) arr_windows = sliding_window_view(arr, window_shape=window_shape) # as before, use squeeze to remove singleton axes arr_windows_1d = np.squeeze( arr_windows, axis=tuple(range(1, arr.ndim)) ) arr_summed = np.sum(arr_windows_1d, axis=1) / size return ( arr_summed, { 'translate': (size // 2,) + (0,) * (arr.ndim - 1), 'name': 'mean-window', 'colormap': 'magenta', 'blending': 'additive', }, 'image', ) viewer = napari.view_image(blobs_dask, colormap='green') viewer.window.add_dock_widget(magicgui(sliding_window_mean, auto_call=True)) viewer.dims.current_step = (32, 0, 0) if __name__ == '__main__': napari.run() napari-0.5.0a1/examples/embed_ipython_.py000066400000000000000000000015371437041365600204040ustar00rootroot00000000000000""" Embed IPython ============= Start napari and land directly in an embedded ipython console with qt event loop. A similar effect can be achieved more simply with `viewer.update_console(locals())`, such as shown in https://github.com/napari/napari/blob/main/examples/update_console.py. However, differently from `update_console`, this will start an independent ipython console which can outlive the viewer. .. tags:: gui """ from IPython.terminal.embed import InteractiveShellEmbed import napari # any code text = 'some text' # initalize viewer viewer = napari.Viewer() # embed ipython and run the magic command to use the qt event loop sh = InteractiveShellEmbed() sh.enable_gui('qt') # equivalent to using the '%gui qt' magic sh() # open the embedded shell # From there, you can access the script's scope, such as the variables `text` and `viewer` napari-0.5.0a1/examples/get_current_viewer.py000066400000000000000000000006231437041365600213140ustar00rootroot00000000000000""" Get current viewer ================== Get a reference to the current napari viewer. Whilst this example is contrived, it can be useful to get a reference to the viewer when the viewer is out of scope. .. tags:: gui """ import napari # create viewer viewer = napari.Viewer() # lose reference to viewer viewer = 'oops no viewer here' # get that reference again viewer = napari.current_viewer() napari-0.5.0a1/examples/image-points-3d.py000066400000000000000000000007731437041365600203200ustar00rootroot00000000000000""" Image points 3D =============== Display points overlaid on a 3D image .. tags:: visualization-nD """ from skimage import data, feature, filters import napari cells = data.cells3d() nuclei = cells[:, 1] smooth = filters.gaussian(nuclei, sigma=10) pts = feature.peak_local_max(smooth) viewer = napari.view_image( cells, channel_axis=1, name=['membranes', 'nuclei'], ndisplay=3 ) viewer.add_points(pts) viewer.camera.angles = (10, -20, 130) if __name__ == '__main__': napari.run() napari-0.5.0a1/examples/image_depth.py000066400000000000000000000007671437041365600176710ustar00rootroot00000000000000""" Image depth =========== .. tags:: visualization-basic """ import numpy as np import napari im_data = np.zeros((50, 50, 50)) im_data[30:40, 25:35, 25:35] = 1 viewer = napari.view_image(im_data, colormap='magenta', rendering='iso') viewer.add_image(im_data, colormap='green', rendering='iso', translate=(30, 0, 0)) points_data = [ [50, 30, 30], [25, 30, 30], [75, 30, 30] ] viewer.add_points(points_data, size=4) viewer.dims.ndisplay = 3 if __name__ == '__main__': napari.run() napari-0.5.0a1/examples/inherit_viewer_style.py000066400000000000000000000042371437041365600216620ustar00rootroot00000000000000""" Method to get napari style in magicgui based windows ==================================================== Example how to embed magicgui widget in dialog to inherit style from main napari window. .. tags:: gui, interactivity """ from magicgui import magicgui from qtpy.QtWidgets import ( QDialog, QGridLayout, QLabel, QPushButton, QSpinBox, QVBoxLayout, QWidget, ) import napari from napari.qt import get_stylesheet from napari.settings import get_settings # The magicgui widget shown by selecting the 'Show widget' button of MyWidget @magicgui def sample_add(a: int, b: int) -> int: return a + b def change_style(): sample_add.native.setStyleSheet(get_stylesheet(get_settings().appearance.theme)) get_settings().appearance.events.theme.connect(change_style) change_style() class MyDialog(QDialog): def __init__(self, parent=None) -> None: super().__init__(parent) self.first_input = QSpinBox() self.second_input = QSpinBox() self.btn = QPushButton('Add') layout = QGridLayout() layout.addWidget(QLabel("first input"), 0, 0) layout.addWidget(self.first_input, 0, 1) layout.addWidget(QLabel("second input"), 1, 0) layout.addWidget(self.second_input, 1, 1) layout.addWidget(self.btn, 2, 0, 1, 2) self.setLayout(layout) self.btn.clicked.connect(self.run) def run(self): print('run', self.first_input.value() + self.second_input.value()) self.close() class MyWidget(QWidget): def __init__(self) -> None: super().__init__() self.btn1 = QPushButton('Show dialog') self.btn1.clicked.connect(self.show_dialog) self.btn2 = QPushButton('Show widget') self.btn2.clicked.connect(self.show_widget) self.layout = QVBoxLayout() self.layout.addWidget(self.btn1) self.layout.addWidget(self.btn2) self.setLayout(self.layout) def show_dialog(self): dialog = MyDialog(self) dialog.exec_() def show_widget(self): sample_add.show() viewer = napari.Viewer() widget = MyWidget() viewer.window.add_dock_widget(widget, area='right') napari.run() napari-0.5.0a1/examples/interaction_box_image.py000066400000000000000000000007041437041365600217430ustar00rootroot00000000000000""" Interaction box image ===================== This example demonstrates activating 'transform' mode on the image layer. This allows the user to manipulate the image via the interaction box (blue box and points around the image). .. tags:: experimental """ from skimage import data import napari viewer = napari.view_image(data.astronaut(), rgb=True) viewer.layers.selection.active.mode = 'transform' if __name__ == '__main__': napari.run() napari-0.5.0a1/examples/interactive_move_rectangle_3d.py000066400000000000000000000034041437041365600233670ustar00rootroot00000000000000""" Interactive move rectangle ========================== Shift a rectangle along its normal vector in 3D .. tags:: experimental """ import numpy as np import napari rectangle = np.array( [ [50, 75, 75], [50, 125, 75], [100, 125, 125], [100, 75, 125] ], dtype=float ) shapes_data = np.array(rectangle) normal_vector = np.cross( rectangle[0] - rectangle[1], rectangle[2] - rectangle[1] ) normal_vector /= np.linalg.norm(normal_vector) viewer = napari.Viewer(ndisplay=3) shapes_layer = viewer.add_shapes( data=shapes_data, face_color='blue' ) viewer.camera.angles = (-170, -20, -170) viewer.camera.zoom = 1.5 viewer.text_overlay.visible = True viewer.text_overlay.text = """'click and drag the rectangle to create copies along its normal vector """ @shapes_layer.mouse_drag_callbacks.append def move_rectangle_along_normal(layer, event): shape_index, _ = layer.get_value( position=event.position, view_direction=event.view_direction, dims_displayed=event.dims_displayed ) if shape_index is None: return layer.interactive = False start_position = np.copy(event.position) yield while event.type == 'mouse_move': projected_distance = layer.projected_distance_from_mouse_drag( start_position=start_position, end_position=event.position, view_direction=event.view_direction, vector=normal_vector, dims_displayed=event.dims_displayed, ) shift_data_coordinates = projected_distance * normal_vector new_rectangle = layer.data[shape_index] + shift_data_coordinates layer.add(new_rectangle) yield layer.interactive = True if __name__ == '__main__': napari.run() napari-0.5.0a1/examples/interactive_scripting.py000066400000000000000000000012371437041365600220130ustar00rootroot00000000000000""" Interactive scripting ===================== .. tags:: interactivity """ import time import numpy as np import napari from napari.qt import thread_worker # create the viewer with an image data = np.random.random((512, 512)) viewer = napari.Viewer() layer = viewer.add_image(data) def update_layer(data): layer.data = data @thread_worker(connect={'yielded': update_layer}) def create_data(*, update_period, num_updates): # number of times to update for _k in range(num_updates): yield np.random.random((512, 512)) time.sleep(update_period) create_data(update_period=0.05, num_updates=50) if __name__ == '__main__': napari.run() napari-0.5.0a1/examples/labels-2d.py000066400000000000000000000014461437041365600171630ustar00rootroot00000000000000""" Labels 2D ========= Display a labels layer above of an image layer using the ``add_labels`` and ``add_image`` APIs .. tags:: visualization-basic """ from skimage import data from skimage.color import rgb2gray from skimage.segmentation import slic import napari astro = data.astronaut() # initialise viewer with astro image viewer = napari.view_image(rgb2gray(astro), name='astronaut', rgb=False) # add the labels # we add 1 because SLIC returns labels from 0, which we consider background labels = slic(astro, multichannel=True, compactness=20) + 1 label_layer = viewer.add_labels(labels, name='segmentation') # Set the labels layer mode to picker with a string label_layer.mode = 'PICK' print(f'The color of label 5 is {label_layer.get_color(5)}') if __name__ == '__main__': napari.run() napari-0.5.0a1/examples/labels3d.py000066400000000000000000000017301437041365600171030ustar00rootroot00000000000000""" Labels 3D ========= View 3D labels. .. tags:: visualization-nD """ import numpy as np from scipy import ndimage as ndi from skimage import data, filters, morphology import napari cells3d = data.cells3d() viewer = napari.view_image( cells3d, channel_axis=1, name=['membranes', 'nuclei'] ) membrane, nuclei = cells3d.transpose((1, 0, 2, 3)) / np.max(cells3d) edges = filters.scharr(nuclei) denoised = ndi.median_filter(nuclei, size=3) thresholded = denoised > filters.threshold_li(denoised) cleaned = morphology.remove_small_objects( morphology.remove_small_holes(thresholded, 20**3), 20**3, ) segmented = ndi.label(cleaned)[0] # maxima = ndi.label(morphology.local_maxima(filters.gaussian(nuclei, sigma=10)))[0] # markers_big = morphology.dilation(maxima, morphology.ball(5)) # segmented = segmentation.watershed( # edges, # markers_big, # mask=cleaned, # ) labels_layer = viewer.add_labels(segmented) if __name__ == '__main__': napari.run() napari-0.5.0a1/examples/layers.py000066400000000000000000000013701437041365600167110ustar00rootroot00000000000000""" Layers ====== Display multiple image layers using the ``add_image`` API and then reorder them using the layers swap method and remove one .. tags:: visualization-basic """ import numpy as np from skimage import data from skimage.color import rgb2gray import napari # create the viewer with several image layers viewer = napari.view_image(rgb2gray(data.astronaut()), name='astronaut') viewer.add_image(data.camera(), name='photographer') viewer.add_image(data.coins(), name='coins') viewer.add_image(data.moon(), name='moon') viewer.add_image(np.random.random((512, 512)), name='random') viewer.add_image(data.binary_blobs(length=512, volume_fraction=0.2, n_dim=2), name='blobs') viewer.grid.enabled = True if __name__ == '__main__': napari.run() napari-0.5.0a1/examples/linked_layers.py000066400000000000000000000016131437041365600202370ustar00rootroot00000000000000""" Linked layers ============= Demonstrates the `link_layers` function. This function takes a list of layers and an optional list of attributes, and links them such that when one of the linked attributes changes on any of the linked layers, all of the other layers follow. .. tags:: experimental """ import numpy as np import napari from napari.experimental import link_layers viewer = napari.view_image(np.random.rand(3, 64, 64), channel_axis=0) # link contrast_limits and gamma between all layers in viewer # NOTE: you may also omit the second argument to link ALL valid, common # attributes for the set of layers provided link_layers(viewer.layers, ('contrast_limits', 'gamma')) # unlinking may be done with napari.experimental.unlink_layers # this may also be done in a context manager: # with napari.experimental.layers_linked([layers]): # ... if __name__ == '__main__': napari.run() napari-0.5.0a1/examples/live_tiffs_.py000066400000000000000000000103371437041365600177060ustar00rootroot00000000000000""" Live tiffs ========== Loads and Displays tiffs as they get generated in the specific directory. Trying to simulate the live display of data as it gets acquired by microscope. This script should be run together with live_tiffs_generator.py .. tags:: experimental """ import os import sys import time import dask.array as da from dask import delayed from skimage.io.collection import alphanumeric_key from tifffile import imread import napari from napari.qt import thread_worker viewer = napari.Viewer(ndisplay=3) # pass a directory to monitor or it will monitor current directory. path = sys.argv[1] if len(sys.argv) > 1 else '.' path = os.path.abspath(path) end_of_experiment = 'final.log' def append(delayed_image): """Appends the image to viewer. Parameters ---------- delayed_image : dask.delayed function object """ if delayed_image is None: return if viewer.layers: # layer is present, append to its data layer = viewer.layers[0] image_shape = layer.data.shape[1:] image_dtype = layer.data.dtype image = da.from_delayed( delayed_image, shape=image_shape, dtype=image_dtype, ).reshape((1,) + image_shape) layer.data = da.concatenate((layer.data, image), axis=0) else: # first run, no layer added yet image = delayed_image.compute() image = da.from_delayed( delayed_image, shape=image.shape, dtype=image.dtype, ).reshape((1,) + image.shape) layer = viewer.add_image(image, rendering='attenuated_mip') # we want to show the last file added in the viewer to do so we want to # put the slider at the very end. But, sometimes when user is scrolling # through the previous slide then it is annoying to jump to last # stack as it gets added. To avoid that jump we 1st check where # the scroll is and if its not at the last slide then don't move the slider. if viewer.dims.point[0] >= layer.data.shape[0] - 2: viewer.dims.set_point(0, layer.data.shape[0] - 1) @thread_worker(connect={'yielded': append}) def watch_path(path): """Watches the path for new files and yields it once file is ready. Notes ----- Currently, there is no proper way to know if the file has written entirely. So the workaround is we assume that files are generating serially (in most microscopes it common), and files are name in alphanumeric sequence We start loading the total number of minus the last file (`total__files - last`). In other words, once we see the new file in the directory, it means the file before it has completed so load that file. For this example, we also assume that the microscope is generating a `final.log` file at the end of the acquisition, this file is an indicator to stop monitoring the directory. Parameters ---------- path : str directory to monitor and load tiffs as they start appearing. """ current_files = set() processed_files = set() end_of_acquisition = False while not end_of_acquisition: files_to_process = set() # Get the all files in the directory at this time current_files = set(os.listdir(path)) # Check if the end of acquisition has reached # if yes then remove it from the files_to_process set # and send it to display if end_of_experiment in current_files: files_to_process = current_files - processed_files files_to_process.remove(end_of_experiment) end_of_acquisition = True elif len(current_files): # get the last file from the current files based on the file names last_file = sorted(current_files, key=alphanumeric_key)[-1] current_files.remove(last_file) files_to_process = current_files - processed_files # yield every file to process as a dask.delayed function object. for p in sorted(files_to_process, key=alphanumeric_key): yield delayed(imread)(os.path.join(path, p)) else: yield # add the files which we have yield to the processed list. processed_files.update(files_to_process) time.sleep(0.1) worker = watch_path(path) if __name__ == '__main__': napari.run() napari-0.5.0a1/examples/live_tiffs_generator_.py000066400000000000000000000027761437041365600217640ustar00rootroot00000000000000""" Live tiffs generator ==================== Simulation of microscope acquisition. This code generates time series tiffs in an output directory (must be supplied by the user). .. tags:: experimental """ import argparse import os import sys import time import numpy as np import tifffile from skimage import data parser = argparse.ArgumentParser() parser.add_argument('outdir', help='output directory for tiffs') parser.add_argument( '--sleep-time', help='how long to sleep between volumes, in seconds', type=float, default=1.0, ) parser.add_argument( '-n', help='total number of volumes', type=int, default=100 ) def main(argv=sys.argv[1:]): args = parser.parse_args(argv) outdir = args.outdir sleep_time = args.sleep_time n = args.n fractions = np.linspace(0.05, 0.5, n) os.makedirs(outdir, exist_ok=True) for i, f in enumerate(fractions): # We are using skimage binary_blobs which generate's synthetic binary # image with several rounded blob-like objects and write them into files. curr_vol = 255 * data.binary_blobs( length=128, blob_size_fraction=0.05, n_dim=3, volume_fraction=f ).astype(np.uint8) tifffile.imwrite( os.path.join(outdir, f'{i}.tiff'), curr_vol, compress=6 ) time.sleep(sleep_time) # create a final.log file as an indicator for end of acquisition final_file = open(os.path.join(outdir, 'final.log'), 'w') final_file.close() if __name__ == '__main__': main() napari-0.5.0a1/examples/magic_image_arithmetic.py000066400000000000000000000032471437041365600220520ustar00rootroot00000000000000""" magicgui Image Arithmetic ========================= Basic example of using magicgui to create an Image Arithmetic GUI in napari. .. tags:: gui """ import enum import numpy as np import napari # Enums are a convenient way to get a dropdown menu class Operation(enum.Enum): """A set of valid arithmetic operations for image_arithmetic.""" add = np.add subtract = np.subtract multiply = np.multiply divide = np.divide # Define our image_arithmetic function. # Note that we can use forward references for the napari type annotations. # You can read more about them here: # https://peps.python.org/pep-0484/#forward-references # In this example, because we have already imported napari anyway, it doesn't # really matter. But this syntax would let you specify that a parameter is a # napari object type without actually importing or depending on napari. # Note: here we use `napari.types.ImageData` as our parameter annotations, # which means our function will be passed layer.data instead of # the full layer instance def image_arithmetic( layerA: 'napari.types.ImageData', operation: Operation, layerB: 'napari.types.ImageData', ) -> 'napari.types.ImageData': """Adds, subtracts, multiplies, or divides two same-shaped image layers.""" if layerA is not None and layerB is not None: return operation.value(layerA, layerB) # create a new viewer with a couple image layers viewer = napari.Viewer() viewer.add_image(np.random.rand(20, 20), name="Layer 1") viewer.add_image(np.random.rand(20, 20), name="Layer 2") # Add our magic function to napari viewer.window.add_function_widget(image_arithmetic) if __name__ == '__main__': napari.run() napari-0.5.0a1/examples/magic_parameter_sweep.py000066400000000000000000000034721437041365600217420ustar00rootroot00000000000000""" magicgui parameter sweep ======================== Example showing how to accomplish a napari parameter sweep with magicgui. It demonstrates: 1. overriding the default widget type with a custom class 2. the `auto_call` option, which calls the function whenever a parameter changes .. tags:: gui """ import skimage.data import skimage.filters from typing_extensions import Annotated import napari # Define our gaussian_blur function. # Note that we can use forward references for the napari type annotations. # You can read more about them here: # https://peps.python.org/pep-0484/#forward-references # In this example, because we have already imported napari anyway, it doesn't # really matter. But this syntax would let you specify that a parameter is a # napari object type without actually importing or depending on napari. # We also use the `Annotated` type to pass an additional dictionary that can be used # to aid widget generation. The keys of the dictionary are keyword arguments to # the corresponding magicgui widget type. For more informaiton see # https://napari.org/magicgui/api/widgets.html. def gaussian_blur( layer: 'napari.layers.Image', sigma: Annotated[float, {"widget_type": "FloatSlider", "max": 6}] = 1.0, mode: Annotated[str, {"choices": ["reflect", "constant", "nearest", "mirror", "wrap"]}]="nearest", ) -> 'napari.types.ImageData': """Apply a gaussian blur to ``layer``.""" if layer: return skimage.filters.gaussian(layer.data, sigma=sigma, mode=mode) # create a viewer and add some images viewer = napari.Viewer() viewer.add_image(skimage.data.astronaut().mean(-1), name="astronaut") viewer.add_image(skimage.data.grass().astype("float"), name="grass") # Add our magic function to napari viewer.window.add_function_widget(gaussian_blur) if __name__ == '__main__': napari.run() napari-0.5.0a1/examples/magic_viewer.py000066400000000000000000000011061437041365600200500ustar00rootroot00000000000000""" magicgui viewer =============== Example showing how to access the current viewer from a function widget. .. tags:: gui """ import napari # annotating a paramater as `napari.Viewer` will automatically provide # the viewer that the function is embedded in, when the function is added to # the viewer with add_function_widget. def my_function(viewer: napari.Viewer): print(viewer, f"with {len(viewer.layers)} layers") viewer = napari.Viewer() # Add our magic function to napari viewer.window.add_function_widget(my_function) if __name__ == '__main__': napari.run() napari-0.5.0a1/examples/mgui_dask_delayed_.py000066400000000000000000000014351437041365600212050ustar00rootroot00000000000000""" magicgui dask delayed ===================== An example of calling a threaded function from a magicgui dock_widget. Note: this example requires python >= 3.9 .. tags:: gui """ import time from concurrent.futures import Future import dask.array as da from magicgui import magicgui import napari from napari.types import ImageData def _slow_function(nz): time.sleep(2) return da.random.random((nz, 512, 512)) if __name__ == '__main__': from dask.distributed import Client client = Client() @magicgui(client={'bind': client}) def widget(client, nz: int = 1000) -> Future[ImageData]: return client.submit(_slow_function, nz) viewer = napari.Viewer() viewer.window.add_dock_widget(widget, area="right") if __name__ == '__main__': napari.run() napari-0.5.0a1/examples/mgui_with_threadpoolexec_.py000066400000000000000000000033031437041365600226310ustar00rootroot00000000000000""" magicgui with threadpoolexec ============================ An example of calling a threaded function from a magicgui ``dock_widget``. using ``ThreadPoolExecutor`` Note: this example requires python >= 3.9 .. tags:: gui """ import sys from concurrent.futures import Future, ThreadPoolExecutor from magicgui import magic_factory from skimage import data from skimage.feature import blob_log import napari from napari.types import ImageData, LayerDataTuple if sys.version_info < (3, 9): print('This example requires python >= 3.9') sys.exit(0) pool = ThreadPoolExecutor() @magic_factory( min_sigma={"min": 0.5, "max": 15, "step": 0.5}, max_sigma={"min": 1, "max": 200, "step": 0.5}, num_sigma={"min": 1, "max": 20}, threshold={"min": 0, "max": 1000, "step": 0.1}, ) def make_widget( image: ImageData, min_sigma: float = 5, max_sigma: float = 30, num_sigma: int = 10, threshold: float = 0.3, ) -> Future[LayerDataTuple]: # long running function def _make_blob(): # skimage.feature may take a while depending on the parameters blobs = blob_log( image, min_sigma=min_sigma, max_sigma=max_sigma, num_sigma=num_sigma, threshold=threshold, ) data = blobs[:, : image.ndim] kwargs = dict( size=blobs[:, -1], edge_color="red", edge_width=2, face_color="transparent", ) return (data, kwargs, 'points') return pool.submit(_make_blob) viewer = napari.Viewer() viewer.window.add_dock_widget(make_widget(), area="right") viewer.add_image(data.hubble_deep_field().mean(-1)) napari.run() pool.shutdown(wait=True) napari-0.5.0a1/examples/mgui_with_threadworker_.py000066400000000000000000000034321437041365600223270ustar00rootroot00000000000000""" magicgui with threadworker ========================== An example of calling a threaded function from a magicgui ``dock_widget``. Note: this example requires python >= 3.9 .. tags:: gui """ from magicgui import magic_factory, widgets from skimage import data from skimage.feature import blob_log from typing_extensions import Annotated import napari from napari.qt.threading import FunctionWorker, thread_worker from napari.types import ImageData, LayerDataTuple @magic_factory(pbar={'visible': False, 'max': 0, 'label': 'working...'}) def make_widget( pbar: widgets.ProgressBar, image: ImageData, min_sigma: Annotated[float, {"min": 0.5, "max": 15, "step": 0.5}] = 5, max_sigma: Annotated[float, {"min": 1, "max": 200, "step": 0.5}] = 30, num_sigma: Annotated[int, {"min": 1, "max": 20}] = 10, threshold: Annotated[float, {"min": 0, "max": 1000, "step": 0.1}] = 6, ) -> FunctionWorker[LayerDataTuple]: # @thread_worker creates a worker that runs a function in another thread # we connect the "returned" signal to the ProgressBar.hide method @thread_worker(connect={'returned': pbar.hide}) def detect_blobs() -> LayerDataTuple: # this is the potentially long-running function blobs = blob_log(image, min_sigma, max_sigma, num_sigma, threshold) points = blobs[:, : image.ndim] meta = dict( size=blobs[:, -1], edge_color="red", edge_width=2, face_color="transparent", ) # return a "LayerDataTuple" return (points, meta, 'points') # show progress bar and return worker pbar.show() return detect_blobs() viewer = napari.Viewer() viewer.window.add_dock_widget(make_widget(), area="right") viewer.add_image(data.hubble_deep_field().mean(-1)) napari.run() napari-0.5.0a1/examples/minimum_blending.py000066400000000000000000000025351437041365600207330ustar00rootroot00000000000000""" Minimum blending ================ Demonstrates how to use the `minimum` blending mode with inverted colormaps. `minimum` blending uses the minimum value of each R, G, B channel for each pixel. `minimum` blending can be used to yield multichannel color images on a white background, when the channels have inverted colormaps assigned. An inverted colormap is one where white [1, 1, 1] is used to represent the lowest values, as opposed to the more conventional black [0, 0, 0]. For example, try the colormaps prefixed with *I*, such as *I Forest* or *I Bordeaux*, from ChrisLUTs: https://github.com/cleterrier/ChrisLUTs . .. tags:: visualization-basic """ from skimage import data import napari # create a viewer viewer = napari.Viewer() # Add the cells3d example image, using the two inverted colormaps # and minimum blending mode. Note that the bottom-most layer # must be translucent or opaque to prevent blending with the canvas. viewer.add_image(data.cells3d(), name=["membrane", "nuclei"], channel_axis=1, contrast_limits = [[1110, 23855], [1600, 50000]], colormap = ["I Purple", "I Orange"], blending= ["translucent_no_depth", "minimum"] ) if __name__ == '__main__': napari.run() napari-0.5.0a1/examples/mixed-dimensions-labels.py000066400000000000000000000020031437041365600221200ustar00rootroot00000000000000""" Mixed dimensions labels ======================= Overlay a 3D segmentation on a 4D time series. Sometimes, our data have mixed dimensionality. napari "right-aligns" the dimensions of your data, following NumPy broadcasting conventions [1]_. In this example, we show how we can see a 3D segmentation overlaid on a 4D dataset. As we slice through the dataset, the segmentation stays unchanged, but is visible on every slice. .. [1] https://numpy.org/doc/stable/user/basics.broadcasting.html .. tags:: visualization-nD """ import numpy as np from scipy import ndimage as ndi from skimage.data import binary_blobs import napari blobs3d = binary_blobs(length=64, volume_fraction=0.1, n_dim=3).astype(float) blobs3dt = np.stack([np.roll(blobs3d, 3 * i, axis=2) for i in range(10)]) labels = ndi.label(blobs3dt[5])[0] viewer = napari.Viewer(ndisplay=3) image_layer = viewer.add_image(blobs3dt) labels_layer = viewer.add_labels(labels) viewer.dims.current_step = (5, 0, 0, 0) if __name__ == '__main__': napari.run() napari-0.5.0a1/examples/mouse_drag_callback.py000066400000000000000000000024161437041365600213550ustar00rootroot00000000000000""" Mouse drag callback =================== Example updating the status bar with line profile info while dragging lines around in a shapes layer. .. tags:: gui """ import numpy as np from skimage import data, measure import napari def profile_lines(image, shape_layer): profile_data = [ measure.profile_line(image, line[0], line[1], mode='reflect').mean() for line in shape_layer.data ] print(f"profile means: [{', '.join(f'{d:.2f}' for d in profile_data)}]") np.random.seed(1) viewer = napari.Viewer() blobs = data.binary_blobs(length=512, volume_fraction=0.1, n_dim=2) viewer.add_image(blobs, name='blobs') line1 = np.array([[11, 13], [111, 113]]) line2 = np.array([[200, 200], [400, 300]]) lines = [line1, line2] shapes_layer = viewer.add_shapes( lines, shape_type='line', edge_width=5, edge_color='coral', face_color='royalblue', ) shapes_layer.mode = 'select' @shapes_layer.mouse_drag_callbacks.append def profile_lines_drag(layer, event): profile_lines(blobs, layer) yield while event.type == 'mouse_move': profile_lines(blobs, layer) # the yield statement allows the mouse UI to keep working while # this loop is executed repeatedly yield if __name__ == '__main__': napari.run() napari-0.5.0a1/examples/mpl_plot_.py000066400000000000000000000020241437041365600173740ustar00rootroot00000000000000""" Matplotlib plot =============== .. tags:: gui """ import matplotlib.pyplot as plt import numpy as np from matplotlib.backends.backend_qt5agg import FigureCanvas import napari # create image x = np.linspace(0, 5, 256) y = np.linspace(0, 5, 256)[:, np.newaxis] img = np.sin(x) ** 10 + np.cos(10 + y * x) * np.cos(x) # add it to the viewer viewer = napari.view_image(img, colormap='viridis') layer = viewer.layers[-1] # create mpl figure with subplots mpl_fig = plt.figure() ax = mpl_fig.add_subplot(111) (line,) = ax.plot(layer.data[123]) # linescan through the middle of the image # add the figure to the viewer as a FigureCanvas widget viewer.window.add_dock_widget(FigureCanvas(mpl_fig)) # connect a callback that updates the line plot when # the user clicks on the image @layer.mouse_drag_callbacks.append def profile_lines_drag(layer, event): try: line.set_ydata(layer.data[int(event.position[0])]) line.figure.canvas.draw() except IndexError: pass if __name__ == '__main__': napari.run() napari-0.5.0a1/examples/multiple_viewer_widget.py000066400000000000000000000354001437041365600221720ustar00rootroot00000000000000""" Multiple viewer widget ====================== This is an example on how to have more than one viewer in the same napari window. Additional viewers state will be synchronized with the main viewer. Switching to 3D display will only impact the main viewer. This example also contain option to enable cross that will be moved to the current dims point (`viewer.dims.point`). .. tags:: gui """ from copy import deepcopy import numpy as np from packaging.version import parse as parse_version from qtpy.QtCore import Qt from qtpy.QtWidgets import ( QCheckBox, QDoubleSpinBox, QPushButton, QSplitter, QTabWidget, QVBoxLayout, QWidget, ) from superqt.utils import qthrottled import napari from napari.components.layerlist import Extent from napari.components.viewer_model import ViewerModel from napari.layers import Image, Labels, Layer, Vectors from napari.qt import QtViewer from napari.utils.action_manager import action_manager from napari.utils.events.event import WarningEmitter from napari.utils.notifications import show_info NAPARI_GE_4_16 = parse_version(napari.__version__) > parse_version("0.4.16") def copy_layer_le_4_16(layer: Layer, name: str = ""): res_layer = deepcopy(layer) # this deepcopy is not optimal for labels and images layers if isinstance(layer, (Image, Labels)): res_layer.data = layer.data res_layer.metadata["viewer_name"] = name res_layer.events.disconnect() res_layer.events.source = res_layer for emitter in res_layer.events.emitters.values(): emitter.disconnect() emitter.source = res_layer return res_layer def copy_layer(layer: Layer, name: str = ""): if NAPARI_GE_4_16: return copy_layer_le_4_16(layer, name) res_layer = Layer.create(*layer.as_layer_data_tuple()) res_layer.metadata["viewer_name"] = name return res_layer def get_property_names(layer: Layer): klass = layer.__class__ res = [] for event_name, event_emitter in layer.events.emitters.items(): if isinstance(event_emitter, WarningEmitter): continue if event_name in ("thumbnail", "name"): continue if ( isinstance(getattr(klass, event_name, None), property) and getattr(klass, event_name).fset is not None ): res.append(event_name) return res def center_cross_on_mouse( viewer_model: napari.components.viewer_model.ViewerModel, ): """move the cross to the mouse position""" if not getattr(viewer_model, "mouse_over_canvas", True): # There is no way for napari 0.4.15 to check if mouse is over sending canvas. show_info( "Mouse is not over the canvas. You may need to click on the canvas." ) return viewer_model.dims.current_step = tuple( np.round( [ max(min_, min(p, max_)) / step for p, (min_, max_, step) in zip( viewer_model.cursor.position, viewer_model.dims.range ) ] ).astype(int) ) action_manager.register_action( name='napari:move_point', command=center_cross_on_mouse, description='Move dims point to mouse position', keymapprovider=ViewerModel, ) action_manager.bind_shortcut('napari:move_point', 'C') class own_partial: """ Workaround for deepcopy not copying partial functions (Qt widgets are not serializable) """ def __init__(self, func, *args, **kwargs) -> None: self.func = func self.args = args self.kwargs = kwargs def __call__(self, *args, **kwargs): return self.func(*(self.args + args), **{**self.kwargs, **kwargs}) def __deepcopy__(self, memodict=None): if memodict is None: memodict = {} return own_partial( self.func, *deepcopy(self.args, memodict), **deepcopy(self.kwargs, memodict), ) class QtViewerWrap(QtViewer): def __init__(self, main_viewer, *args, **kwargs) -> None: super().__init__(*args, **kwargs) self.main_viewer = main_viewer def _qt_open( self, filenames: list, stack: bool, plugin: str = None, layer_type: str = None, **kwargs, ): """for drag and drop open files""" self.main_viewer.window._qt_viewer._qt_open( filenames, stack, plugin, layer_type, **kwargs ) class CrossWidget(QCheckBox): """ Widget to control the cross layer. because of the performance reason the cross update is throttled """ def __init__(self, viewer: napari.Viewer) -> None: super().__init__("Add cross layer") self.viewer = viewer self.setChecked(False) self.stateChanged.connect(self._update_cross_visibility) self.layer = None self.viewer.dims.events.order.connect(self.update_cross) self.viewer.dims.events.ndim.connect(self._update_ndim) self.viewer.dims.events.current_step.connect(self.update_cross) self._extent = None self._update_extent() self.viewer.dims.events.connect(self._update_extent) @qthrottled(leading=False) def _update_extent(self): """ Calculate the extent of the data. Ignores the the cross layer itself in calculating the extent. """ if NAPARI_GE_4_16: layers = [ layer for layer in self.viewer.layers if layer is not self.layer ] self._extent = self.viewer.layers.get_extent(layers) else: extent_list = [ layer.extent for layer in self.viewer.layers if layer is not self.layer ] self._extent = Extent( data=None, world=self.viewer.layers._get_extent_world(extent_list), step=self.viewer.layers._get_step_size(extent_list), ) self.update_cross() def _update_ndim(self, event): if self.layer in self.viewer.layers: self.viewer.layers.remove(self.layer) self.layer = Vectors(name=".cross", ndim=event.value) self.layer.edge_width = 1.5 self.update_cross() def _update_cross_visibility(self, state): if state: self.viewer.layers.append(self.layer) else: self.viewer.layers.remove(self.layer) self.update_cross() def update_cross(self): if self.layer not in self.viewer.layers: return point = self.viewer.dims.current_step vec = [] for i, (lower, upper) in enumerate(self._extent.world.T): if (upper - lower) / self._extent.step[i] == 1: continue point1 = list(point) point1[i] = (lower + self._extent.step[i] / 2) / self._extent.step[ i ] point2 = [0 for _ in point] point2[i] = (upper - lower) / self._extent.step[i] vec.append((point1, point2)) if np.any(self.layer.scale != self._extent.step): self.layer.scale = self._extent.step self.layer.data = vec class ExampleWidget(QWidget): """ Dummy widget showcasing how to place additional widgets to the right of the additional viewers. """ def __init__(self) -> None: super().__init__() self.btn = QPushButton("Perform action") self.spin = QDoubleSpinBox() layout = QVBoxLayout() layout.addWidget(self.spin) layout.addWidget(self.btn) layout.addStretch(1) self.setLayout(layout) class MultipleViewerWidget(QSplitter): """The main widget of the example.""" def __init__(self, viewer: napari.Viewer) -> None: super().__init__() self.viewer = viewer self.viewer_model1 = ViewerModel(title="model1") self.viewer_model2 = ViewerModel(title="model2") self._block = False self.qt_viewer1 = QtViewerWrap(viewer, self.viewer_model1) self.qt_viewer2 = QtViewerWrap(viewer, self.viewer_model2) self.tab_widget = QTabWidget() w1 = ExampleWidget() w2 = ExampleWidget() self.tab_widget.addTab(w1, "Sample 1") self.tab_widget.addTab(w2, "Sample 2") viewer_splitter = QSplitter() viewer_splitter.setOrientation(Qt.Vertical) viewer_splitter.addWidget(self.qt_viewer1) viewer_splitter.addWidget(self.qt_viewer2) viewer_splitter.setContentsMargins(0, 0, 0, 0) self.addWidget(viewer_splitter) self.addWidget(self.tab_widget) self.viewer.layers.events.inserted.connect(self._layer_added) self.viewer.layers.events.removed.connect(self._layer_removed) self.viewer.layers.events.moved.connect(self._layer_moved) self.viewer.layers.selection.events.active.connect( self._layer_selection_changed ) self.viewer.dims.events.current_step.connect(self._point_update) self.viewer_model1.dims.events.current_step.connect(self._point_update) self.viewer_model2.dims.events.current_step.connect(self._point_update) self.viewer.dims.events.order.connect(self._order_update) self.viewer.events.reset_view.connect(self._reset_view) self.viewer_model1.events.status.connect(self._status_update) self.viewer_model2.events.status.connect(self._status_update) def _status_update(self, event): self.viewer.status = event.value def _reset_view(self): self.viewer_model1.reset_view() self.viewer_model2.reset_view() def _layer_selection_changed(self, event): """ update of current active layer """ if self._block: return if event.value is None: self.viewer_model1.layers.selection.active = None self.viewer_model2.layers.selection.active = None return self.viewer_model1.layers.selection.active = self.viewer_model1.layers[ event.value.name ] self.viewer_model2.layers.selection.active = self.viewer_model2.layers[ event.value.name ] def _point_update(self, event): for model in [self.viewer, self.viewer_model1, self.viewer_model2]: if model.dims is event.source: continue model.dims.current_step = event.value def _order_update(self): order = list(self.viewer.dims.order) if len(order) <= 2: self.viewer_model1.dims.order = order self.viewer_model2.dims.order = order return order[-3:] = order[-2], order[-3], order[-1] self.viewer_model1.dims.order = order order = list(self.viewer.dims.order) order[-3:] = order[-1], order[-2], order[-3] self.viewer_model2.dims.order = order def _layer_added(self, event): """add layer to additional viewers and connect all required events""" self.viewer_model1.layers.insert( event.index, copy_layer(event.value, "model1") ) self.viewer_model2.layers.insert( event.index, copy_layer(event.value, "model2") ) for name in get_property_names(event.value): getattr(event.value.events, name).connect( own_partial(self._property_sync, name) ) if isinstance(event.value, Labels): event.value.events.set_data.connect(self._set_data_refresh) self.viewer_model1.layers[ event.value.name ].events.set_data.connect(self._set_data_refresh) self.viewer_model2.layers[ event.value.name ].events.set_data.connect(self._set_data_refresh) if event.value.name != ".cross": self.viewer_model1.layers[event.value.name].events.data.connect( self._sync_data ) self.viewer_model2.layers[event.value.name].events.data.connect( self._sync_data ) event.value.events.name.connect(self._sync_name) self._order_update() def _sync_name(self, event): """sync name of layers""" index = self.viewer.layers.index(event.source) self.viewer_model1.layers[index].name = event.source.name self.viewer_model2.layers[index].name = event.source.name def _sync_data(self, event): """sync data modification from additional viewers""" if self._block: return for model in [self.viewer, self.viewer_model1, self.viewer_model2]: layer = model.layers[event.source.name] if layer is event.source: continue try: self._block = True layer.data = event.source.data finally: self._block = False def _set_data_refresh(self, event): """ synchronize data refresh between layers """ if self._block: return for model in [self.viewer, self.viewer_model1, self.viewer_model2]: layer = model.layers[event.source.name] if layer is event.source: continue try: self._block = True layer.refresh() finally: self._block = False def _layer_removed(self, event): """remove layer in all viewers""" self.viewer_model1.layers.pop(event.index) self.viewer_model2.layers.pop(event.index) def _layer_moved(self, event): """update order of layers""" dest_index = ( event.new_index if event.new_index < event.index else event.new_index + 1 ) self.viewer_model1.layers.move(event.index, dest_index) self.viewer_model2.layers.move(event.index, dest_index) def _property_sync(self, name, event): """Sync layers properties (except the name)""" if event.source not in self.viewer.layers: return try: self._block = True setattr( self.viewer_model1.layers[event.source.name], name, getattr(event.source, name), ) setattr( self.viewer_model2.layers[event.source.name], name, getattr(event.source, name), ) finally: self._block = False if __name__ == "__main__": from qtpy import QtCore, QtWidgets QtWidgets.QApplication.setAttribute(QtCore.Qt.AA_ShareOpenGLContexts) # above two lines are needed to allow to undock the widget with # additional viewers view = napari.Viewer() dock_widget = MultipleViewerWidget(view) cross = CrossWidget(view) view.window.add_dock_widget(dock_widget, name="Sample") view.window.add_dock_widget(cross, name="Cross", area="left") napari.run() napari-0.5.0a1/examples/multiple_viewers.py000066400000000000000000000007471437041365600210200ustar00rootroot00000000000000""" Multiple viewers ================ Create multiple viewers from the same script .. tags:: gui """ from skimage import data import napari # add the image photographer = data.camera() viewer_a = napari.view_image(photographer, name='photographer') # add the image in a new viewer window astronaut = data.astronaut() # Also view_path, view_shapes, view_points, view_labels etc. viewer_b = napari.view_image(astronaut, name='astronaut') if __name__ == '__main__': napari.run() napari-0.5.0a1/examples/multithreading_simple_.py000066400000000000000000000026041437041365600221430ustar00rootroot00000000000000""" Multithreading simple ===================== .. tags:: interactivity """ import time from qtpy.QtWidgets import QApplication, QHBoxLayout, QLabel, QWidget from napari.qt import thread_worker @thread_worker def long_running_function(): """Just a long running function, most like viewer.update.""" time.sleep(2) # long function return 'finished!' def create_widget(): widget = QWidget() layout = QHBoxLayout() widget.setLayout(layout) widget.status = QLabel('ready...') layout.addWidget(widget.status) widget.show() return widget if __name__ == "__main__": app = QApplication([]) wdg = create_widget() # call decorated function # By default, @thread_worker-decorated functions do not immediately start worker = long_running_function() # Signals are best connected *before* starting the worker. worker.started.connect(lambda: wdg.status.setText("worker is running...")) worker.returned.connect(lambda x: wdg.status.setText(f"returned {x}")) # # Connections may also be passed directly to the decorated function. # # The above syntax is equivalent to: # worker = long_running_function( # _connect={ # 'started': lambda: wdg.status.setText("worker is running..."), # 'returned': lambda x: wdg.status.setText(f"returned {x!r}"), # } # ) worker.start() app.exec_() napari-0.5.0a1/examples/multithreading_two_way_.py000066400000000000000000000072511437041365600223460ustar00rootroot00000000000000""" Multithreading two-way ====================== .. tags:: interactivity """ import time import numpy as np from qtpy.QtWidgets import ( QGridLayout, QLabel, QProgressBar, QPushButton, QWidget, ) import napari from napari.qt.threading import thread_worker @thread_worker def two_way_communication_with_args(start, end): """Both sends and receives values to & from the main thread. Accepts arguments, puts them on the worker object. Receives values from main thread with ``incoming = yield`` Optionally returns a value at the end """ # do computationally intensive work here i = start while i < end: i += 1 time.sleep(0.1) # incoming receives values from the main thread # while yielding sends values back to the main thread incoming = yield i i = incoming if incoming is not None else i # do optional teardown here return "done" class Controller(QWidget): def __init__(self) -> None: super().__init__() layout = QGridLayout() self.setLayout(layout) self.status = QLabel('Click "Start"', self) self.play_btn = QPushButton("Start", self) self.abort_btn = QPushButton("Abort!", self) self.reset_btn = QPushButton("Reset", self) self.progress_bar = QProgressBar() layout.addWidget(self.play_btn, 0, 0) layout.addWidget(self.reset_btn, 0, 1) layout.addWidget(self.abort_btn, 0, 2) layout.addWidget(self.status, 0, 3) layout.setColumnStretch(3, 1) layout.addWidget(self.progress_bar, 1, 0, 1, 4) def create_connected_widget(): """Builds a widget that can control a function in another thread.""" w = Controller() steps = 40 # the decorated function now returns a GeneratorWorker object, and the # Qthread in which it's running. # (optionally pass start=False to prevent immediate running) worker = two_way_communication_with_args(0, steps) w.play_btn.clicked.connect(worker.start) # it provides signals like {started, yielded, returned, errored, finished} worker.returned.connect(lambda x: w.status.setText(f"worker returned {x}")) worker.errored.connect(lambda x: w.status.setText(f"worker errored {x}")) worker.started.connect(lambda: w.status.setText("worker started...")) worker.aborted.connect(lambda: w.status.setText("worker aborted")) # send values into the function (like generator.send) using worker.send # abort thread with worker.abort() w.abort_btn.clicked.connect(lambda: worker.quit()) def on_reset_button_pressed(): # we want to avoid sending into a unstarted worker if worker.is_running: worker.send(0) def on_yield(x): # Receive events and update widget progress w.progress_bar.setValue(100 * x // steps) w.status.setText(f"worker yielded {x}") def on_start(): def handle_pause(): worker.toggle_pause() w.play_btn.setText("Pause" if worker.is_paused else "Continue") w.play_btn.clicked.disconnect(worker.start) w.play_btn.setText("Pause") w.play_btn.clicked.connect(handle_pause) def on_finish(): w.play_btn.setDisabled(True) w.reset_btn.setDisabled(True) w.abort_btn.setDisabled(True) w.play_btn.setText("Done") w.reset_btn.clicked.connect(on_reset_button_pressed) worker.yielded.connect(on_yield) worker.started.connect(on_start) worker.finished.connect(on_finish) return w if __name__ == "__main__": viewer = napari.view_image(np.random.rand(512, 512)) w = create_connected_widget() viewer.window.add_dock_widget(w) napari.run() napari-0.5.0a1/examples/nD_image.py000066400000000000000000000007311437041365600171150ustar00rootroot00000000000000""" nD image ======== Display one 4-D image layer using the :func:`view_image` API. .. tags:: visualization-nD """ import numpy as np from skimage import data import napari blobs = np.stack( [ data.binary_blobs( length=128, blob_size_fraction=0.05, n_dim=3, volume_fraction=f ) for f in np.linspace(0.05, 0.5, 10) ], axis=0, ) viewer = napari.view_image(blobs.astype(float)) if __name__ == '__main__': napari.run() napari-0.5.0a1/examples/nD_labels.py000066400000000000000000000010071437041365600172720ustar00rootroot00000000000000""" nD labels ========= Display a labels layer above of an image layer using the ``add_labels`` and ``add_image`` APIs .. tags:: visualization-nD """ from scipy import ndimage as ndi from skimage import data import napari blobs = data.binary_blobs(length=128, volume_fraction=0.1, n_dim=3) viewer = napari.view_image(blobs[::2].astype(float), name='blobs', scale=(2, 1, 1)) labeled = ndi.label(blobs)[0] viewer.add_labels(labeled[::2], name='blob ID', scale=(2, 1, 1)) if __name__ == '__main__': napari.run() napari-0.5.0a1/examples/nD_multiscale_image.py000066400000000000000000000012361437041365600213400ustar00rootroot00000000000000""" nD multiscale image =================== Displays an nD multiscale image .. tags:: visualization-advanced """ import numpy as np from skimage.transform import pyramid_gaussian import napari # create multiscale from random data base = np.random.random((1536, 1536)) base = np.array([base * (8 - i) / 8 for i in range(8)]) print('base shape', base.shape) multiscale = list( pyramid_gaussian(base, downscale=2, max_layer=2, multichannel=False) ) print('multiscale level shapes: ', [p.shape for p in multiscale]) # add image multiscale viewer = napari.view_image(multiscale, contrast_limits=[0, 1], multiscale=True) if __name__ == '__main__': napari.run() napari-0.5.0a1/examples/nD_multiscale_image_non_uniform.py000066400000000000000000000013361437041365600237520ustar00rootroot00000000000000""" nD multiscale image non-uniform =============================== Displays an nD multiscale image .. tags:: visualization-advanced """ import numpy as np from skimage import data from skimage.transform import pyramid_gaussian import napari # create multiscale from astronaut image astronaut = data.astronaut() base = np.tile(astronaut, (3, 3, 1)) multiscale = list( pyramid_gaussian(base, downscale=2, max_layer=3, multichannel=True) ) multiscale = [ np.array([p * (abs(3 - i) + 1) / 4 for i in range(6)]) for p in multiscale ] print('multiscale level shapes: ', [p.shape for p in multiscale]) # add image multiscale viewer = napari.view_image(multiscale, multiscale=True) if __name__ == '__main__': napari.run() napari-0.5.0a1/examples/nD_points.py000066400000000000000000000015721437041365600173530ustar00rootroot00000000000000""" nD points ========= Display one points layer on top of one 4-D image layer using the add_points and add_image APIs, where the markes are visible as nD objects across the dimensions, specified by their size .. tags:: visualization-nD """ import numpy as np from skimage import data import napari blobs = np.stack( [ data.binary_blobs( length=128, blob_size_fraction=0.05, n_dim=3, volume_fraction=f ) for f in np.linspace(0.05, 0.5, 10) ], axis=0, ) viewer = napari.view_image(blobs.astype(float)) # add the points points = np.array( [ [0, 0, 100, 100], [0, 0, 50, 120], [1, 0, 100, 40], [2, 10, 110, 100], [9, 8, 80, 100], ], dtype=float ) viewer.add_points( points, size=[0, 6, 10, 10], face_color='blue', out_of_slice_display=True ) if __name__ == '__main__': napari.run() napari-0.5.0a1/examples/nD_points_with_features.py000066400000000000000000000026501437041365600223020ustar00rootroot00000000000000""" nD points with features ======================= Display one points layer ontop of one 4-D image layer using the add_points and add_image APIs, where the markes are visible as nD objects across the dimensions, specified by their size .. tags:: visualization-nD """ import numpy as np from skimage import data import napari blobs = data.binary_blobs( length=100, blob_size_fraction=0.05, n_dim=3, volume_fraction=0.05 ) viewer = napari.view_image(blobs.astype(float)) # create the points points = [] for z in range(blobs.shape[0]): points += [[z, 25, 25], [z, 25, 75], [z, 75, 25], [z, 75, 75]] # create the features for setting the face and edge color. face_feature = np.array( [True, True, True, True, False, False, False, False] * int(blobs.shape[0] / 2) ) edge_feature = np.array(['A', 'B', 'C', 'D', 'E'] * int(len(points) / 5)) features = { 'face_feature': face_feature, 'edge_feature': edge_feature, } points_layer = viewer.add_points( points, features=features, size=3, edge_width=5, edge_width_is_relative=False, edge_color='edge_feature', face_color='face_feature', out_of_slice_display=False, ) # change the face color cycle points_layer.face_color_cycle = ['white', 'black'] # change the edge_color cycle. # there are 4 colors for 5 categories, so 'c' will be recycled points_layer.edge_color_cycle = ['c', 'm', 'y', 'k'] if __name__ == '__main__': napari.run() napari-0.5.0a1/examples/nD_shapes.py000066400000000000000000000030171437041365600173160ustar00rootroot00000000000000""" nD shapes ========= Display one 4-D image layer using the ``add_image`` API .. tags:: visualization-nD """ import numpy as np from skimage import data import napari blobs = data.binary_blobs( length=128, blob_size_fraction=0.05, n_dim=3, volume_fraction=0.1 ).astype(float) viewer = napari.view_image(blobs.astype(float)) # create one random polygon per "plane" planes = np.tile(np.arange(128).reshape((128, 1, 1)), (1, 5, 1)) np.random.seed(0) corners = np.random.uniform(0, 128, size=(128, 5, 2)) shapes = np.concatenate((planes, corners), axis=2) base_cols = ['red', 'green', 'blue', 'white', 'yellow', 'magenta', 'cyan'] colors = np.random.choice(base_cols, size=128) layer = viewer.add_shapes( np.array(shapes), shape_type='polygon', face_color=colors, name='sliced', ) masks = layer.to_masks(mask_shape=(128, 128, 128)) labels = layer.to_labels(labels_shape=(128, 128, 128)) shape_array = np.array(layer.data) print( f'sliced: nshapes {layer.nshapes}, mask shape {masks.shape}, ' f'labels_shape {labels.shape}, array_shape, {shape_array.shape}' ) corners = np.random.uniform(0, 128, size=(2, 2)) layer = viewer.add_shapes(corners, shape_type='rectangle', name='broadcasted') masks = layer.to_masks(mask_shape=(128, 128)) labels = layer.to_labels(labels_shape=(128, 128)) shape_array = np.array(layer.data) print( f'broadcast: nshapes {layer.nshapes}, mask shape {masks.shape}, ' f'labels_shape {labels.shape}, array_shape, {shape_array.shape}' ) if __name__ == '__main__': napari.run() napari-0.5.0a1/examples/nD_shapes_with_text.py000066400000000000000000000012271437041365600214160ustar00rootroot00000000000000""" nD shapes with text =================== .. tags:: visualization-nD """ from skimage import data import napari blobs = data.binary_blobs( length=100, blob_size_fraction=0.05, n_dim=3, volume_fraction=0.03 ).astype(float) viewer = napari.view_image(blobs.astype(float), ndisplay=3) n = 50 shape = [[[n, 40, 40], [n, 40, 60], [n + 20, 60, 60], [n + 20, 60, 40]]] features = {'z_index': [n]} text = {'string': 'z_index', 'color': 'green', 'anchor': 'upper_left'} shapes_layer = viewer.add_shapes( shape, edge_color=[0, 1, 0, 1], face_color='transparent', features=features, text=text, ) if __name__ == '__main__': napari.run() napari-0.5.0a1/examples/nD_surface.py000066400000000000000000000006771437041365600174740ustar00rootroot00000000000000""" nD surface ========== Display a 3D surface .. tags:: visualization-nD """ import numpy as np import napari # create the viewer and window viewer = napari.Viewer(ndisplay=3) data = np.array([[0, 0, 0], [0, 20, 10], [10, 0, -10], [10, 10, -10]]) faces = np.array([[0, 1, 2], [1, 2, 3]]) values = np.linspace(0, 1, len(data)) # add the surface layer = viewer.add_surface((data, faces, values)) if __name__ == '__main__': napari.run() napari-0.5.0a1/examples/nD_vectors.py000066400000000000000000000026171437041365600175250ustar00rootroot00000000000000""" nD vectors ========== Display two vectors layers ontop of a 4-D image layer. One of the vectors layers is 3D and "sliced" with a different set of vectors appearing on different 3D slices. Another is 2D and "broadcast" with the same vectors apprearing on each slice. .. tags:: visualization-nD """ import numpy as np from skimage import data import napari blobs = np.stack( [ data.binary_blobs( length=128, blob_size_fraction=0.05, n_dim=3, volume_fraction=f ) for f in np.linspace(0.05, 0.5, 10) ], axis=0, ) viewer = napari.view_image(blobs.astype(float)) # sample vector coord-like data n = 200 pos = np.zeros((n, 2, 2), dtype=np.float32) phi_space = np.linspace(0, 4 * np.pi, n) radius_space = np.linspace(0, 20, n) # assign x-y position pos[:, 0, 0] = radius_space * np.cos(phi_space) + 64 pos[:, 0, 1] = radius_space * np.sin(phi_space) + 64 # assign x-y projection pos[:, 1, 0] = 2 * radius_space * np.cos(phi_space) pos[:, 1, 1] = 2 * radius_space * np.sin(phi_space) planes = np.round(np.linspace(0, 128, n)).astype(int) planes = np.concatenate( (planes.reshape((n, 1, 1)), np.zeros((n, 1, 1))), axis=1 ) vectors = np.concatenate((planes, pos), axis=2) # add the sliced vectors layer = viewer.add_vectors( vectors, edge_width=0.4, name='sliced vectors', edge_color='blue' ) viewer.dims.ndisplay = 3 if __name__ == '__main__': napari.run() napari-0.5.0a1/examples/nD_vectors_image.py000066400000000000000000000015151437041365600206630ustar00rootroot00000000000000""" nD vectors image ================ This example generates an image of vectors Vector data is an array of shape (M, N, P, 3) Each vector position is defined by an (x-proj, y-proj, z-proj) element which are vector projections centered on a pixel of the MxNxP grid .. tags:: visualization-nD """ import numpy as np import napari # create the viewer and window viewer = napari.Viewer() m = 10 n = 20 p = 40 image = 0.2 * np.random.random((m, n, p)) + 0.5 layer = viewer.add_image(image, contrast_limits=[0, 1], name='background') # sample vector image-like data # n x m grid of slanted lines # random data on the open interval (-1, 1) pos = np.random.uniform(-1, 1, size=(m, n, p, 3)) print(image.shape, pos.shape) # add the vectors vect = viewer.add_vectors(pos, edge_width=0.2, length=2.5) if __name__ == '__main__': napari.run() napari-0.5.0a1/examples/new_theme.py000066400000000000000000000016431437041365600173700ustar00rootroot00000000000000""" New theme ========= Displays an image and sets the theme to new custom theme. .. tags:: experimental """ from skimage import data import napari from napari.utils.theme import available_themes, get_theme, register_theme # create the viewer with an image viewer = napari.view_image(data.astronaut(), rgb=True, name='astronaut') # List themes print('Originally themes', available_themes()) blue_theme = get_theme('dark', False) blue_theme.id = "blue" blue_theme.icon = ( 'rgb(0, 255, 255)' # you can provide colors as rgb(XXX, YYY, ZZZ) ) blue_theme.background = 28, 31, 48 # or as tuples blue_theme.foreground = [45, 52, 71] # or as list blue_theme.primary = '#50586c' # or as hexes blue_theme.current = 'orange' # or as color name register_theme('blue', blue_theme, "custom") # List themes print('New themes', available_themes()) # Set theme viewer.theme = 'blue' if __name__ == '__main__': napari.run() napari-0.5.0a1/examples/notebook.ipynb000066400000000000000000000022671437041365600177310ustar00rootroot00000000000000{ "cells": [ { "cell_type": "markdown", "metadata": {}, "source": [ "# Display an image using Napari" ] }, { "cell_type": "markdown", "metadata": {}, "source": [ "### Initial setup" ] }, { "cell_type": "code", "execution_count": null, "metadata": {}, "outputs": [], "source": [ "from skimage import data\n", "import napari" ] }, { "cell_type": "markdown", "metadata": {}, "source": [ "### Display an image" ] }, { "cell_type": "code", "execution_count": null, "metadata": {}, "outputs": [], "source": [ "viewer = napari.view_image(data.moon())" ] }, { "cell_type": "code", "execution_count": null, "metadata": {}, "outputs": [], "source": [] } ], "metadata": { "kernelspec": { "display_name": "Python 3", "language": "python", "name": "python3" }, "language_info": { "codemirror_mode": { "name": "ipython", "version": 3 }, "file_extension": ".py", "mimetype": "text/x-python", "name": "python", "nbconvert_exporter": "python", "pygments_lexer": "ipython3", "version": "3.7.3" } }, "nbformat": 4, "nbformat_minor": 2 }napari-0.5.0a1/examples/paint-nd.py000066400000000000000000000013211437041365600171200ustar00rootroot00000000000000""" Paint nD ======== Display a 4D labels layer and paint only in 3D. This is useful e.g. when proofreading segmentations within a time series. .. tags:: analysis """ import numpy as np from skimage import data import napari blobs = np.stack( [ data.binary_blobs( length=128, blob_size_fraction=0.05, n_dim=3, volume_fraction=f ) for f in np.linspace(0.05, 0.5, 10) ], axis=0, ) viewer = napari.view_image(blobs.astype(float), rendering='attenuated_mip') labels = viewer.add_labels(np.zeros_like(blobs, dtype=np.int32)) labels.n_edit_dimensions = 3 labels.brush_size = 15 labels.mode = 'paint' labels.n_dimensional = True if __name__ == '__main__': napari.run() napari-0.5.0a1/examples/pass_colormaps.py000066400000000000000000000011361437041365600204370ustar00rootroot00000000000000""" Pass colormaps ============== Add named or unnamed vispy colormaps to existing layers. .. tags:: visualization-basic """ import numpy as np from skimage import data import napari histo = data.astronaut() / 255 rch, gch, bch = np.transpose(histo, (2, 0, 1)) v = napari.Viewer() rlayer = v.add_image( rch, name='red channel', colormap='red', blending='additive' ) glayer = v.add_image( gch, name='green channel', colormap='green', blending='additive' ) blayer = v.add_image( bch, name='blue channel', colormap='blue', blending='additive' ) if __name__ == '__main__': napari.run() napari-0.5.0a1/examples/point_cloud.py000066400000000000000000000010031437041365600177220ustar00rootroot00000000000000""" Point cloud =========== Display 3D points with combinations of different renderings. .. tags:: visualization-basic """ import numpy as np import napari n_points = 100 points = np.random.normal(10, 100, (n_points, 3)) symbols = np.random.choice(['o', 's', '*'], n_points) sizes = np.random.rand(n_points) * 10 + 10 colors = np.random.rand(n_points, 3) viewer = napari.Viewer(ndisplay=3) viewer.add_points(points, symbol=symbols, size=sizes, face_color=colors) if __name__ == '__main__': napari.run() napari-0.5.0a1/examples/points-over-time.py000066400000000000000000000021641437041365600206350ustar00rootroot00000000000000""" Points over time ================ .. tags:: visualization-advanced """ import dask.array as da import numpy as np import napari image4d = da.random.random( (4000, 32, 256, 256), chunks=(1, 32, 256, 256), ) pts_coordinates = np.random.random((50000, 3)) * image4d.shape[1:] pts_values = da.random.random((50000, 4000), chunks=(50000, 1)) viewer = napari.Viewer(ndisplay=3) image_layer = viewer.add_image( image4d, opacity=0.5 ) pts_layer = viewer.add_points( pts_coordinates, features={'value': np.asarray(pts_values[:, 0])}, face_color='value', size=2, ) def set_pts_features(pts_layer, values_table, step): # step is a 4D coordinate with the current slider position for each dim column = step[0] # grab the leading ("time") coordinate pts_layer.features['value'] = np.asarray(values_table[:, column]) pts_layer.face_color = 'value' # force features refresh viewer.dims.events.current_step.connect( lambda event: set_pts_features(pts_layer, pts_values, event.value) ) if __name__ == '__main__': napari.run() napari-0.5.0a1/examples/progress_bar_minimal_.py000066400000000000000000000065301437041365600217520ustar00rootroot00000000000000""" Progress bar minimal ==================== This file provides minimal working examples of progress bars in the napari viewer. .. tags:: gui """ from random import choice from time import sleep import numpy as np from qtpy.QtWidgets import QPushButton, QVBoxLayout, QWidget import napari from napari.utils import progress def process(im_slice): # do something with your image slice sleep(0.4) def iterable(): """using progress as a wrapper for iterables """ my_stacked_volume = np.random.random((5, 4, 500, 500)) # we can wrap any iterable object in `progress` and see a progress # bar in the viewer for im_slice in progress(my_stacked_volume): process(im_slice) def iterable_w_context(): """using progress with a context manager """ my_stacked_volume = np.random.random((5, 4, 500, 500)) # progress provides a context manager we can use for automatic # teardown of our widget once iteration is complete. Wherever # possible, we should *always* use progress within a context with progress(my_stacked_volume) as pbr: for i, im_slice in enumerate(pbr): # using a context manager also allows us to manipulate # the progress object e.g. by setting a description pbr.set_description(f"Slice {i}") # we can group progress bars together in the viewer # by passing a parent progress bar to new progress # objects' nest_under attribute for channel in progress(im_slice, nest_under=pbr): process(channel) def indeterminate(): """By passing a total of 0, we can have an indeterminate progress bar """ # note progress(total=0) is equivalent to progress() with progress(total=0) as pbr: x = 0 while x != 42: pbr.set_description(f"Processing {x}") x = choice(range(100)) sleep(0.05) def arbitrary_steps(): """We can manually control updating the value of the progress bar. """ with progress(total=4) as pbr: sleep(3) pbr.set_description("Step 1 Complete") # manually updating the progress bar by 1 pbr.update(1) sleep(1) pbr.set_description("Step 2 Complete") pbr.update(1) sleep(2) pbr.set_description("Processing Complete!") # we can manually update by any number of steps pbr.update(2) # sleeping so we can see full completion sleep(1) viewer = napari.Viewer() button_layout = QVBoxLayout() iterable_btn = QPushButton("Iterable") iterable_btn.clicked.connect(iterable) button_layout.addWidget(iterable_btn) iterable_context_btn = QPushButton("Iterable With Context") iterable_context_btn.clicked.connect(iterable_w_context) button_layout.addWidget(iterable_context_btn) indeterminate_btn = QPushButton("Indeterminate") indeterminate_btn.clicked.connect(indeterminate) button_layout.addWidget(indeterminate_btn) steps_btn = QPushButton("Arbitrary Steps") steps_btn.clicked.connect(arbitrary_steps) button_layout.addWidget(steps_btn) pbar_widget = QWidget() pbar_widget.setLayout(button_layout) pbar_widget.setObjectName("Progress Examples") viewer.window.add_dock_widget(pbar_widget) # showing the activity dock so we can see the progress bars viewer.window._status_bar._toggle_activity_dock(True) if __name__ == '__main__': napari.run() napari-0.5.0a1/examples/progress_bar_segmentation_.py000066400000000000000000000120321437041365600230130ustar00rootroot00000000000000""" Progress bar segmentation ========================= Use napari's tqdm wrapper to display the progress of long-running operations in the viewer. .. tags:: gui """ import numpy as np from qtpy.QtWidgets import QPushButton, QVBoxLayout, QWidget from skimage.filters import ( threshold_isodata, threshold_li, threshold_otsu, threshold_triangle, threshold_yen, ) from skimage.measure import label import napari from napari.utils import progress # we will try each of these thresholds on our image all_thresholds = [ threshold_isodata, threshold_li, threshold_otsu, threshold_triangle, threshold_yen, ] viewer = napari.Viewer() # load cells data and take just nuclei membrane, cell_nuclei = viewer.open_sample('napari', 'cells3d') cell_nuclei = cell_nuclei.data def try_thresholds(): """Tries each threshold, and adds result to viewer.""" if 'Binarised' in viewer.layers: del viewer.layers['Binarised'] thresholded_nuclei = [] # we wrap our iterable with `progress` # this will automatically add a progress bar to our activity dock for threshold_func in progress(all_thresholds): current_threshold = threshold_func(cell_nuclei) binarised_im = cell_nuclei > current_threshold thresholded_nuclei.append(binarised_im) # uncomment if processing is too fast # from time import sleep # sleep(0.5) # working with a wrapped iterable, the progress bar will be closed # as soon as the iteration is complete binarised_nuclei = np.stack(thresholded_nuclei) viewer.add_labels( binarised_nuclei, color={1: 'lightgreen'}, opacity=0.7, name="Binarised", blending='translucent', ) # In the previous example, we were able to see the progress bar, but were not # able to control it. By using `progress` within a context manager, we can # manipulate the `progress` object and still get the benefit of automatic # clean up def segment_binarised_ims(): """Segments each of the binarised ims. Uses `progress` within a context manager allowing us to manipulate the progress bar within the loop """ if 'Binarised' not in viewer.layers: raise TypeError("Cannot segment before thresholding") if 'Segmented' in viewer.layers: del viewer.layers['Segmented'] binarised_data = viewer.layers['Binarised'].data segmented_nuclei = [] # using the `with` keyword we can use `progress` inside a context manager # `progress` inherits from tqdm and therefore provides the same API # e.g. we can provide the miniters argument if we want to see the # progress bar update with each iteration with progress(binarised_data, miniters=0) as pbar: for i, binarised_cells in enumerate(pbar): # this allows us to manipulate the pbar object within the loop # e.g. setting the description. pbar.set_description(all_thresholds[i].__name__.split("_")[1]) labelled_im = label(binarised_cells) segmented_nuclei.append(labelled_im) # uncomment if processing is too fast # from time import sleep # sleep(0.5) # progress bar is still automatically closed segmented_nuclei = np.stack(segmented_nuclei) viewer.add_labels( segmented_nuclei, name="Segmented", blending='translucent', ) viewer.layers['Binarised'].visible = False # we can also manually control `progress` objects using their # `update` method (inherited from tqdm) def process_ims(): """ First performs thresholding, then segmentation on our image. Manually updates a `progress` object. """ if 'Binarised' in viewer.layers: del viewer.layers['Binarised'] if 'Segmented' in viewer.layers: del viewer.layers['Segmented'] # we instantiate a manually controlled `progress` object # by just passing a total with no iterable with progress(total=2) as pbar: pbar.set_description("Thresholding") try_thresholds() # once one processing step is complete, we increment # the value of our progress bar pbar.update(1) pbar.set_description("Segmenting") segment_binarised_ims() pbar.update(1) # uncomment this line to see the 100% progress bar # from time import sleep # sleep(0.5) button_layout = QVBoxLayout() process_btn = QPushButton("Full Process") process_btn.clicked.connect(process_ims) button_layout.addWidget(process_btn) thresh_btn = QPushButton("1.Threshold") thresh_btn.clicked.connect(try_thresholds) button_layout.addWidget(thresh_btn) segment_btn = QPushButton("2.Segment") segment_btn.clicked.connect(segment_binarised_ims) button_layout.addWidget(segment_btn) action_widget = QWidget() action_widget.setLayout(button_layout) action_widget.setObjectName("Segmentation") viewer.window.add_dock_widget(action_widget) # showing the activity dock so we can see the progress bars viewer.window._status_bar._toggle_activity_dock(True) if __name__ == '__main__': napari.run() napari-0.5.0a1/examples/progress_bar_threading_.py000066400000000000000000000052671437041365600222770ustar00rootroot00000000000000""" Progress bar threading ====================== This file provides a minimal working example using a progress bar alongside ``@thread_worker`` to report progress. .. tags:: interactivity """ from time import sleep from qtpy.QtWidgets import QPushButton, QVBoxLayout, QWidget import napari from napari.qt import thread_worker viewer = napari.Viewer() def handle_yields(yielded_val): print(f"Just yielded: {yielded_val}") # generator thread workers can provide progress updates on each yield @thread_worker( # passing a progress dictionary with the total number of expected yields # will place a progress bar in the activity dock and increment its value # with each yield. We can optionally pass a description for the bar # using the 'desc' key. progress={'total': 5, 'desc': 'thread-progress'}, # this does not preclude us from connecting other functions to any of the # worker signals (including `yielded`) connect={'yielded': handle_yields}, ) def my_long_running_thread(*_): for i in range(5): sleep(0.1) yield i @thread_worker( # If we are unsure of the number of expected yields, # we can still pass an estimate to total, # and the progress bar will become indeterminate # once this number is exceeded. progress={'total': 5}, # we can also get a simple indeterminate progress bar # by passing progress=True connect={'yielded': handle_yields}, ) def my_indeterminate_thread(*_): for i in range(10): sleep(0.1) yield i def return_func(return_val): print(f"Returned: {return_val}") # finally, a FunctionWorker can still provide an indeterminate # progress bar, but will not take a total>0 @thread_worker( progress={'total': 0, 'desc': 'FunctionWorker'}, # can use progress=True if not passing description connect={'returned': return_func}, ) def my_function(*_): sum = 0 for i in range(10): sum += i sleep(0.1) return sum button_layout = QVBoxLayout() start_btn = QPushButton("Start") start_btn.clicked.connect(my_long_running_thread) button_layout.addWidget(start_btn) start_btn2 = QPushButton("Start Indeterminate") start_btn2.clicked.connect(my_indeterminate_thread) button_layout.addWidget(start_btn2) start_btn3 = QPushButton("Start FunctionWorker") start_btn3.clicked.connect(my_function) button_layout.addWidget(start_btn3) pbar_widget = QWidget() pbar_widget.setLayout(button_layout) pbar_widget.setObjectName("Threading Examples") viewer.window.add_dock_widget(pbar_widget, allowed_areas=["right"]) # showing the activity dock so we can see the progress bars viewer.window._status_bar._toggle_activity_dock(True) if __name__ == '__main__': napari.run() napari-0.5.0a1/examples/reader_plugin.py000066400000000000000000000016201437041365600202300ustar00rootroot00000000000000""" Reader plugin ============= Barebones reader plugin example, using ``imageio.imread``` .. tags:: historical """ from imageio import formats, imread from napari_plugin_engine import napari_hook_implementation readable_extensions = tuple({x for f in formats for x in f.extensions}) @napari_hook_implementation def napari_get_reader(path): """A basic implementation of the napari_get_reader hook specification.""" # if we know we cannot read the file, we immediately return None. if not path.endswith(readable_extensions): return None # otherwise we return the *function* that can read ``path``. return reader_function def reader_function(path): """Take a path and returns a list of LayerData tuples.""" data = imread(path) # Readers are expected to return data as a list of tuples, where each tuple # is (data, [meta_dict, [layer_type]]) return [(data,)] napari-0.5.0a1/examples/scale_bar.py000066400000000000000000000006341437041365600173270ustar00rootroot00000000000000""" Scale bar ========= Display a 3D volume and the scale bar .. tags:: experimental """ from skimage import data import napari cells = data.cells3d() viewer = napari.Viewer(ndisplay=3) viewer.add_image( cells, name=('membrane', 'nuclei'), channel_axis=1, scale=(0.29, 0.26, 0.26), ) viewer.scale_bar.visible = True viewer.scale_bar.unit = "um" if __name__ == '__main__': napari.run() napari-0.5.0a1/examples/set_colormaps.py000066400000000000000000000016021437041365600202620ustar00rootroot00000000000000""" Set colormaps ============= Add named or unnamed vispy colormaps to existing layers. .. tags:: visualization-basic """ import numpy as np import vispy.color from skimage import data import napari histo = data.astronaut() / 255 rch, gch, bch = np.transpose(histo, (2, 0, 1)) red = vispy.color.Colormap([[0.0, 0.0, 0.0], [1.0, 0.0, 0.0]]) green = vispy.color.Colormap([[0.0, 0.0, 0.0], [0.0, 1.0, 0.0]]) blue = vispy.color.Colormap([[0.0, 0.0, 0.0], [0.0, 0.0, 1.0]]) v = napari.Viewer() rlayer = v.add_image(rch, name='red channel') rlayer.blending = 'additive' rlayer.colormap = 'red', red glayer = v.add_image(gch, name='green channel') glayer.blending = 'additive' glayer.colormap = green # this will appear as [unnamed colormap] blayer = v.add_image(bch, name='blue channel') blayer.blending = 'additive' blayer.colormap = {'blue': blue} if __name__ == '__main__': napari.run() napari-0.5.0a1/examples/set_theme.py000066400000000000000000000005221437041365600173650ustar00rootroot00000000000000""" Set theme ========= Displays an image and sets the theme to 'light'. .. tags:: gui """ from skimage import data import napari # create the viewer with an image viewer = napari.view_image(data.astronaut(), rgb=True, name='astronaut') # set the theme to 'light' viewer.theme = 'light' if __name__ == '__main__': napari.run() napari-0.5.0a1/examples/shapes_to_labels.py000066400000000000000000000043611437041365600207240ustar00rootroot00000000000000""" Shapes to labels ================ Display one shapes layer ontop of one image layer using the ``add_shapes`` and ``add_image`` APIs. When the window is closed it will print the coordinates of your shapes. .. tags:: historical """ import numpy as np from skimage import data from vispy.color import Colormap import napari # create the viewer and window viewer = napari.Viewer() # add the image img_layer = viewer.add_image(data.camera(), name='photographer') # create a list of polygons polygons = [ np.array([[11, 13], [111, 113], [22, 246]]), np.array( [ [505, 60], [402, 71], [383, 42], [251, 95], [212, 59], [131, 137], [126, 187], [191, 204], [171, 248], [211, 260], [273, 243], [264, 225], [430, 173], [512, 160], ] ), np.array( [ [310, 382], [229, 381], [209, 401], [221, 411], [258, 411], [300, 412], [306, 435], [268, 434], [265, 454], [298, 461], [307, 461], [307, 507], [349, 510], [352, 369], [330, 366], [330, 366], ] ), ] # add polygons layer = viewer.add_shapes( polygons, shape_type='polygon', edge_width=1, edge_color='coral', face_color='royalblue', name='shapes', ) # change some attributes of the layer layer.selected_data = set(range(layer.nshapes)) layer.current_edge_width = 5 layer.opacity = 0.75 layer.selected_data = set() # add an ellipse to the layer ellipse = np.array([[59, 222], [110, 289], [170, 243], [119, 176]]) layer.add( ellipse, shape_type='ellipse', edge_width=5, edge_color='coral', face_color='purple', ) masks = layer.to_masks([512, 512]) masks_layer = viewer.add_image(masks.astype(float), name='masks') masks_layer.opacity = 0.7 masks_layer.colormap = Colormap([[0.0, 0.0, 0.0, 0.0], [1.0, 0.0, 0.0, 1.0]]) labels = layer.to_labels([512, 512]) labels_layer = viewer.add_labels(labels, name='labels') labels_layer.visible = False if __name__ == '__main__': napari.run() napari-0.5.0a1/examples/show_points_based_on_feature.py000066400000000000000000000017051437041365600233350ustar00rootroot00000000000000""" Show points based on feature ============================ .. tags:: visualization-advanced """ #!/usr/bin/env python3 import numpy as np from magicgui import magicgui import napari # create points with a randomized "confidence" feature points = np.random.rand(100, 3) * 100 colors = np.random.rand(100, 3) confidence = np.random.rand(100) viewer = napari.Viewer(ndisplay=3) points = viewer.add_points( points, face_color=colors, features={'confidence': confidence} ) # create a simple widget with magicgui which provides a slider that controls the visilibility # of individual points based on their "confidence" value @magicgui( auto_call=True, threshold={'widget_type': 'FloatSlider', 'min': 0, 'max': 1} ) def confidence_slider(layer: napari.layers.Points, threshold=0.5): layer.shown = layer.features['confidence'] > threshold viewer.window.add_dock_widget(confidence_slider) if __name__ == '__main__': napari.run() napari-0.5.0a1/examples/spheres_.py000066400000000000000000000007511437041365600172240ustar00rootroot00000000000000""" Spheres ======= Display two spheres with Surface layers .. tags:: visualization-advanced """ from vispy.geometry import create_sphere import napari mesh = create_sphere(method='ico') faces = mesh.get_faces() vert = mesh.get_vertices() * 100 sphere1 = (vert + 30, faces) sphere2 = (vert - 30, faces) viewer = napari.Viewer(ndisplay=3) surface1 = viewer.add_surface(sphere1) surface2 = viewer.add_surface(sphere2) viewer.reset_view() if __name__ == '__main__': napari.run() napari-0.5.0a1/examples/spherical_points.py000066400000000000000000000010761437041365600207630ustar00rootroot00000000000000""" Spherical points ================ .. tags:: experimental """ import numpy as np import napari np.random.seed() pts = np.random.rand(100, 3) * 100 colors = np.random.rand(100, 3) sizes = np.random.rand(100) * 20 + 10 viewer = napari.Viewer(ndisplay=3) pts_layer = viewer.add_points( pts, face_color=colors, size=sizes, shading='spherical', edge_width=0, ) # antialiasing is currently a bit broken, this is especially bad in 3D so # we turn it off here pts_layer.antialiasing = 0 viewer.reset_view() if __name__ == '__main__': napari.run() napari-0.5.0a1/examples/surface_normals_wireframe.py000066400000000000000000000012161437041365600226350ustar00rootroot00000000000000""" Surface normals wireframe ========================= Display a 3D mesh with normals and wireframe .. tags:: experimental """ from vispy.io import load_data_file, read_mesh import napari vert, faces, _, _ = read_mesh(load_data_file('orig/triceratops.obj.gz')) # put the mesh right side up, scale it up (napari#3477) and fix faces handedness vert *= -100 faces = faces[:, ::-1] viewer = napari.Viewer(ndisplay=3) surface = viewer.add_surface(data=(vert, faces)) # enable normals and wireframe surface.normals.face.visible = True surface.normals.vertex.visible = True surface.wireframe.visible = True if __name__ == '__main__': napari.run() napari-0.5.0a1/examples/surface_timeseries_.py000066400000000000000000000025061437041365600214340ustar00rootroot00000000000000""" Surface timeseries ================== Display a surface timeseries using data from nilearn .. tags:: experimental """ try: from nilearn import datasets, surface except ModuleNotFoundError: raise ModuleNotFoundError( "You must have nilearn installed to run this example." ) from None import napari # Fetch datasets - this will download dataset if datasets are not found nki_dataset = datasets.fetch_surf_nki_enhanced(n_subjects=1) fsaverage = datasets.fetch_surf_fsaverage() # Load surface data and resting state time series from nilearn brain_vertices, brain_faces = surface.load_surf_data(fsaverage['pial_left']) brain_vertex_depth = surface.load_surf_data(fsaverage['sulc_left']) timeseries = surface.load_surf_data(nki_dataset['func_left'][0]) # nilearn provides data as n_vertices x n_timepoints, but napari requires the # vertices axis to be placed last to match NumPy broadcasting rules timeseries = timeseries.transpose((1, 0)) # create an empty viewer viewer = napari.Viewer(ndisplay=3) # add the mri viewer.add_surface((brain_vertices, brain_faces, brain_vertex_depth), name='base') viewer.add_surface((brain_vertices, brain_faces, timeseries), colormap='turbo', opacity=0.9, contrast_limits=[-1.5, 3.5], name='timeseries') if __name__ == '__main__': napari.run() napari-0.5.0a1/examples/swap_dims.py000066400000000000000000000014321437041365600173770ustar00rootroot00000000000000""" Swap dims ========= Display a 4-D image and points layer and swap the displayed dimensions .. tags:: visualization-nD """ import numpy as np from skimage import data import napari blobs = np.stack( [ data.binary_blobs( length=128, blob_size_fraction=0.05, n_dim=3, volume_fraction=f ) for f in np.linspace(0.05, 0.5, 10) ], axis=0, ) viewer = napari.view_image(blobs.astype(float)) # add the points points = np.array( [ [0, 0, 0, 100], [0, 0, 50, 120], [1, 0, 100, 40], [2, 10, 110, 100], [9, 8, 80, 100], ] ) viewer.add_points( points, size=[0, 6, 10, 10], face_color='blue', out_of_slice_display=True ) viewer.dims.order = (0, 2, 1, 3) if __name__ == '__main__': napari.run() napari-0.5.0a1/examples/tiled-rendering-2d_.py000066400000000000000000000026111437041365600211270ustar00rootroot00000000000000""" Tiled rendering 2D ================== This example shows how to display tiled, chunked data in napari using the experimental octree support. If given a large 2D image with octree support enabled, napari will only load and display the tiles in the center of the current canvas view. (Note: napari uses its own internal tile size that may or may not be aligned with the underlying tiled data, but this should have only minor performance consequences.) If octree support is *not* enabled, napari will try to load the entire image, which may not fit in memory and may bring your computer to a halt. Oops! So, we make sure that we enable octree support by setting the NAPARI_OCTREE environment variable to 1 if it is not set by the user. .. tags:: experimental """ import os # important: if this is not set, the entire ~4GB array will be created! os.environ.setdefault('NAPARI_OCTREE', '1') import dask.array as da # noqa: E402 import napari # noqa: E402 ndim = 2 data = da.random.randint( 0, 256, (65536,) * ndim, chunks=(256,) * ndim, dtype='uint8' ) viewer = napari.Viewer() viewer.add_image(data, contrast_limits=[0, 255]) # To turn off grid lines #viewer.layers[0].display.show_grid = False # set small zoom so we don't try to load the whole image at once viewer.camera.zoom = 0.75 # run the example — try to pan around! if __name__ == '__main__': napari.run() napari-0.5.0a1/examples/to_screenshot.py000066400000000000000000000060211437041365600202670ustar00rootroot00000000000000""" To screenshot ============= Display one shapes layer ontop of one image layer using the ``add_shapes`` and ``add_image`` APIs. When the window is closed it will print the coordinates of your shapes. .. tags:: visualization-advanced """ import numpy as np from skimage import data from vispy.color import Colormap import napari # create the viewer and window viewer = napari.Viewer() # add the image img_layer = viewer.add_image(data.camera(), name='photographer') img_layer.colormap = 'gray' # create a list of polygons polygons = [ np.array([[11, 13], [111, 113], [22, 246]]), np.array( [ [505, 60], [402, 71], [383, 42], [251, 95], [212, 59], [131, 137], [126, 187], [191, 204], [171, 248], [211, 260], [273, 243], [264, 225], [430, 173], [512, 160], ] ), np.array( [ [310, 382], [229, 381], [209, 401], [221, 411], [258, 411], [300, 412], [306, 435], [268, 434], [265, 454], [298, 461], [307, 461], [307, 507], [349, 510], [352, 369], [330, 366], [330, 366], ] ), ] # add polygons layer = viewer.add_shapes( polygons, shape_type='polygon', edge_width=1, edge_color='coral', face_color='royalblue', name='shapes', ) # change some attributes of the layer layer.selected_data = set(range(layer.nshapes)) layer.current_edge_width = 5 layer.opacity = 0.75 layer.selected_data = set() # add an ellipse to the layer ellipse = np.array([[59, 222], [110, 289], [170, 243], [119, 176]]) layer.add( ellipse, shape_type='ellipse', edge_width=5, edge_color='coral', face_color='purple', ) masks = layer.to_masks([512, 512]) masks_layer = viewer.add_image(masks.astype(float), name='masks') masks_layer.opacity = 0.7 masks_layer.colormap = Colormap([[0.0, 0.0, 0.0, 0.0], [1.0, 0.0, 0.0, 1.0]]) labels = layer.to_labels([512, 512]) labels_layer = viewer.add_labels(labels, name='labels') points = np.array([[100, 100], [200, 200], [333, 111]]) size = np.array([10, 20, 20]) viewer.add_points(points, size=size) # sample vector coord-like data n = 100 pos = np.zeros((n, 2, 2), dtype=np.float32) phi_space = np.linspace(0, 4 * np.pi, n) radius_space = np.linspace(0, 100, n) # assign x-y position pos[:, 0, 0] = radius_space * np.cos(phi_space) + 350 pos[:, 0, 1] = radius_space * np.sin(phi_space) + 256 # assign x-y projection pos[:, 1, 0] = 2 * radius_space * np.cos(phi_space) pos[:, 1, 1] = 2 * radius_space * np.sin(phi_space) # add the vectors layer = viewer.add_vectors(pos, edge_width=2) # take screenshot screenshot = viewer.screenshot() viewer.add_image(screenshot, rgb=True, name='screenshot') # from skimage.io import imsave # imsave('screenshot.png', screenshot) if __name__ == '__main__': napari.run() napari-0.5.0a1/examples/tracks_2d.py000066400000000000000000000025421437041365600172700ustar00rootroot00000000000000""" Tracks 2D ========= .. tags:: visualization-basic """ import numpy as np import napari def _circle(r, theta): x = r * np.cos(theta) y = r * np.sin(theta) return x, y def tracks_2d(num_tracks=10): """ create 2d+t track data """ tracks = [] for track_id in range(num_tracks): # space to store the track data and features track = np.zeros((100, 6), dtype=np.float32) # time timestamps = np.arange(track.shape[0]) radius = 20 + 30 * np.random.random() theta = timestamps * 0.1 + np.random.random() * np.pi x, y = _circle(radius, theta) track[:, 0] = track_id track[:, 1] = timestamps track[:, 2] = 50.0 + y track[:, 3] = 50.0 + x track[:, 4] = theta track[:, 5] = radius tracks.append(track) tracks = np.concatenate(tracks, axis=0) data = tracks[:, :4] # just the coordinate data features = { 'time': tracks[:, 1], 'theta': tracks[:, 4], 'radius': tracks[:, 5], } graph = {} return data, features, graph tracks, features, graph = tracks_2d(num_tracks=10) vertices = tracks[:, 1:] viewer = napari.Viewer() viewer.add_points(vertices, size=1, name='points', opacity=0.3) viewer.add_tracks(tracks, features=features, name='tracks') if __name__ == '__main__': napari.run() napari-0.5.0a1/examples/tracks_3d.py000066400000000000000000000035461437041365600172760ustar00rootroot00000000000000""" Tracks 3D ========= .. tags:: visualization-advanced """ import numpy as np import napari def lissajous(t): a = np.random.random(size=(3,)) * 80.0 - 40.0 b = np.random.random(size=(3,)) * 0.05 c = np.random.random(size=(3,)) * 0.1 return (a[i] * np.cos(b[i] * t + c[i]) for i in range(3)) def tracks_3d(num_tracks=10): """ create 3d+t track data """ tracks = [] for track_id in range(num_tracks): # space to store the track data and features track = np.zeros((200, 10), dtype=np.float32) # time timestamps = np.arange(track.shape[0]) x, y, z = lissajous(timestamps) track[:, 0] = track_id track[:, 1] = timestamps track[:, 2] = 50.0 + z track[:, 3] = 50.0 + y track[:, 4] = 50.0 + x # calculate the speed as a feature gz = np.gradient(track[:, 2]) gy = np.gradient(track[:, 3]) gx = np.gradient(track[:, 4]) speed = np.sqrt(gx ** 2 + gy ** 2 + gz ** 2) distance = np.sqrt(x ** 2 + y ** 2 + z ** 2) track[:, 5] = gz track[:, 6] = gy track[:, 7] = gx track[:, 8] = speed track[:, 9] = distance tracks.append(track) tracks = np.concatenate(tracks, axis=0) data = tracks[:, :5] # just the coordinate data features = { 'time': tracks[:, 1], 'gradient_z': tracks[:, 5], 'gradient_y': tracks[:, 6], 'gradient_x': tracks[:, 7], 'speed': tracks[:, 8], 'distance': tracks[:, 9], } graph = {} return data, features, graph tracks, features, graph = tracks_3d(num_tracks=100) vertices = tracks[:, 1:] viewer = napari.Viewer(ndisplay=3) viewer.add_points(vertices, size=1, name='points', opacity=0.3) viewer.add_tracks(tracks, features=features, name='tracks') if __name__ == '__main__': napari.run() napari-0.5.0a1/examples/tracks_3d_with_graph.py000066400000000000000000000024471437041365600215110ustar00rootroot00000000000000""" Tracks 3D with graph ==================== .. tags:: visualization-advanced """ import numpy as np import napari def _circle(r, theta): x = r * np.cos(theta) y = r * np.sin(theta) return x, y def tracks_3d_merge_split(): """Create tracks with splitting and merging.""" timestamps = np.arange(300) def _trajectory(t, r, track_id): theta = t * 0.1 x, y = _circle(r, theta) z = np.zeros(x.shape) tid = np.ones(x.shape) * track_id return np.stack([tid, t, z, y, x], axis=1) trackA = _trajectory(timestamps[:100], 30.0, 0) trackB = _trajectory(timestamps[100:200], 10.0, 1) trackC = _trajectory(timestamps[100:200], 50.0, 2) trackD = _trajectory(timestamps[200:], 30.0, 3) data = [trackA, trackB, trackC, trackD] tracks = np.concatenate(data, axis=0) tracks[:, 2:] += 50.0 # centre the track at (50, 50, 50) graph = {1: 0, 2: [0], 3: [1, 2]} features = {'time': tracks[:, 1]} return tracks, features, graph tracks, features, graph = tracks_3d_merge_split() vertices = tracks[:, 1:] viewer = napari.Viewer(ndisplay=3) viewer.add_points(vertices, size=1, name='points', opacity=0.3) viewer.add_tracks(tracks, features=features, graph=graph, name='tracks') if __name__ == '__main__': napari.run() napari-0.5.0a1/examples/update_console.py000066400000000000000000000031511437041365600204150ustar00rootroot00000000000000""" Update console ============== Display one shapes layer ontop of one image layer using the add_shapes and add_image APIs. When the window is closed it will print the coordinates of your shapes. .. tags:: historical """ import numpy as np from skimage import data import napari # create the viewer and window viewer = napari.Viewer() # add the image photographer = data.camera() image_layer = napari.view_image(photographer, name='photographer') # create a list of polygons polygons = [ np.array([[11, 13], [111, 113], [22, 246]]), np.array( [ [505, 60], [402, 71], [383, 42], [251, 95], [212, 59], [131, 137], [126, 187], [191, 204], [171, 248], [211, 260], [273, 243], [264, 225], [430, 173], [512, 160], ] ), np.array( [ [310, 382], [229, 381], [209, 401], [221, 411], [258, 411], [300, 412], [306, 435], [268, 434], [265, 454], [298, 461], [307, 461], [307, 507], [349, 510], [352, 369], [330, 366], [330, 366], ] ), ] # add polygons shapes_layer = viewer.add_shapes( polygons, shape_type='polygon', edge_width=5, edge_color='coral', face_color='royalblue', name='shapes', ) # Send local variables to the console viewer.update_console(locals()) if __name__ == '__main__': napari.run() napari-0.5.0a1/examples/viewer_fps_label.py000066400000000000000000000007401437041365600207220ustar00rootroot00000000000000""" Viewer FPS label ================ Display a 3D volume and the fps label. .. tags:: experimental """ import numpy as np import napari def update_fps(fps): """Update fps.""" viewer.text_overlay.text = f"{fps:1.1f} FPS" viewer = napari.Viewer() viewer.add_image(np.random.random((5, 5, 5)), colormap='red', opacity=0.8) viewer.text_overlay.visible = True viewer.window.qt_viewer.canvas.measure_fps(callback=update_fps) if __name__ == '__main__': napari.run() napari-0.5.0a1/examples/viewer_loop_reproducible_screenshots.md000066400000000000000000000061771437041365600251050ustar00rootroot00000000000000--- jupytext: formats: ipynb,md:myst text_representation: extension: .md format_name: myst format_version: 0.13 jupytext_version: 1.13.8 kernelspec: display_name: Python 3 (ipykernel) language: python name: python3 --- ```{tags} gui ``` # Creating reproducible screenshots with a viewer loop This example captures images in three dimensions for multiple samples. This can be e.g. useful when one has dozens of ct scans and wants to visualize them for a quick overview with napari but does not want to load them one by one. Reproducibility is achieved by defining exact frame width and frame height. +++ The first cell takes care of the imports and data initializing, in this case a blob, a ball and an octahedron. ```{code-cell} ipython3 from napari.settings import get_settings import time import napari from napari._qt.qthreading import thread_worker from skimage import data from skimage.morphology import ball, octahedron import matplotlib.pyplot as plt def make_screenshot(viewer): img = viewer.screenshot(canvas_only=True, flash=False) plt.imshow(img) plt.axis("off") plt.show() myblob = data.binary_blobs( length=200, volume_fraction=0.1, blob_size_fraction=0.3, n_dim=3, seed=42 ) myoctahedron = octahedron(100) myball = ball(100) # store the variables in a dict with the image name as key. data_dict = { "blob": myblob, "ball": myball, "octahedron": myoctahedron, } ``` Now, the napari viewer settings can be adjusted programmatically, such as 3D rendering methods, axes visible, color maps, zoom level, and camera orientation. Every plot will have these exact settings, while only one napari viewer instance is needed. After setting these parameters, one should not make changes with the mouse in the napari viewer anymore, as this would rule out the reproducibility. ```{code-cell} ipython3 viewer = napari.Viewer() viewer.window.resize(900, 600) viewer.theme = "light" viewer.dims.ndisplay = 3 viewer.axes.visible = True viewer.axes.colored = False viewer.axes.labels = False viewer.text_overlay.visible = True viewer.text_overlay.text = "Hello World!" # Not yet implemented, but can be added as soon as this feature exisits (syntax might change): # viewer.controls.visible = False viewer.add_labels(myball, name="result" , opacity=1) viewer.camera.angles = (19, -33, -121) viewer.camera.zoom = 1.3 ``` Next, the loop run is defined. The `loop_run` function reads new `image_data` and the corresponding `image_name` and yields them to napari. The `update_layer` function gives instructions how to process the yielded data in the napari viewer. ```{code-cell} ipython3 @thread_worker def loop_run(): for image_name in data_dict: time.sleep(0.5) image_data = data_dict[image_name] yield (image_data, image_name) def update_layer(image_text_tuple): image, text = image_text_tuple viewer.layers["result"].data = image viewer.text_overlay.text = text make_screenshot(viewer) ``` And finally, the loop is executed: ```{code-cell} ipython3 worker = loop_run() worker.yielded.connect(update_layer) worker.start() ``` ```{code-cell} ipython3 ``` napari-0.5.0a1/examples/without_gui_qt.py000066400000000000000000000023331437041365600204650ustar00rootroot00000000000000""" napari without gui_qt ===================== Alternative to using napari.gui_qt() context manager. This is here for historical purposes, to the transition away from the "gui_qt()" context manager. .. tags:: historical """ from collections import Counter from skimage import data import napari viewer = napari.view_image(data.astronaut(), rgb=True) # You can do anything you would normally do with the viewer object # like take a screenshot = viewer.screenshot() print('Maximum value', screenshot.max()) # To see the napari viewer and interact with the graphical user interface, # use `napari.run()`. (it's similar to `plt.show` in matplotlib) # If you only wanted the screenshot then you could skip this entirely. # *run* will *block execution of your script* until the window is closed. if __name__ == '__main__': napari.run() # When the window is closed, your script continues and you can still inspect # the viewer object. For example, add click the buttons to add various layer # types when the window is open and see the result below: print("Your viewer has the following layers:") for name, n in Counter(type(x).__name__ for x in viewer.layers).most_common(): print(f" {name:<7}: {n}") napari-0.5.0a1/examples/xarray_nD_image_.py000066400000000000000000000011121437041365600206340ustar00rootroot00000000000000""" Xarray example ============== Displays an xarray .. tags:: visualization-nD """ try: import xarray as xr except ModuleNotFoundError: raise ModuleNotFoundError( """This example uses a xarray but xarray is not installed. To install try 'pip install xarray'.""" ) from None import numpy as np import napari data = np.random.random((20, 40, 50)) xdata = xr.DataArray(data, dims=['z', 'y', 'x']) # create an empty viewer viewer = napari.Viewer() # add the xarray layer = viewer.add_image(xdata, name='xarray') if __name__ == '__main__': napari.run() napari-0.5.0a1/examples/zarr_nD_image_.py000066400000000000000000000012241437041365600203100ustar00rootroot00000000000000""" Zarr array ========== Display a zarr array .. tags:: visualization-nD """ try: import zarr except ModuleNotFoundError: raise ModuleNotFoundError( """This example uses a zarr array but zarr is not installed. To install try 'pip install zarr'.""" ) from None import napari data = zarr.zeros((102_0, 200, 210), chunks=(100, 200, 210)) data[53_0:53_1, 100:110, 110:120] = 1 print(data.shape) # For big data, we should specify the contrast_limits range, or napari will try # to find the min and max of the full image. viewer = napari.view_image(data, contrast_limits=[0, 1], rgb=False) if __name__ == '__main__': napari.run() napari-0.5.0a1/napari/000077500000000000000000000000001437041365600144735ustar00rootroot00000000000000napari-0.5.0a1/napari/__init__.py000066400000000000000000000030441437041365600166050ustar00rootroot00000000000000import os from napari._lazy import install_lazy try: from napari._version import version as __version__ except ImportError: __version__ = "not-installed" # Allows us to use pydata/sparse arrays as layer data os.environ.setdefault('SPARSE_AUTO_DENSIFY', '1') del os # Add everything that needs to be accessible from the napari namespace here. _proto_all_ = [ '__version__', 'components', 'experimental', 'layers', 'qt', 'types', 'viewer', 'utils', ] _submod_attrs = { '_event_loop': ['gui_qt', 'run'], 'plugins.io': ['save_layers'], 'utils': ['sys_info'], 'utils.notifications': ['notification_manager'], 'view_layers': [ 'view_image', 'view_labels', 'view_path', 'view_points', 'view_shapes', 'view_surface', 'view_tracks', 'view_vectors', 'imshow', ], 'viewer': ['Viewer', 'current_viewer'], } # All imports in __init__ are hidden inside of `__getattr__` to prevent # importing the full chain of packages required when calling `import napari`. # # This has the biggest implications for running `napari` on the command line # (or running `python -m napari`) since `napari.__init__` gets imported # on the way to `napari.__main__`. Importing everything here has the # potential to take a second or more, so we definitely don't want to import it # just to access the CLI (which may not actually need any of the imports) __getattr__, __dir__, __all__ = install_lazy( __name__, _proto_all_, _submod_attrs ) del install_lazy napari-0.5.0a1/napari/__init__.pyi000066400000000000000000000014161437041365600167570ustar00rootroot00000000000000import napari._qt.qt_event_loop import napari.plugins.io import napari.utils.notifications import napari.view_layers import napari.viewer __version__: str notification_manager: napari.utils.notifications.NotificationManager Viewer = napari.viewer.Viewer current_viewer = napari.viewer.current_viewer gui_qt = napari._qt.qt_event_loop.gui_qt run = napari._qt.qt_event_loop.run save_layers = napari.plugins.io.save_layers view_image = napari.view_layers.view_image view_labels = napari.view_layers.view_labels view_path = napari.view_layers.view_path view_points = napari.view_layers.view_points view_shapes = napari.view_layers.view_shapes view_surface = napari.view_layers.view_surface view_tracks = napari.view_layers.view_tracks view_vectors = napari.view_layers.view_vectors napari-0.5.0a1/napari/__main__.py000066400000000000000000000473751437041365600166050ustar00rootroot00000000000000""" napari command line viewer. """ import argparse import contextlib import logging import os import runpy import sys import warnings from ast import literal_eval from itertools import chain, repeat from pathlib import Path from textwrap import wrap from typing import Any, Dict, List from napari.utils.translations import trans class InfoAction(argparse.Action): def __call__(self, *args, **kwargs): # prevent unrelated INFO logs when doing "napari --info" from npe2 import cli from napari.utils import sys_info logging.basicConfig(level=logging.WARNING) print(sys_info()) print("Plugins:") cli.list(fields="", sort="0", format="compact") sys.exit() class PluginInfoAction(argparse.Action): def __call__(self, *args, **kwargs): # prevent unrelated INFO logs when doing "napari --info" logging.basicConfig(level=logging.WARNING) from npe2 import cli cli.list( fields="name,version,npe2,contributions", sort="name", format="table", ) sys.exit() class CitationAction(argparse.Action): def __call__(self, *args, **kwargs): # prevent unrelated INFO logs when doing "napari --citation" from napari.utils import citation_text logging.basicConfig(level=logging.WARNING) print(citation_text) sys.exit() def validate_unknown_args(unknown: List[str]) -> Dict[str, Any]: """Convert a list of strings into a dict of valid kwargs for add_* methods. Will exit program if any of the arguments are unrecognized, or are malformed. Converts string to python type using literal_eval. Parameters ---------- unknown : List[str] a list of strings gathered as "unknown" arguments in argparse. Returns ------- kwargs : Dict[str, Any] {key: val} dict suitable for the viewer.add_* methods where ``val`` is a ``literal_eval`` result, or string. """ from napari.components.viewer_model import valid_add_kwargs out: Dict[str, Any] = {} valid = set.union(*valid_add_kwargs().values()) for i, arg in enumerate(unknown): if not arg.startswith("--"): continue if "=" in arg: key, value = arg.split("=", maxsplit=1) else: key = arg key = key.lstrip('-').replace("-", "_") if key not in valid: sys.exit(f"error: unrecognized arguments: {arg}") if "=" not in arg: try: value = unknown[i + 1] if value.startswith("--"): raise IndexError() except IndexError: sys.exit(f"error: argument {arg} expected one argument") with contextlib.suppress(Exception): value = literal_eval(value) out[key] = value return out def parse_sys_argv(): """Parse command line arguments.""" from napari import __version__, layers from napari.components.viewer_model import valid_add_kwargs kwarg_options = [] for layer_type, keys in valid_add_kwargs().items(): kwarg_options.append(f" {layer_type.title()}:") keys = {k.replace('_', '-') for k in keys} lines = wrap(", ".join(sorted(keys)), break_on_hyphens=False) kwarg_options.extend([f" {line}" for line in lines]) parser = argparse.ArgumentParser( usage=__doc__, formatter_class=argparse.RawDescriptionHelpFormatter, epilog="optional layer-type-specific arguments (precede with '--'):\n" + "\n".join(kwarg_options), ) parser.add_argument('paths', nargs='*', help='path(s) to view.') parser.add_argument( '-v', '--verbose', action='count', default=0, help="increase output verbosity", ) parser.add_argument( '-w', '--with', dest='with_', nargs='+', action='append', default=[], metavar=('PLUGIN_NAME', 'WIDGET_NAME'), help=( "open napari with dock widget from specified plugin name." "(If plugin provides multiple dock widgets, widget name must also " "be provided). Use __all__ to open all dock widgets of a " "specified plugin. Multiple widgets are opened in tabs." ), ) parser.add_argument( '--version', action='version', version=f'napari version {__version__}', ) parser.add_argument( '--info', action=InfoAction, nargs=0, help='show system information and exit', ) parser.add_argument( '--plugin-info', action=PluginInfoAction, nargs=0, help='show information about plugins and exit', ) parser.add_argument( '--citation', action=CitationAction, nargs=0, help='show citation information and exit', ) # Allow multiple --stack options to be provided. # Each stack option will result in its own stack parser.add_argument( '--stack', action='append', nargs='*', default=[], help='concatenate multiple input files into a single stack. Can be provided multiple times for multiple stacks.', ) parser.add_argument( '--plugin', help='specify plugin name when opening a file', ) parser.add_argument( '--layer-type', metavar="TYPE", choices=set(layers.NAMES), help=( 'force file to be interpreted as a specific layer type. ' f'one of {set(layers.NAMES)}' ), ) parser.add_argument( '--reset', action='store_true', help='reset settings to default values.', ) parser.add_argument( '--settings-path', type=Path, help='use specific path to store and load settings.', ) args, unknown = parser.parse_known_args() # this is a hack to allow using "=" as a key=value separator while also # allowing nargs='*' on the "paths" argument... for idx, item in enumerate(reversed(args.paths)): if item.startswith("--"): unknown.append(args.paths.pop(len(args.paths) - idx - 1)) kwargs = validate_unknown_args(unknown) if unknown else {} return args, kwargs def _run(): from napari import Viewer, run from napari.settings import get_settings """Main program.""" args, kwargs = parse_sys_argv() # parse -v flags and set the appropriate logging level levels = [logging.WARNING, logging.INFO, logging.DEBUG] level = levels[min(2, args.verbose)] # prevent index error logging.basicConfig( level=level, format="%(asctime)s %(levelname)s %(message)s", datefmt='%H:%M:%S', ) if args.reset: if args.settings_path: settings = get_settings(path=args.settings_path) else: settings = get_settings() settings.reset() settings.save() sys.exit("Resetting settings to default values.\n") if args.plugin: # make sure plugin is only used when files are specified if not args.paths: sys.exit( "error: The '--plugin' argument is only valid " "when providing a file name" ) # I *think* that Qt is looking in sys.argv for a flag `--plugins`, # which emits "WARNING: No such plugin for spec 'builtins'" # so remove --plugin from sys.argv to prevent that warningz sys.argv.remove('--plugin') if any(p.endswith('.py') for p in args.paths): # we're running a script if len(args.paths) > 1: sys.exit( 'When providing a python script, only a ' 'single positional argument may be provided' ) # run the file mod = runpy.run_path(args.paths[0]) from napari_plugin_engine.markers import HookImplementationMarker # if this file had any hook implementations, register and run as plugin if any(isinstance(i, HookImplementationMarker) for i in mod.values()): _run_plugin_module(mod, os.path.basename(args.paths[0])) else: if args.with_: from napari.plugins import ( _initialize_plugins, _npe2, plugin_manager, ) # if a plugin widget has been requested, this will fail immediately # if the requested plugin/widget is not available. _initialize_plugins() plugin_manager.discover_widgets() plugin_manager_plugins = [] npe2_plugins = [] for plugin in args.with_: pname, *wnames = plugin for _name, (_pname, _wnames) in _npe2.widget_iterator(): if _name == 'dock' and pname == _pname: npe2_plugins.append(plugin) if '__all__' in wnames: wnames = _wnames break for _name, (_pname, _wnames) in plugin_manager.iter_widgets(): if _name == 'dock' and pname == _pname: plugin_manager_plugins.append(plugin) if '__all__' in wnames: # Plugin_manager iter_widgets return wnames as dict keys wnames = list(_wnames.keys()) print( trans._( 'Non-npe2 plugin {pname} detected. Disable tabify for this plugin.', deferred=True, pname=pname, ) ) break if wnames: for wname in wnames: _npe2.get_widget_contribution( pname, wname ) or plugin_manager.get_widget(pname, wname) else: _npe2.get_widget_contribution( pname ) or plugin_manager.get_widget(pname) from napari._qt.widgets.qt_splash_screen import NapariSplashScreen splash = NapariSplashScreen() splash.close() # will close once event loop starts # viewer _must_ be kept around. # it will be referenced by the global window only # once napari has finished starting # but in the meantime if the garbage collector runs; # it will collect it and hang napari at start time. # in a way that is machine, os, time (and likely weather dependant). viewer = Viewer() # For backwards compatibility # If the --stack option is provided without additional arguments # just set stack to True similar to the previous store_true action if args.stack and len(args.stack) == 1 and len(args.stack[0]) == 0: warnings.warn( trans._( "The usage of the --stack option as a boolean is deprecated. Please use '--stack file1 file2 .. fileN' instead. It is now also possible to specify multiple stacks of files to stack '--stack file1 file2 --stack file3 file4 file5 --stack ..'. This warning will become an error in version 0.5.0.", ), DeprecationWarning, stacklevel=3, ) args.stack = True viewer._window._qt_viewer._qt_open( args.paths, stack=args.stack, plugin=args.plugin, layer_type=args.layer_type, **kwargs, ) if args.with_: # Non-npe2 plugins disappear on tabify or if tabified npe2 plugins are loaded after them. # Therefore, read npe2 plugins first and do not tabify for non-npe2 plugins. for plugin, tabify in chain( zip(npe2_plugins, repeat(True)), zip(plugin_manager_plugins, repeat(False)), ): pname, *wnames = plugin if '__all__' in wnames: for name, (_pname, _wnames) in chain( _npe2.widget_iterator(), plugin_manager.iter_widgets() ): if name == 'dock' and pname == _pname: if isinstance(_wnames, dict): # Plugin_manager iter_widgets return wnames as dict keys wnames = list(_wnames.keys()) else: wnames = _wnames break if wnames: first_dock_widget = viewer.window.add_plugin_dock_widget( pname, wnames[0], tabify=tabify )[0] for wname in wnames[1:]: viewer.window.add_plugin_dock_widget( pname, wname, tabify=tabify ) first_dock_widget.show() first_dock_widget.raise_() else: viewer.window.add_plugin_dock_widget(pname, tabify=tabify) # only necessary in bundled app, but see #3596 from napari.utils.misc import ( install_certifi_opener, running_as_bundled_app, ) if running_as_bundled_app(): install_certifi_opener() run(gui_exceptions=True) def _run_plugin_module(mod, plugin_name): """Register `mod` as a plugin, find/create viewer, and run napari.""" from napari import Viewer, run from napari.plugins import plugin_manager plugin_manager.register(mod, name=plugin_name) # now, check if a viewer was created, and if not, create one. for obj in mod.values(): if isinstance(obj, Viewer): _v = obj break else: _v = Viewer() try: _v.window._qt_window.parent() except RuntimeError: # this script had a napari.run() in it, and the viewer has already been # used and cleaned up... if we eventually have "reusable viewers", we # can continue here return # finally, if the file declared a dock widget, add it to the viewer. dws = plugin_manager.hooks.napari_experimental_provide_dock_widget if any(i.plugin_name == plugin_name for i in dws.get_hookimpls()): _v.window.add_plugin_dock_widget(plugin_name) run() def _maybe_rerun_with_macos_fixes(): """ Apply some fixes needed in macOS, which might involve running this script again using a different sys.executable. 1) Quick fix for Big Sur Python 3.9 and Qt 5. No relaunch needed. 2) Using `pythonw` instead of `python`. This can be used to ensure we're using a framework build of Python on macOS, which fixes frozen menubar issues in some macOS versions. 3) Make sure the menu bar uses 'napari' as the display name. This requires relaunching the app from a symlink to the desired python executable, conveniently named 'napari'. """ from napari._qt import API_NAME # This import mus be here to raise exception about PySide6 problem if sys.platform != "darwin": return if "_NAPARI_RERUN_WITH_FIXES" in os.environ: # This function already ran, do not recurse! # We also restore sys.executable to its initial value, # if we used a symlink if exe := os.environ.pop("_NAPARI_SYMLINKED_EXECUTABLE", ""): sys.executable = exe return import platform import subprocess from tempfile import mkdtemp # In principle, we will relaunch to the same python we were using executable = sys.executable cwd = Path.cwd() _MACOS_AT_LEAST_CATALINA = int(platform.release().split('.')[0]) >= 19 _MACOS_AT_LEAST_BIG_SUR = int(platform.release().split('.')[0]) >= 20 _RUNNING_CONDA = "CONDA_PREFIX" in os.environ _RUNNING_PYTHONW = "PYTHONEXECUTABLE" in os.environ # 1) quick fix for Big Sur py3.9 and qt 5 # https://github.com/napari/napari/pull/1894 if _MACOS_AT_LEAST_BIG_SUR and '6' not in API_NAME: os.environ['QT_MAC_WANTS_LAYER'] = '1' # Create the env copy now because the following changes # should not persist in the current process in case # we do not run the subprocess! env = os.environ.copy() # 2) Ensure we're always using a "framework build" on the latest # macOS to ensure menubar works without needing to refocus napari. # We try this for macOS later than the Catalina release # See https://github.com/napari/napari/pull/1554 and # https://github.com/napari/napari/issues/380#issuecomment-659656775 # and https://github.com/ContinuumIO/anaconda-issues/issues/199 if ( _MACOS_AT_LEAST_CATALINA and not _MACOS_AT_LEAST_BIG_SUR and _RUNNING_CONDA and not _RUNNING_PYTHONW ): pythonw_path = Path(sys.exec_prefix) / 'bin' / 'pythonw' if pythonw_path.exists(): # Use this one instead of sys.executable to relaunch # the subprocess executable = pythonw_path else: msg = ( 'pythonw executable not found.\n' 'To unfreeze the menubar on macOS, ' 'click away from napari to another app, ' 'then reactivate napari. To avoid this problem, ' 'please install python.app in conda using:\n' 'conda install -c conda-forge python.app' ) warnings.warn(msg) # 3) Make sure the app name in the menu bar is 'napari', not 'python' tempdir = None _NEEDS_SYMLINK = ( # When napari is launched from the conda bundle shortcut # it already has the right 'napari' name in the app title # and __CFBundleIdentifier is set to 'com.napari._()' "napari" not in os.environ.get("__CFBundleIdentifier", "") # with a sys.executable named napari, # macOS should have picked the right name already or os.path.basename(executable) != "napari" ) if _NEEDS_SYMLINK: tempdir = mkdtemp(prefix="symlink-to-fix-macos-menu-name-") # By using a symlink with basename napari # we make macOS take 'napari' as the program name napari_link = os.path.join(tempdir, "napari") os.symlink(executable, napari_link) # Pass original executable to the subprocess so it can restore it later env["_NAPARI_SYMLINKED_EXECUTABLE"] = executable executable = napari_link # if at this point 'executable' is different from 'sys.executable', we # need to launch the subprocess to apply the fixes if sys.executable != executable: env["_NAPARI_RERUN_WITH_FIXES"] = "1" if Path(sys.argv[0]).name == "napari": # launched through entry point, we do that again to avoid # issues with working directory getting into sys.path (#5007) cmd = [executable, sys.argv[0]] else: # we assume it must have been launched via '-m' syntax cmd = [executable, "-m", "napari"] # Append original command line arguments. if len(sys.argv) > 1: cmd.extend(sys.argv[1:]) try: result = subprocess.run(cmd, env=env, cwd=cwd) sys.exit(result.returncode) finally: if tempdir is not None: import shutil shutil.rmtree(tempdir) def main(): # There a number of macOS issues we can fix with env vars # and/or relaunching a subprocess _maybe_rerun_with_macos_fixes() # Prevent https://github.com/napari/napari/issues/3415 # This one fix is needed _after_ a potential relaunch, # that's why it's here and not in _maybe_rerun_with_macos_fixes() if sys.platform == "darwin": import multiprocessing multiprocessing.set_start_method('fork') _run() if __name__ == '__main__': sys.exit(main()) napari-0.5.0a1/napari/_app_model/000077500000000000000000000000001437041365600165725ustar00rootroot00000000000000napari-0.5.0a1/napari/_app_model/__init__.py000066400000000000000000000001021437041365600206740ustar00rootroot00000000000000from napari._app_model._app import get_app __all__ = ["get_app"] napari-0.5.0a1/napari/_app_model/_app.py000066400000000000000000000046351437041365600200730ustar00rootroot00000000000000from __future__ import annotations from functools import lru_cache from itertools import chain from typing import Dict from app_model import Application from napari._app_model._submenus import SUBMENUS from napari._app_model.actions._help_actions import HELP_ACTIONS from napari._app_model.actions._layer_actions import LAYER_ACTIONS from napari._app_model.actions._view_actions import VIEW_ACTIONS from napari._app_model.injection._processors import PROCESSORS from napari._app_model.injection._providers import PROVIDERS APP_NAME = 'napari' class NapariApplication(Application): def __init__(self, app_name=APP_NAME) -> None: # raise_synchronous_exceptions means that commands triggered via # ``execute_command`` will immediately raise exceptions. Normally, # `execute_command` returns a Future object (which by definition does not # raise exceptions until requested). While we could use that future to raise # exceptions with `.result()`, for now, raising immediately should # prevent any unexpected silent errors. We can turn it off later if we # adopt asynchronous command execution. super().__init__(app_name, raise_synchronous_exceptions=True) self.injection_store.namespace = _napari_names # type: ignore [assignment] self.injection_store.register( providers=PROVIDERS, processors=PROCESSORS ) for action in chain(HELP_ACTIONS, LAYER_ACTIONS, VIEW_ACTIONS): self.register_action(action) self.menus.append_menu_items(SUBMENUS) @classmethod def get_app(cls) -> NapariApplication: return Application.get_app(APP_NAME) or cls() @lru_cache(maxsize=1) def _napari_names() -> Dict[str, object]: """Napari names to inject into local namespace when evaluating type hints.""" import napari from napari import components, layers, viewer def _public_types(module): return { name: val for name, val in vars(module).items() if not name.startswith('_') and isinstance(val, type) and getattr(val, '__module__', '_').startswith('napari') } return { 'napari': napari, **_public_types(components), **_public_types(layers), **_public_types(viewer), } def get_app() -> NapariApplication: """Get the Napari Application singleton.""" return NapariApplication.get_app() napari-0.5.0a1/napari/_app_model/_submenus.py000066400000000000000000000020621437041365600211440ustar00rootroot00000000000000from app_model.types import SubmenuItem from napari._app_model.constants import MenuGroup, MenuId from napari._app_model.context import LayerListContextKeys as LLCK from napari.utils.translations import trans SUBMENUS = [ ( MenuId.LAYERLIST_CONTEXT, SubmenuItem( submenu=MenuId.LAYERS_CONVERT_DTYPE, title=trans._('Convert data type'), group=MenuGroup.LAYERLIST_CONTEXT.CONVERSION, order=None, enablement=LLCK.all_selected_layers_labels, ), ), ( MenuId.LAYERLIST_CONTEXT, SubmenuItem( submenu=MenuId.LAYERS_PROJECT, title=trans._('Projections'), group=MenuGroup.LAYERLIST_CONTEXT.SPLIT_MERGE, order=None, enablement=LLCK.active_layer_is_image_3d, ), ), ( MenuId.MENUBAR_VIEW, SubmenuItem(submenu=MenuId.VIEW_AXES, title=trans._('Axes')), ), ( MenuId.MENUBAR_VIEW, SubmenuItem(submenu=MenuId.VIEW_SCALEBAR, title=trans._('Scale Bar')), ), ] napari-0.5.0a1/napari/_app_model/_tests/000077500000000000000000000000001437041365600200735ustar00rootroot00000000000000napari-0.5.0a1/napari/_app_model/_tests/test_app.py000066400000000000000000000013641437041365600222700ustar00rootroot00000000000000from napari._app_model import get_app from napari.layers import Points def test_app(): """just make sure our app model is registering menus and commands""" app = get_app() assert app.name == 'test_app' assert list(app.menus) assert list(app.commands) # assert list(app.keybindings) # don't have any yet def test_app_injection(): """Simple test to make sure napari namespaces are working in app injection.""" app = get_app() def use_points(points: 'Points'): return points p = Points() def provide_points() -> 'Points': return p with app.injection_store.register(providers=[(provide_points,)]): injected = app.injection_store.inject(use_points) assert injected() is p napari-0.5.0a1/napari/_app_model/_tests/test_constants.py000066400000000000000000000006421437041365600235220ustar00rootroot00000000000000from napari._app_model.constants import CommandId, MenuId def test_command_titles(): """make sure all command start with napari: and have a title""" for command in CommandId: assert command.value.startswith('napari:') assert command.title is not None def test_menus(): """make sure all menus start with napari/""" for menu in MenuId: assert menu.value.startswith('napari/') napari-0.5.0a1/napari/_app_model/_tests/test_help_menu_urls.py000066400000000000000000000005561437041365600245330ustar00rootroot00000000000000"""For testing the URLs in the Help menu""" import pytest import requests from napari._app_model.actions._help_actions import HELP_URLS @pytest.mark.parametrize('url', HELP_URLS.keys()) def test_help_urls(url): if url == 'release_notes': pytest.skip("No release notes for dev version") r = requests.head(HELP_URLS[url]) r.raise_for_status() napari-0.5.0a1/napari/_app_model/_tests/test_misc_callbacks.py000066400000000000000000000007271437041365600244440ustar00rootroot00000000000000"""For testing one off action callbacks""" from napari._app_model.actions._view_actions import _tooltip_visibility_toggle from napari.settings import get_settings def test_tooltip_visibility_toggle(): settings = get_settings().appearance assert settings.layer_tooltip_visibility is False _tooltip_visibility_toggle() assert settings.layer_tooltip_visibility is True _tooltip_visibility_toggle() assert settings.layer_tooltip_visibility is False napari-0.5.0a1/napari/_app_model/_tests/test_viewer_toggler.py000066400000000000000000000014211437041365600245260ustar00rootroot00000000000000from napari import Viewer from napari._app_model._app import get_app from napari._app_model.actions._toggle_action import ViewerToggleAction from napari.components import ViewerModel def test_viewer_toggler(): viewer = ViewerModel() action = ViewerToggleAction( id='some.command.id', title='Toggle Axis Visibility', viewer_attribute='axes', sub_attribute='visible', ) app = get_app() app.register_action(action) with app.injection_store.register(providers={Viewer: lambda: viewer}): assert viewer.axes.visible is False app.commands.execute_command('some.command.id') assert viewer.axes.visible is True app.commands.execute_command('some.command.id') assert viewer.axes.visible is False napari-0.5.0a1/napari/_app_model/actions/000077500000000000000000000000001437041365600202325ustar00rootroot00000000000000napari-0.5.0a1/napari/_app_model/actions/__init__.py000066400000000000000000000000001437041365600223310ustar00rootroot00000000000000napari-0.5.0a1/napari/_app_model/actions/_help_actions.py000066400000000000000000000055611437041365600234220ustar00rootroot00000000000000"""Actions related to the 'Help' menu that do not require Qt. View actions that do require Qt should go in `napari/_qt/_qapp_model/qactions/_help.py`. """ import webbrowser from typing import List from app_model.types import Action from packaging.version import parse from napari import __version__ from napari._app_model.constants import CommandId, MenuGroup, MenuId v = parse(__version__) VERSION = "dev" if v.is_devrelease else str(v) HELP_URLS = { "getting_started": f'https://napari.org/{VERSION}/tutorials/start_index.html', "tutorials": f'https://napari.org/{VERSION}/tutorials/index.html', "layers_guide": f'https://napari.org/{VERSION}/howtos/layers/index.html', "examples_gallery": f'https://napari.org/{VERSION}/gallery.html', "release_notes": f'https://napari.org/{VERSION}/release/release_{VERSION.replace(".", "_")}.html', "github_issue": 'https://github.com/napari/napari/issues', "homepage": 'https://napari.org', } HELP_ACTIONS: List[Action] = [ Action( id=CommandId.NAPARI_GETTING_STARTED, title=CommandId.NAPARI_GETTING_STARTED.title, callback=lambda: webbrowser.open(HELP_URLS['getting_started']), menus=[{'id': MenuId.MENUBAR_HELP}], ), Action( id=CommandId.NAPARI_TUTORIALS, title=CommandId.NAPARI_TUTORIALS.title, callback=lambda: webbrowser.open(HELP_URLS['tutorials']), menus=[{'id': MenuId.MENUBAR_HELP}], ), Action( id=CommandId.NAPARI_LAYERS_GUIDE, title=CommandId.NAPARI_LAYERS_GUIDE.title, callback=lambda: webbrowser.open(HELP_URLS['layers_guide']), menus=[{'id': MenuId.MENUBAR_HELP}], ), Action( id=CommandId.NAPARI_EXAMPLES, title=CommandId.NAPARI_EXAMPLES.title, callback=lambda: webbrowser.open(HELP_URLS['examples_gallery']), menus=[{'id': MenuId.MENUBAR_HELP}], ), Action( id=CommandId.NAPARI_RELEASE_NOTES, title=CommandId.NAPARI_RELEASE_NOTES.title, callback=lambda: webbrowser.open( HELP_URLS['release_notes'], ), menus=[ { 'id': MenuId.MENUBAR_HELP, 'when': VERSION != "dev", 'group': MenuGroup.NAVIGATION, } ], ), Action( id=CommandId.NAPARI_GITHUB_ISSUE, title=CommandId.NAPARI_GITHUB_ISSUE.title, callback=lambda: webbrowser.open( HELP_URLS['github_issue'], ), menus=[ { 'id': MenuId.MENUBAR_HELP, 'when': VERSION == "dev", 'group': MenuGroup.NAVIGATION, } ], ), Action( id=CommandId.NAPARI_HOMEPAGE, title=CommandId.NAPARI_HOMEPAGE.title, callback=lambda: webbrowser.open(HELP_URLS['homepage']), menus=[{'id': MenuId.MENUBAR_HELP, 'group': MenuGroup.NAVIGATION}], ), ] napari-0.5.0a1/napari/_app_model/actions/_layer_actions.py000066400000000000000000000127341437041365600236060ustar00rootroot00000000000000"""This module defines actions (functions) that operate on layers. Among other potential uses, these will populate the menu when you right-click on a layer in the LayerList. The Actions in LAYER_ACTIONS are registered with the application when it is created in `_app_model._app`. Modifying this list at runtime will have no effect. Use `app.register_action` to register new actions at runtime. """ from __future__ import annotations from functools import partial from typing import TYPE_CHECKING, List from app_model.types import Action from napari._app_model.constants import CommandId, MenuGroup, MenuId from napari._app_model.context import LayerListContextKeys as LLCK from napari.layers import _layer_actions if TYPE_CHECKING: from app_model.types import MenuRuleDict # The following dicts define groups to which menu items in the layer list context menu can belong # see https://app-model.readthedocs.io/en/latest/types/#app_model.types.MenuRule for details LAYERCTX_SPLITMERGE: MenuRuleDict = { 'id': MenuId.LAYERLIST_CONTEXT, 'group': MenuGroup.LAYERLIST_CONTEXT.SPLIT_MERGE, } LAYERCTX_CONVERSION: MenuRuleDict = { 'id': MenuId.LAYERLIST_CONTEXT, 'group': MenuGroup.LAYERLIST_CONTEXT.CONVERSION, } LAYERCTX_LINK: MenuRuleDict = { 'id': MenuId.LAYERLIST_CONTEXT, 'group': MenuGroup.LAYERLIST_CONTEXT.LINK, } # Statically defined Layer actions. # modifying this list at runtime has no effect. LAYER_ACTIONS: List[Action] = [ Action( id=CommandId.LAYER_DUPLICATE, title=CommandId.LAYER_DUPLICATE.title, callback=_layer_actions._duplicate_layer, menus=[LAYERCTX_SPLITMERGE], ), Action( id=CommandId.LAYER_SPLIT_STACK, title=CommandId.LAYER_SPLIT_STACK.title, callback=_layer_actions._split_stack, menus=[{**LAYERCTX_SPLITMERGE, 'when': ~LLCK.active_layer_is_rgb}], enablement=LLCK.active_layer_is_image_3d, ), Action( id=CommandId.LAYER_SPLIT_RGB, title=CommandId.LAYER_SPLIT_RGB.title, callback=_layer_actions._split_rgb, menus=[{**LAYERCTX_SPLITMERGE, 'when': LLCK.active_layer_is_rgb}], enablement=LLCK.active_layer_is_rgb, ), Action( id=CommandId.LAYER_CONVERT_TO_LABELS, title=CommandId.LAYER_CONVERT_TO_LABELS.title, callback=_layer_actions._convert_to_labels, enablement=( ( (LLCK.num_selected_image_layers >= 1) | (LLCK.num_selected_shapes_layers >= 1) ) & LLCK.all_selected_layers_same_type ), menus=[LAYERCTX_CONVERSION], ), Action( id=CommandId.LAYER_CONVERT_TO_IMAGE, title=CommandId.LAYER_CONVERT_TO_IMAGE.title, callback=_layer_actions._convert_to_image, enablement=( (LLCK.num_selected_labels_layers >= 1) & LLCK.all_selected_layers_same_type ), menus=[LAYERCTX_CONVERSION], ), Action( id=CommandId.LAYER_MERGE_STACK, title=CommandId.LAYER_MERGE_STACK.title, callback=_layer_actions._merge_stack, enablement=( (LLCK.num_selected_layers > 1) & (LLCK.num_selected_image_layers == LLCK.num_selected_layers) & LLCK.all_selected_layers_same_shape ), menus=[LAYERCTX_SPLITMERGE], ), Action( id=CommandId.LAYER_TOGGLE_VISIBILITY, title=CommandId.LAYER_TOGGLE_VISIBILITY.title, callback=_layer_actions._toggle_visibility, menus=[ { 'id': MenuId.LAYERLIST_CONTEXT, 'group': MenuGroup.NAVIGATION, } ], ), Action( id=CommandId.LAYER_LINK_SELECTED, title=CommandId.LAYER_LINK_SELECTED.title, callback=_layer_actions._link_selected_layers, enablement=( (LLCK.num_selected_layers > 1) & ~LLCK.num_selected_layers_linked ), menus=[{**LAYERCTX_LINK, 'when': ~LLCK.num_selected_layers_linked}], ), Action( id=CommandId.LAYER_UNLINK_SELECTED, title=CommandId.LAYER_UNLINK_SELECTED.title, callback=_layer_actions._unlink_selected_layers, enablement=LLCK.num_selected_layers_linked, menus=[{**LAYERCTX_LINK, 'when': LLCK.num_selected_layers_linked}], ), Action( id=CommandId.LAYER_SELECT_LINKED, title=CommandId.LAYER_SELECT_LINKED.title, callback=_layer_actions._select_linked_layers, enablement=LLCK.num_unselected_linked_layers, menus=[LAYERCTX_LINK], ), ] for _dtype in ( 'int8', 'int16', 'int32', 'int64', 'uint8', 'uint16', 'uint32', 'uint64', ): cmd: CommandId = getattr(CommandId, f'LAYER_CONVERT_TO_{_dtype.upper()}') LAYER_ACTIONS.append( Action( id=cmd, title=cmd.title, callback=partial(_layer_actions._convert_dtype, mode=_dtype), enablement=( LLCK.all_selected_layers_labels & (LLCK.active_layer_dtype != _dtype) ), menus=[{'id': MenuId.LAYERS_CONVERT_DTYPE}], ) ) for mode in ('max', 'min', 'std', 'sum', 'mean', 'median'): cmd: CommandId = getattr(CommandId, f'LAYER_PROJECT_{mode.upper()}') LAYER_ACTIONS.append( Action( id=cmd, title=cmd.title, callback=partial(_layer_actions._project, mode=mode), enablement=LLCK.active_layer_is_image_3d, menus=[{'id': MenuId.LAYERS_PROJECT}], ) ) napari-0.5.0a1/napari/_app_model/actions/_toggle_action.py000066400000000000000000000033001437041365600235550ustar00rootroot00000000000000from __future__ import annotations from typing import TYPE_CHECKING from app_model.types import Action, ToggleRule if TYPE_CHECKING: from napari.viewer import Viewer class ViewerToggleAction(Action): """Action subclass that toggles a boolean viewer (sub)attribute on trigger. Parameters ---------- id : str The command id of the action. title : str The title of the action. Prefer capital case. viewer_attribute : str The attribute of the viewer to toggle. (e.g. 'axes') sub_attribute : str The attribute of the viewer attribute to toggle. (e.g. 'visible') **kwargs Additional keyword arguments to pass to the Action constructor. Examples -------- >>> action = ViewerToggleAction( ... id='some.command.id', ... title='Toggle Axis Visibility', ... viewer_attribute='axes', ... sub_attribute='visible', ... ) """ def __init__( self, *, id: str, title: str, viewer_attribute: str, sub_attribute: str, **kwargs, ) -> None: def get_current(viewer: Viewer): """return the current value of the viewer attribute""" attr = getattr(viewer, viewer_attribute) return getattr(attr, sub_attribute) def toggle(viewer: Viewer): """toggle the viewer attribute""" attr = getattr(viewer, viewer_attribute) setattr(attr, sub_attribute, not getattr(attr, sub_attribute)) super().__init__( id=id, title=title, toggled=ToggleRule(get_current=get_current), callback=toggle, **kwargs, ) napari-0.5.0a1/napari/_app_model/actions/_view_actions.py000066400000000000000000000042141437041365600234360ustar00rootroot00000000000000"""Actions related to the 'View' menu that do not require Qt. View actions that do require Qt should go in `napari/_qt/_qapp_model/qactions/_view.py`. """ from typing import List from app_model.types import Action, ToggleRule from napari._app_model.actions._toggle_action import ViewerToggleAction from napari._app_model.constants import CommandId, MenuId from napari.settings import get_settings VIEW_ACTIONS: List[Action] = [] for cmd, viewer_attr, sub_attr in ( (CommandId.TOGGLE_VIEWER_AXES, 'axes', 'visible'), (CommandId.TOGGLE_VIEWER_AXES_COLORED, 'axes', 'colored'), (CommandId.TOGGLE_VIEWER_AXES_LABELS, 'axes', 'labels'), (CommandId.TOGGLE_VIEWER_AXES_DASHED, 'axes', 'dashed'), (CommandId.TOGGLE_VIEWER_AXES_ARROWS, 'axes', 'arrows'), (CommandId.TOGGLE_VIEWER_SCALE_BAR, 'scale_bar', 'visible'), (CommandId.TOGGLE_VIEWER_SCALE_BAR_COLORED, 'scale_bar', 'colored'), (CommandId.TOGGLE_VIEWER_SCALE_BAR_TICKS, 'scale_bar', 'ticks'), ): menu = MenuId.VIEW_AXES if viewer_attr == 'axes' else MenuId.VIEW_SCALEBAR VIEW_ACTIONS.append( ViewerToggleAction( id=cmd, title=cmd.title, viewer_attribute=viewer_attr, sub_attribute=sub_attr, menus=[{'id': menu}], ) ) def _tooltip_visibility_toggle(): settings = get_settings().appearance settings.layer_tooltip_visibility = not settings.layer_tooltip_visibility # this can be generalised for all boolean settings, similar to `ViewerToggleAction` def _get_current_tooltip_visibility(): return get_settings().appearance.layer_tooltip_visibility VIEW_ACTIONS.extend( [ # TODO: this could be made into a toggle setting Action subclass # using a similar pattern to the above ViewerToggleAction classes Action( id=CommandId.TOGGLE_LAYER_TOOLTIPS, title=CommandId.TOGGLE_LAYER_TOOLTIPS.title, menus=[ {'id': MenuId.MENUBAR_VIEW, 'group': '1_render', 'order': 10} ], callback=_tooltip_visibility_toggle, toggled=ToggleRule(get_current=_get_current_tooltip_visibility), ), ] ) napari-0.5.0a1/napari/_app_model/constants/000077500000000000000000000000001437041365600206065ustar00rootroot00000000000000napari-0.5.0a1/napari/_app_model/constants/__init__.py000066400000000000000000000002551437041365600227210ustar00rootroot00000000000000from napari._app_model.constants._commands import CommandId from napari._app_model.constants._menus import MenuGroup, MenuId __all__ = ['CommandId', 'MenuGroup', 'MenuId'] napari-0.5.0a1/napari/_app_model/constants/_commands.py000066400000000000000000000162461437041365600231310ustar00rootroot00000000000000"""All commands that are available in the napari GUI are defined here. Internally, prefer using the CommandId enum instead of the string literal. When adding a new command, add a new title/description in the _COMMAND_INFO dict below. The title will be used in the GUI, and the may be used in auto generated documentation. CommandId values should be namespaced, e.g. 'napari:layer:something' for a command that operates on layers. """ from enum import Enum from typing import NamedTuple, Optional from napari.utils.translations import trans # fmt: off class CommandId(str, Enum): """Id representing a napari command.""" # View menubar TOGGLE_FULLSCREEN = 'napari:window:view:toggle_fullscreen' TOGGLE_MENUBAR = 'napari:window:view:toggle_menubar' TOGGLE_PLAY = 'napari:window:view:toggle_play' TOGGLE_OCTREE_CHUNK_OUTLINES = 'napari:window:view:toggle_octree_chunk_outlines' TOGGLE_LAYER_TOOLTIPS = 'napari:window:view:toggle_layer_tooltips' TOGGLE_ACTIVITY_DOCK = 'napari:window:view:toggle_activity_dock' TOGGLE_VIEWER_AXES = 'napari:window:view:toggle_viewer_axes' TOGGLE_VIEWER_AXES_COLORED = 'napari:window:view:toggle_viewer_axes_colored' TOGGLE_VIEWER_AXES_LABELS = 'napari:window:view:toggle_viewer_axes_labels' TOGGLE_VIEWER_AXES_DASHED = 'napari:window:view:toggle_viewer_axesdashed' TOGGLE_VIEWER_AXES_ARROWS = 'napari:window:view:toggle_viewer_axes_arrows' TOGGLE_VIEWER_SCALE_BAR = 'napari:window:view:toggle_viewer_scale_bar' TOGGLE_VIEWER_SCALE_BAR_COLORED = 'napari:window:view:toggle_viewer_scale_bar_colored' TOGGLE_VIEWER_SCALE_BAR_TICKS = 'napari:window:view:toggle_viewer_scale_bar_ticks' # Help menubar NAPARI_GETTING_STARTED = 'napari:window:help:getting_started' NAPARI_TUTORIALS = 'napari:window:help:tutorials' NAPARI_LAYERS_GUIDE = 'napari:window:help:layers_guide' NAPARI_EXAMPLES = 'napari:window:help:examples' NAPARI_RELEASE_NOTES = 'napari:window:help:release_notes' NAPARI_HOMEPAGE = 'napari:window:help:homepage' NAPARI_INFO = 'napari:window:help:info' NAPARI_GITHUB_ISSUE = 'napari:window:help:github_issue' TOGGLE_BUG_REPORT_OPT_IN = 'napari:window:help:bug_report_opt_in' # Layer menubar LAYER_DUPLICATE = 'napari:layer:duplicate' LAYER_SPLIT_STACK = 'napari:layer:split_stack' LAYER_SPLIT_RGB = 'napari:layer:split_rgb' LAYER_MERGE_STACK = 'napari:layer:merge_stack' LAYER_TOGGLE_VISIBILITY = 'napari:layer:toggle_visibility' LAYER_LINK_SELECTED = 'napari:layer:link_selected_layers' LAYER_UNLINK_SELECTED = 'napari:layer:unlink_selected_layers' LAYER_SELECT_LINKED = 'napari:layer:select_linked_layers' LAYER_CONVERT_TO_LABELS = 'napari:layer:convert_to_labels' LAYER_CONVERT_TO_IMAGE = 'napari:layer:convert_to_image' LAYER_CONVERT_TO_INT8 = 'napari:layer:convert_to_int8' LAYER_CONVERT_TO_INT16 = 'napari:layer:convert_to_int16' LAYER_CONVERT_TO_INT32 = 'napari:layer:convert_to_int32' LAYER_CONVERT_TO_INT64 = 'napari:layer:convert_to_int64' LAYER_CONVERT_TO_UINT8 = 'napari:layer:convert_to_uint8' LAYER_CONVERT_TO_UINT16 = 'napari:layer:convert_to_uint16' LAYER_CONVERT_TO_UINT32 = 'napari:layer:convert_to_uint32' LAYER_CONVERT_TO_UINT64 = 'napari:layer:convert_to_uint64' LAYER_PROJECT_MAX = 'napari:layer:project_max' LAYER_PROJECT_MIN = 'napari:layer:project_min' LAYER_PROJECT_STD = 'napari:layer:project_std' LAYER_PROJECT_SUM = 'napari:layer:project_sum' LAYER_PROJECT_MEAN = 'napari:layer:project_mean' LAYER_PROJECT_MEDIAN = 'napari:layer:project_median' @property def title(self) -> str: return _COMMAND_INFO[self].title @property def description(self) -> Optional[str]: return _COMMAND_INFO[self].description class _i(NamedTuple): """simple utility tuple for defining items in _COMMAND_INFO.""" title: str description: Optional[str] = None _COMMAND_INFO = { # View menubar CommandId.TOGGLE_FULLSCREEN: _i(trans._('Toggle Full Screen'),), CommandId.TOGGLE_MENUBAR: _i(trans._('Toggle Menubar Visibility'),), CommandId.TOGGLE_PLAY: _i(trans._('Toggle Play'),), CommandId.TOGGLE_OCTREE_CHUNK_OUTLINES: _i(trans._('Toggle Chunk Outlines'),), CommandId.TOGGLE_LAYER_TOOLTIPS: _i(trans._('Toggle Layer Tooltips'),), CommandId.TOGGLE_ACTIVITY_DOCK: _i(trans._('Toggle Activity Dock'),), CommandId.TOGGLE_VIEWER_AXES: _i(trans._('Axes Visible')), CommandId.TOGGLE_VIEWER_AXES_COLORED: _i(trans._('Axes Colored')), CommandId.TOGGLE_VIEWER_AXES_LABELS: _i(trans._('Axes Labels')), CommandId.TOGGLE_VIEWER_AXES_DASHED: _i(trans._('Axes Dashed')), CommandId.TOGGLE_VIEWER_AXES_ARROWS: _i(trans._('Axes Arrows')), CommandId.TOGGLE_VIEWER_SCALE_BAR: _i(trans._('Scale Bar Visible')), CommandId.TOGGLE_VIEWER_SCALE_BAR_COLORED: _i(trans._('Scale Bar Colored')), CommandId.TOGGLE_VIEWER_SCALE_BAR_TICKS: _i(trans._('Scale Bar Ticks')), # Help menubar CommandId.NAPARI_GETTING_STARTED: _i(trans._('Getting started'), ), CommandId.NAPARI_TUTORIALS: _i(trans._('Tutorials'), ), CommandId.NAPARI_LAYERS_GUIDE: _i(trans._('Using Layers Guides'), ), CommandId.NAPARI_EXAMPLES: _i(trans._('Examples Gallery'), ), CommandId.NAPARI_RELEASE_NOTES: _i(trans._('Release Notes'), ), CommandId.NAPARI_HOMEPAGE: _i(trans._('napari homepage'), ), CommandId.NAPARI_INFO: _i(trans._('napari Info'), ), CommandId.NAPARI_GITHUB_ISSUE: _i(trans._('Report an issue on GitHub'), ), CommandId.TOGGLE_BUG_REPORT_OPT_IN: _i(trans._('Bug Reporting Opt In/Out...'), ), # Layer menubar CommandId.LAYER_DUPLICATE: _i(trans._('Duplicate Layer'),), CommandId.LAYER_SPLIT_STACK: _i(trans._('Split Stack'),), CommandId.LAYER_SPLIT_RGB: _i(trans._('Split RGB'),), CommandId.LAYER_MERGE_STACK: _i(trans._('Merge to Stack'),), CommandId.LAYER_TOGGLE_VISIBILITY: _i(trans._('Toggle visibility'),), CommandId.LAYER_LINK_SELECTED: _i(trans._('Link Layers'),), CommandId.LAYER_UNLINK_SELECTED: _i(trans._('Unlink Layers'),), CommandId.LAYER_SELECT_LINKED: _i(trans._('Select Linked Layers'),), CommandId.LAYER_CONVERT_TO_LABELS: _i(trans._('Convert to Labels'),), CommandId.LAYER_CONVERT_TO_IMAGE: _i(trans._('Convert to Image'),), CommandId.LAYER_CONVERT_TO_INT8: _i(trans._('Convert to int8'),), CommandId.LAYER_CONVERT_TO_INT16: _i(trans._('Convert to int16'),), CommandId.LAYER_CONVERT_TO_INT32: _i(trans._('Convert to int32'),), CommandId.LAYER_CONVERT_TO_INT64: _i(trans._('Convert to int64'),), CommandId.LAYER_CONVERT_TO_UINT8: _i(trans._('Convert to uint8'),), CommandId.LAYER_CONVERT_TO_UINT16: _i(trans._('Convert to uint16'),), CommandId.LAYER_CONVERT_TO_UINT32: _i(trans._('Convert to uint32'),), CommandId.LAYER_CONVERT_TO_UINT64: _i(trans._('Convert to uint64'),), CommandId.LAYER_PROJECT_MAX: _i(trans._('Max projection'),), CommandId.LAYER_PROJECT_MIN: _i(trans._('Min projection'),), CommandId.LAYER_PROJECT_STD: _i(trans._('Std projection'),), CommandId.LAYER_PROJECT_SUM: _i(trans._('Sum projection'),), CommandId.LAYER_PROJECT_MEAN: _i(trans._('Mean projection'),), CommandId.LAYER_PROJECT_MEDIAN: _i(trans._('Median projection'),), } # fmt: on napari-0.5.0a1/napari/_app_model/constants/_menus.py000066400000000000000000000033251437041365600224510ustar00rootroot00000000000000"""All Menus that are available anywhere in the napari GUI are defined here. These might be menubar menus, context menus, or other menus. They could even be "toolbars", such as the set of mode buttons on the layer list. A "menu" needn't just be a literal QMenu (though it usually is): it is better thought of as a set of related commands. Internally, prefer using the `MenuId` enum instead of the string literal. SOME of these (but definitely not all) will be exposed as "contributable" menus for plugins to contribute commands and submenu items to. """ from enum import Enum class MenuId(str, Enum): """Id representing a menu somewhere in napari.""" MENUBAR_VIEW = 'napari/view' VIEW_AXES = 'napari/view/axes' VIEW_SCALEBAR = 'napari/view/scalebar' MENUBAR_HELP = 'napari/help' LAYERLIST_CONTEXT = 'napari/layers/context' LAYERS_CONVERT_DTYPE = 'napari/layers/convert_dtype' LAYERS_PROJECT = 'napari/layers/project' def __str__(self) -> str: return self.value # XXX: the structure/usage pattern of this class may change in the future class MenuGroup: NAVIGATION = 'navigation' # always the first group in any menu RENDER = '1_render' class LAYERLIST_CONTEXT: CONVERSION = '1_conversion' SPLIT_MERGE = '5_split_merge' LINK = '9_link' # TODO: add these to docs, with a lookup for what each menu is/does. _CONTRIBUTABLES = {MenuId.LAYERLIST_CONTEXT.value} """Set of all menu ids that can be contributed to by plugins.""" def is_menu_contributable(menu_id: str) -> bool: """Return True if the given menu_id is a menu that plugins can contribute to.""" return ( menu_id in _CONTRIBUTABLES if menu_id.startswith("napari/") else True ) napari-0.5.0a1/napari/_app_model/context/000077500000000000000000000000001437041365600202565ustar00rootroot00000000000000napari-0.5.0a1/napari/_app_model/context/__init__.py000066400000000000000000000004261437041365600223710ustar00rootroot00000000000000from napari._app_model.context._context import ( Context, create_context, get_context, ) from napari._app_model.context._layerlist_context import LayerListContextKeys __all__ = [ 'Context', 'create_context', 'get_context', 'LayerListContextKeys', ] napari-0.5.0a1/napari/_app_model/context/_context.py000066400000000000000000000050111437041365600224500ustar00rootroot00000000000000from __future__ import annotations from typing import TYPE_CHECKING, Any, Final, Optional from app_model.expressions import Context, get_context from app_model.expressions import create_context as _create_context from napari.utils.translations import trans if TYPE_CHECKING: from napari.utils.events import Event __all__ = ["create_context", "get_context", "Context", "SettingsAwareContext"] class SettingsAwareContext(Context): """A special context that allows access of settings using `settings.` This takes no parents, and will always be a root context. """ _PREFIX: Final[str] = 'settings.' def __init__(self) -> None: super().__init__() from napari.settings import get_settings self._settings = get_settings() self._settings.events.changed.connect(self._update_key) def _update_key(self, event: Event): self.changed.emit({f'{self._PREFIX}{event.key}'}) def __del__(self): self._settings.events.changed.disconnect(self._update_key) def __missing__(self, key: str) -> Any: if key.startswith(self._PREFIX): splits = [k for k in key.split(".")[1:] if k] val: Any = self._settings if splits: while splits: val = getattr(val, splits.pop(0)) if hasattr(val, 'dict'): val = val.dict() return val return super().__missing__(key) def new_child(self, m: Optional[dict] = None) -> Context: # type: ignore """New ChainMap with a new map followed by all previous maps. If no map is provided, an empty dict is used. """ # important to use self, not *self.maps return Context(m or {}, self) # type: ignore def __setitem__(self, k: str, v: Any) -> None: if k.startswith(self._PREFIX): raise ValueError( trans._( "Cannot set key starting with {prefix!r}", deferred=True, prefix=self._PREFIX, ) ) return super().__setitem__(k, v) def __bool__(self): # settings mappings are always populated, so we can always return True return True def create_context( obj: object, max_depth: int = 20, start: int = 2, root: Optional[Context] = None, ) -> Optional[Context]: return _create_context( obj=obj, max_depth=max_depth, start=start, root=root, root_class=SettingsAwareContext, ) napari-0.5.0a1/napari/_app_model/context/_context_keys.py000066400000000000000000000011411437041365600235030ustar00rootroot00000000000000from typing import TYPE_CHECKING, Generic, TypeVar from app_model.expressions import ContextNamespace as _ContextNamespace if TYPE_CHECKING: from napari.utils.events import Event A = TypeVar("A") class ContextNamespace(_ContextNamespace, Generic[A]): """A collection of related keys in a context meant to be subclassed, with class attributes that are `ContextKeys`. """ def update(self, event: 'Event') -> None: """Trigger an update of all "getter" functions in this namespace.""" for k, get in self._getters.items(): setattr(self, k, get(event.source)) napari-0.5.0a1/napari/_app_model/context/_layerlist_context.py000066400000000000000000000153131437041365600245460ustar00rootroot00000000000000from __future__ import annotations import contextlib from typing import TYPE_CHECKING, Optional, Tuple from app_model.expressions import ContextKey from napari._app_model.context._context_keys import ContextNamespace from napari.utils._dtype import normalize_dtype from napari.utils.translations import trans if TYPE_CHECKING: from numpy.typing import DTypeLike from napari.layers import Layer from napari.utils.events import Selection LayerSel = Selection[Layer] def _len(s: LayerSel) -> int: return len(s) def _all_linked(s: LayerSel) -> bool: from napari.layers.utils._link_layers import layer_is_linked return bool(s and all(layer_is_linked(x) for x in s)) def _n_unselected_links(s: LayerSel) -> int: from napari.layers.utils._link_layers import get_linked_layers return len(get_linked_layers(*s) - s) def _is_rgb(s: LayerSel) -> bool: return getattr(s.active, "rgb", False) def _only_img(s: LayerSel) -> bool: return bool(s and all(x._type_string == "image" for x in s)) def _n_selected_imgs(s: LayerSel) -> int: return sum(x._type_string == "image" for x in s) def _only_labels(s: LayerSel) -> bool: return bool(s and all(x._type_string == "labels" for x in s)) def _n_selected_labels(s: LayerSel) -> int: return sum(x._type_string == "labels" for x in s) def _only_points(s: LayerSel) -> bool: return bool(s and all(x._type_string == "points" for x in s)) def _n_selected_points(s: LayerSel) -> int: return sum(x._type_string == "points" for x in s) def _only_shapes(s: LayerSel) -> bool: return bool(s and all(x._type_string == "shapes" for x in s)) def _n_selected_shapes(s: LayerSel) -> int: return sum(x._type_string == "shapes" for x in s) def _only_surface(s: LayerSel) -> bool: return bool(s and all(x._type_string == "surface" for x in s)) def _n_selected_surfaces(s: LayerSel) -> int: return sum(x._type_string == "surface" for x in s) def _only_vectors(s: LayerSel) -> bool: return bool(s and all(x._type_string == "vectors" for x in s)) def _n_selected_vectors(s: LayerSel) -> int: return sum(x._type_string == "vectors" for x in s) def _only_tracks(s: LayerSel) -> bool: return bool(s and all(x._type_string == "tracks" for x in s)) def _n_selected_tracks(s: LayerSel) -> int: return sum(x._type_string == "tracks" for x in s) def _active_type(s: LayerSel) -> Optional[str]: return s.active._type_string if s.active else None def _active_ndim(s: LayerSel) -> Optional[int]: return getattr(s.active.data, "ndim", None) if s.active else None def _active_shape(s: LayerSel) -> Optional[Tuple[int, ...]]: return getattr(s.active.data, "shape", None) if s.active else None def _same_shape(s: LayerSel) -> bool: return len({getattr(x.data, "shape", ()) for x in s}) == 1 def _active_dtype(s: LayerSel) -> DTypeLike: dtype = None if s.active: with contextlib.suppress(AttributeError): dtype = normalize_dtype(s.active.data.dtype).__name__ return dtype def _same_type(s: LayerSel) -> bool: return len({x._type_string for x in s}) == 1 def _active_is_image_3d(s: LayerSel) -> bool: return ( _active_type(s) == "image" and _active_ndim(s) is not None and (_active_ndim(s) > 3 or (_active_ndim(s) > 2 and not _is_rgb(s))) ) class LayerListContextKeys(ContextNamespace['LayerSel']): """These are the available context keys relating to a LayerList. along with default value, a description, and a function to retrieve the current value from layers.selection """ num_selected_layers = ContextKey( 0, trans._("Number of currently selected layers."), _len, ) num_selected_layers_linked = ContextKey( False, trans._("True when all selected layers are linked."), _all_linked, ) num_unselected_linked_layers = ContextKey( 0, trans._("Number of unselected layers linked to selected layer(s)."), _n_unselected_links, ) active_layer_is_rgb = ContextKey( False, trans._("True when the active layer is RGB."), _is_rgb, ) active_layer_type = ContextKey['LayerSel', Optional[str]]( None, trans._( "Lowercase name of active layer type, or None of none active." ), _active_type, ) # TODO: try to reduce these `num_selected_x_layers` to a single set of strings # or something... however, this would require that our context expressions # support Sets, tuples, lists, etc... which they currently do not. num_selected_image_layers = ContextKey( 0, trans._("Number of selected image layers."), _n_selected_imgs, ) num_selected_labels_layers = ContextKey( 0, trans._("Number of selected labels layers."), _n_selected_labels, ) num_selected_points_layers = ContextKey( 0, trans._("Number of selected points layers."), _n_selected_points, ) num_selected_shapes_layers = ContextKey( 0, trans._("Number of selected shapes layers."), _n_selected_shapes, ) num_selected_surface_layers = ContextKey( 0, trans._("Number of selected surface layers."), _n_selected_surfaces, ) num_selected_vectors_layers = ContextKey( 0, trans._("Number of selected vectors layers."), _n_selected_vectors, ) num_selected_tracks_layers = ContextKey( 0, trans._("Number of selected tracks layers."), _n_selected_tracks, ) active_layer_ndim = ContextKey['LayerSel', Optional[int]]( None, trans._( "Number of dimensions in the active layer, or `None` if nothing is active." ), _active_ndim, ) active_layer_shape = ContextKey['LayerSel', Optional[Tuple[int, ...]]]( (), trans._("Shape of the active layer, or `None` if nothing is active."), _active_shape, ) active_layer_is_image_3d = ContextKey( False, trans._("True when the active layer is a 3D image."), _active_is_image_3d, ) active_layer_dtype = ContextKey( None, trans._("Dtype of the active layer, or `None` if nothing is active."), _active_dtype, ) all_selected_layers_same_shape = ContextKey( False, trans._("True when all selected layers have the same shape."), _same_shape, ) all_selected_layers_same_type = ContextKey( False, trans._("True when all selected layers are of the same type."), _same_type, ) all_selected_layers_labels = ContextKey( False, trans._("True when all selected layers are labels."), _only_labels, ) napari-0.5.0a1/napari/_app_model/injection/000077500000000000000000000000001437041365600205545ustar00rootroot00000000000000napari-0.5.0a1/napari/_app_model/injection/__init__.py000066400000000000000000000000001437041365600226530ustar00rootroot00000000000000napari-0.5.0a1/napari/_app_model/injection/_processors.py000066400000000000000000000131351437041365600234720ustar00rootroot00000000000000import sys from concurrent.futures import Future from contextlib import nullcontext, suppress from functools import partial from typing import Any, Callable, Dict, List, Optional, Set, Union from napari import layers, types, viewer from napari._app_model.injection._providers import _provide_viewer from napari.layers._source import layer_source def _add_layer_data_tuples_to_viewer( data: Union[types.LayerDataTuple, List[types.LayerDataTuple]], return_type=None, viewer=None, source: Optional[dict] = None, ): from napari.utils.misc import ensure_list_of_layer_data_tuple if viewer is None: viewer = _provide_viewer() if viewer and data is not None: data = data if isinstance(data, list) else [data] for datum in ensure_list_of_layer_data_tuple(data): # then try to update a viewer layer with the same name. if len(datum) > 1 and (name := datum[1].get("name")): with suppress(KeyError): layer = viewer.layers[name] layer.data = datum[0] for k, v in datum[1].items(): setattr(layer, k, v) continue with layer_source(**source) if source else nullcontext(): # otherwise create a new layer from the layer data viewer._add_layer_from_data(*datum) def _add_layer_data_to_viewer( data: Any, return_type: Any, viewer: Optional[viewer.Viewer] = None, layer_name: Optional[str] = None, source: Optional[dict] = None, ): """Show a result in the viewer. Parameters ---------- data : Any The result of the function call. For this function, this should be *just* the data part of the corresponding layer type. return_type : Any The return annotation that was used in the decorated function. viewer : Optional[Viewer] An optional viewer to use. Otherwise use current viewer. layer_name : Optional[str] An optional layer name to use. If a layer with this name exists, it will be updated. source : Optional[dict] An optional layer source to use. Examples -------- This allows the user to do this, and add the result as a viewer Image. >>> def make_layer() -> napari.types.ImageData: ... return np.random.rand(256, 256) """ if data is not None and (viewer := viewer or _provide_viewer()): if layer_name: with suppress(KeyError): viewer.layers[layer_name].data = data return layer_type = return_type.__name__.replace("Data", "").lower() with layer_source(**source) if source else nullcontext(): getattr(viewer, f'add_{layer_type}')(data=data, name=layer_name) def _add_layer_to_viewer( layer: layers.Layer, viewer: Optional[viewer.Viewer] = None, source: Optional[dict] = None, ): if layer is not None and (viewer := viewer or _provide_viewer()): layer._source = layer.source.copy(update=source or {}) viewer.add_layer(layer) # here to prevent garbace collection of the future object while processing. _FUTURES: Set[Future] = set() def _add_future_data( future: Future, return_type: Any, _from_tuple=True, viewer: Optional[viewer.Viewer] = None, source: dict = None, ): """Process a Future object. This function will be called to process function that has a return annotation of one of the `napari.types.Data` ... and will add the data in `result` to the current viewer as the corresponding layer type. Parameters ---------- future : Future An instance of `concurrent.futures.Future` (or any third-party) object with the same interface, that provides `add_done_callback` and `result` methods. When the future is `done()`, the `result()` will be added to the viewer. return_type : type The return annotation that was used in the decorated function. _from_tuple : bool, optional (only for internal use). True if the future returns `LayerDataTuple`, False if it returns one of the `LayerData` types. """ # when the future is done, add layer data to viewer, dispatching # to the appropriate method based on the Future data type. adder = ( _add_layer_data_tuples_to_viewer if _from_tuple else _add_layer_data_to_viewer ) def _on_future_ready(f: Future): adder( f.result(), return_type=return_type, viewer=viewer, source=source, ) _FUTURES.discard(future) # We need the callback to happen in the main thread... # This still works (no-op) in a headless environment, but # we could be even more granular with it, with a function # that checks if we're actually in a QApp before wrapping. # with suppress(ImportError): # from superqt.utils import ensure_main_thread # _on_future_ready = ensure_main_thread(_on_future_ready) future.add_done_callback(_on_future_ready) _FUTURES.add(future) # Add future and LayerData processors for each layer type. PROCESSORS: Dict[object, Callable] = { types.LayerDataTuple: _add_layer_data_tuples_to_viewer, List[types.LayerDataTuple]: _add_layer_data_tuples_to_viewer, layers.Layer: _add_layer_to_viewer, } for t in types._LayerData.__args__: # type: ignore [attr-defined] PROCESSORS[t] = partial(_add_layer_data_to_viewer, return_type=t) if sys.version_info >= (3, 9): PROCESSORS[Future[t]] = partial( _add_future_data, return_type=t, _from_tuple=False ) napari-0.5.0a1/napari/_app_model/injection/_providers.py000066400000000000000000000011471437041365600233050ustar00rootroot00000000000000from typing import Optional from napari import components, layers, viewer def _provide_viewer() -> Optional[viewer.Viewer]: return viewer.current_viewer() def _provide_active_layer() -> Optional[layers.Layer]: return v.layers.selection.active if (v := _provide_viewer()) else None def _provide_active_layer_list() -> Optional[components.LayerList]: return v.layers if (v := _provide_viewer()) else None # syntax could be simplified after # https://github.com/tlambert03/in-n-out/issues/31 PROVIDERS = [ (_provide_viewer,), (_provide_active_layer,), (_provide_active_layer_list,), ] napari-0.5.0a1/napari/_event_loop.py000066400000000000000000000004041437041365600173540ustar00rootroot00000000000000try: from napari._qt.qt_event_loop import gui_qt, run # qtpy raises a RuntimeError if no Qt bindings can be found except (ImportError, RuntimeError) as e: exc = e def gui_qt(**kwargs): raise exc def run(**kwargs): raise exc napari-0.5.0a1/napari/_lazy.py000066400000000000000000000043341437041365600161670ustar00rootroot00000000000000from importlib import import_module def install_lazy(module_name, submodules=None, submod_attrs=None): """Install lazily loaded submodules, and functions or other attributes. Parameters ---------- module_name : str Typically use __name__. submodules : set List of submodules to install. submod_attrs : dict Dictionary of submodule -> list of attributes / functions. These attributes are imported as they are used. Returns ------- __getattr__, __dir__, __all__ """ if submod_attrs is None: submod_attrs = {} if submodules is None: submodules = set() else: submodules = set(submodules) attr_to_modules = { attr: mod for mod, attrs in submod_attrs.items() for attr in attrs } __all__ = list(submodules | attr_to_modules.keys()) def __getattr__(name): # this unused import is here to fix a very strange bug. # there is some mysterious magical goodness in scipy stats that needs # to be imported early. # see: https://github.com/napari/napari/issues/925 # see: https://github.com/napari/napari/issues/1347 from scipy import stats # noqa: F401 if name in submodules: return import_module(f'{module_name}.{name}') elif name in attr_to_modules: try: submod = import_module( f'{module_name}.{attr_to_modules[name]}' ) except AttributeError as er: # if we want any useful error message to show # (besides just "cannot import name...") then we need raise anything # BUT an attribute error here, because the __getattr__ protocol will # swallow that error. raise ImportError( f'Failed to import {attr_to_modules[name]} from {module_name}. ' 'See cause above' ) from er # this is where we allow an attribute error to be raised. return getattr(submod, name) else: raise AttributeError(f'No {module_name} attribute {name}') def __dir__(): return __all__ return __getattr__, __dir__, __all__ napari-0.5.0a1/napari/_qt/000077500000000000000000000000001437041365600152565ustar00rootroot00000000000000napari-0.5.0a1/napari/_qt/__init__.py000066400000000000000000000054021437041365600173700ustar00rootroot00000000000000import os import sys from pathlib import Path from warnings import warn from napari.utils.translations import trans try: from qtpy import API_NAME, QT_VERSION, QtCore except Exception as e: if 'No Qt bindings could be found' in str(e): raise ImportError( trans._( "No Qt bindings could be found.\n\nnapari requires either PyQt5 or PySide2 to be installed in the environment.\nTo install the default backend (currently PyQt5), run \"pip install napari[all]\" \nYou may also use \"pip install napari[pyside2]\"for Pyside2, or \"pip install napari[pyqt5]\" for PyQt5", deferred=True, ) ) from e raise if API_NAME == 'PySide2': # Set plugin path appropriately if using PySide2. This is a bug fix # for when both PyQt5 and Pyside2 are installed import PySide2 os.environ['QT_PLUGIN_PATH'] = str( Path(PySide2.__file__).parent / 'Qt' / 'plugins' ) if API_NAME == 'PySide6' and sys.version_info[:2] < (3, 10): from packaging import version if version.parse(QT_VERSION) > version.parse("6.3.1"): raise RuntimeError( trans._( "Napari is not expected to work with PySide6 >= 6.3.2 on Python < 3.10", deferred=True, ) ) # When QT is not the specific version, we raise a warning: if tuple(int(x) for x in QtCore.__version__.split('.')[:3]) < (5, 12, 3): import importlib.metadata try: dist_info_version = importlib.metadata.version(API_NAME) if dist_info_version != QtCore.__version__: warn_message = trans._( "\n\nIMPORTANT:\nYou are using QT version {version}, but version {dversion} was also found in your environment.\nThis usually happens when you 'conda install' something that also depends on PyQt\n*after* you have pip installed napari (such as jupyter notebook).\nYou will likely run into problems and should create a fresh environment.\nIf you want to install conda packages into the same environment as napari,\nplease add conda-forge to your channels: https://conda-forge.org\n", deferred=True, version=QtCore.__version__, dversion=dist_info_version, ) except ModuleNotFoundError: warn_message = trans._( "\n\nnapari was tested with QT library `>=5.12.3`.\nThe version installed is {version}. Please report any issues with\nthis specific QT version at https://github.com/Napari/napari/issues.", deferred=True, version=QtCore.__version__, ) warn(message=warn_message) from napari._qt.qt_event_loop import get_app, gui_qt, quit_app, run from napari._qt.qt_main_window import Window __all__ = ["get_app", "gui_qt", "quit_app", "run", "Window"] napari-0.5.0a1/napari/_qt/_qapp_model/000077500000000000000000000000001437041365600175365ustar00rootroot00000000000000napari-0.5.0a1/napari/_qt/_qapp_model/__init__.py000066400000000000000000000002421437041365600216450ustar00rootroot00000000000000"""Helper functions to create Qt objects from app-model objects.""" from napari._qt._qapp_model._menus import build_qmodel_menu __all__ = ['build_qmodel_menu'] napari-0.5.0a1/napari/_qt/_qapp_model/_menus.py000066400000000000000000000014601437041365600213770ustar00rootroot00000000000000from typing import TYPE_CHECKING, Optional from app_model.backends.qt import QModelMenu if TYPE_CHECKING: from qtpy.QtWidgets import QWidget def build_qmodel_menu( menu_id: str, title: Optional[str] = None, parent: Optional['QWidget'] = None, ) -> QModelMenu: """Build a QModelMenu from the napari app model Parameters ---------- menu_id : str ID of a menu registered with napari._app_model.get_app().menus title : Optional[str] Title of the menu parent : Optional[QWidget] Parent of the menu Returns ------- QModelMenu QMenu subclass populated with all items in `menu_id` menu. """ from napari._app_model import get_app return QModelMenu( menu_id=menu_id, app=get_app(), title=title, parent=parent ) napari-0.5.0a1/napari/_qt/_qapp_model/_tests/000077500000000000000000000000001437041365600210375ustar00rootroot00000000000000napari-0.5.0a1/napari/_qt/_qapp_model/_tests/test_qapp_model_menus.py000066400000000000000000000015551437041365600260060ustar00rootroot00000000000000from unittest.mock import MagicMock import pytest from napari import viewer from napari._app_model import constants, get_app from napari._qt._qapp_model import build_qmodel_menu from napari._qt._qapp_model.qactions import init_qactions from napari._qt.qt_main_window import Window @pytest.mark.parametrize('menu_id', list(constants.MenuId)) def test_build_qmodel_menu(qtbot, menu_id): """Test that we can build qmenus for all registered menu IDs""" app = get_app() mock = MagicMock() with app.injection_store.register( providers={viewer.Viewer: lambda: mock, Window: lambda: mock} ): init_qactions.cache_clear() init_qactions() menu = build_qmodel_menu(menu_id) qtbot.addWidget(menu) # `>=` because separator bars count as actions assert len(menu.actions()) >= len(app.menus.get_menu(menu_id)) napari-0.5.0a1/napari/_qt/_qapp_model/qactions/000077500000000000000000000000001437041365600213575ustar00rootroot00000000000000napari-0.5.0a1/napari/_qt/_qapp_model/qactions/__init__.py000066400000000000000000000036431437041365600234760ustar00rootroot00000000000000from functools import lru_cache from itertools import chain from typing import Optional # Submodules should be able to import from most modules, so to # avoid circular imports, don't import submodules at the top level here, # import them inside the init_qactions function. @lru_cache # only call once def init_qactions() -> None: """Initialize all Qt-based Actions with app-model This function will be called in _QtMainWindow.__init__(). It should only be called once (hence the lru_cache decorator). It is responsible for: - injecting Qt-specific names into the application injection_store namespace (this is what allows functions to be declared with annotations like `def foo(window: Window)` or `def foo(qt_viewer: QtViewer)`) - registering provider functions for the names added to the namespace - registering Qt-dependent actions with app-model (i.e. Q_*_ACTIONS actions). """ from napari._app_model import get_app from napari._qt._qapp_model.qactions._help import Q_HELP_ACTIONS from napari._qt._qapp_model.qactions._view import Q_VIEW_ACTIONS from napari._qt.qt_main_window import Window, _QtMainWindow from napari._qt.qt_viewer import QtViewer # update the namespace with the Qt-specific types/providers/processors app = get_app() store = app.injection_store store.namespace = { **store.namespace, 'Window': Window, 'QtViewer': QtViewer, } # Qt-specific providers/processors @store.register_provider def _provide_window() -> Optional[Window]: if _qmainwin := _QtMainWindow.current(): return _qmainwin._window @store.register_provider def _provide_qt_viewer() -> Optional[QtViewer]: if _qmainwin := _QtMainWindow.current(): return _qmainwin._qt_viewer # register actions for action in chain(Q_VIEW_ACTIONS, Q_HELP_ACTIONS): app.register_action(action) napari-0.5.0a1/napari/_qt/_qapp_model/qactions/_help.py000066400000000000000000000024441437041365600230240ustar00rootroot00000000000000"""Actions related to the 'Help' menu that require Qt. 'Help' actions that do not require Qt should go in a new '_help_actions.py' file within `napari/_app_model/actions/`. """ from typing import List from app_model.types import Action, KeyBindingRule, KeyCode, KeyMod from napari._app_model.constants import CommandId, MenuGroup, MenuId from napari._qt.dialogs.qt_about import QtAbout from napari._qt.qt_main_window import Window from napari.utils.translations import trans try: from napari_error_reporter import ask_opt_in except ModuleNotFoundError: ask_opt_in = None def _show_about(window: Window): QtAbout.showAbout(window._qt_window) Q_HELP_ACTIONS: List[Action] = [ Action( id=CommandId.NAPARI_INFO, title=CommandId.NAPARI_INFO.title, callback=_show_about, menus=[{"id": MenuId.MENUBAR_HELP, 'group': MenuGroup.RENDER}], status_tip=trans._('About napari'), keybindings=[KeyBindingRule(primary=KeyMod.CtrlCmd | KeyCode.Slash)], ) ] if ask_opt_in is not None: Q_HELP_ACTIONS.append( Action( id=CommandId.TOGGLE_BUG_REPORT_OPT_IN, title=CommandId.TOGGLE_BUG_REPORT_OPT_IN.title, callback=lambda: ask_opt_in(force=True), menus=[{"id": MenuId.MENUBAR_HELP}], ) ) napari-0.5.0a1/napari/_qt/_qapp_model/qactions/_view.py000066400000000000000000000061321437041365600230440ustar00rootroot00000000000000"""Actions related to the 'View' menu that require Qt. 'View' actions that do not require Qt should go in `napari/_app_model/actions/_view_actions.py`. """ import sys from typing import List from app_model.types import ( Action, KeyCode, KeyMod, StandardKeyBinding, ToggleRule, ) from napari._app_model.constants import CommandId, MenuGroup, MenuId from napari._qt.qt_main_window import Window from napari._qt.qt_viewer import QtViewer from napari.settings import get_settings from napari.utils.translations import trans def _toggle_activity_dock(window: Window): window._status_bar._toggle_activity_dock() def _get_current_activity_dock(window: Window): return window._qt_window._activity_dialog.isVisible() Q_VIEW_ACTIONS: List[Action] = [ Action( id=CommandId.TOGGLE_FULLSCREEN, title=CommandId.TOGGLE_FULLSCREEN.title, menus=[ { 'id': MenuId.MENUBAR_VIEW, 'group': MenuGroup.NAVIGATION, 'order': 1, } ], callback=Window._toggle_fullscreen, keybindings=[StandardKeyBinding.FullScreen], ), Action( id=CommandId.TOGGLE_MENUBAR, title=CommandId.TOGGLE_MENUBAR.title, menus=[ { 'id': MenuId.MENUBAR_VIEW, 'group': MenuGroup.NAVIGATION, 'order': 2, 'when': sys.platform != 'darwin', } ], callback=Window._toggle_menubar_visible, keybindings=[ { 'win': KeyMod.CtrlCmd | KeyCode.KeyM, 'linux': KeyMod.CtrlCmd | KeyCode.KeyM, } ], # TODO: add is_mac global context keys (rather than boolean here) enablement=sys.platform != 'darwin', status_tip=trans._('Show/Hide Menubar'), ), Action( id=CommandId.TOGGLE_PLAY, title=CommandId.TOGGLE_PLAY.title, menus=[ { 'id': MenuId.MENUBAR_VIEW, 'group': MenuGroup.NAVIGATION, 'order': 3, } ], callback=Window._toggle_play, keybindings=[{'primary': KeyMod.CtrlCmd | KeyMod.Alt | KeyCode.KeyP}], ), Action( id=CommandId.TOGGLE_OCTREE_CHUNK_OUTLINES, title=CommandId.TOGGLE_OCTREE_CHUNK_OUTLINES.title, menus=[ { 'id': MenuId.MENUBAR_VIEW, 'group': MenuGroup.RENDER, 'order': 1, 'when': get_settings().experimental.octree, } ], callback=QtViewer._toggle_chunk_outlines, enablement=get_settings().experimental.octree, # this used to have a keybinding of Ctrl+Alt+O, but that conflicts with # Open files as stack ), Action( id=CommandId.TOGGLE_ACTIVITY_DOCK, title=CommandId.TOGGLE_ACTIVITY_DOCK.title, menus=[ {'id': MenuId.MENUBAR_VIEW, 'group': MenuGroup.RENDER, 'order': 11} ], callback=_toggle_activity_dock, toggled=ToggleRule(get_current=_get_current_activity_dock), ), ] napari-0.5.0a1/napari/_qt/_tests/000077500000000000000000000000001437041365600165575ustar00rootroot00000000000000napari-0.5.0a1/napari/_qt/_tests/__init__.py000066400000000000000000000000001437041365600206560ustar00rootroot00000000000000napari-0.5.0a1/napari/_qt/_tests/test_app.py000066400000000000000000000043321437041365600207520ustar00rootroot00000000000000import os from collections import defaultdict from unittest.mock import Mock import pytest from qtpy.QtWidgets import QAction, QShortcut from napari import Viewer from napari._qt.qt_event_loop import _ipython_has_eventloop, run, set_app_id @pytest.mark.skipif(os.name != "Windows", reason="Windows specific") def test_windows_grouping_overwrite(qapp): import ctypes def get_app_id(): mem = ctypes.POINTER(ctypes.c_wchar)() ctypes.windll.shell32.GetCurrentProcessExplicitAppUserModelID( ctypes.byref(mem) ) res = ctypes.wstring_at(mem) ctypes.windll.Ole32.CoTaskMemFree(mem) return res ctypes.windll.shell32.SetCurrentProcessExplicitAppUserModelID("test_text") assert get_app_id() == "test_text" set_app_id("custom_string") assert get_app_id() == "custom_string" set_app_id("") assert get_app_id() == "" def test_run_outside_ipython(qapp, monkeypatch): """Test that we don't incorrectly give ipython the event loop.""" assert not _ipython_has_eventloop() v1 = Viewer(show=False) assert not _ipython_has_eventloop() v2 = Viewer(show=False) assert not _ipython_has_eventloop() with monkeypatch.context() as m: mock_exec = Mock() m.setattr(qapp, 'exec_', mock_exec) run() mock_exec.assert_called_once() v1.close() v2.close() def test_shortcut_collision(make_napari_viewer): viewer = make_napari_viewer() defined_shortcuts = defaultdict(list) problematic_shortcuts = [] shortcuts = viewer.window._qt_window.findChildren(QShortcut) for shortcut in shortcuts: key = shortcut.key().toString() if key == "Ctrl+M": # menubar toggle support # https://github.com/napari/napari/pull/3204 continue if key and key in defined_shortcuts: problematic_shortcuts.append(key) defined_shortcuts[key].append(key) actions = viewer.window._qt_window.findChildren(QAction) for action in actions: key = action.shortcut().toString() if key and key in defined_shortcuts: problematic_shortcuts.append(key) defined_shortcuts[key].append(key) assert not problematic_shortcuts napari-0.5.0a1/napari/_qt/_tests/test_plugin_widgets.py000066400000000000000000000152201437041365600232140ustar00rootroot00000000000000from itertools import dropwhile from unittest.mock import Mock, patch import pytest from napari_plugin_engine import napari_hook_implementation from qtpy.QtWidgets import QWidget import napari from napari import Viewer from napari._qt.menus import PluginsMenu from napari._qt.qt_main_window import _instantiate_dock_widget from napari.utils._proxies import PublicOnlyProxy class Widg1(QWidget): pass class Widg2(QWidget): def __init__(self, napari_viewer) -> None: self.viewer = napari_viewer super().__init__() class Widg3(QWidget): def __init__(self, v: Viewer) -> None: self.viewer = v super().__init__() def fail(self): """private attr not allowed""" self.viewer.window._qt_window def magicfunc(viewer: 'napari.Viewer'): return viewer dwidget_args = { 'single_class': Widg1, 'class_tuple': (Widg1, {'area': 'right'}), 'tuple_list': [(Widg1, {'area': 'right'}), (Widg2, {})], 'tuple_list2': [(Widg1, {'area': 'right'}), Widg2], 'bad_class': 1, 'bad_tuple1': (Widg1, 1), 'bad_double_tuple': ((Widg1, {}), (Widg2, {})), } # napari_plugin_manager from _testsupport.py # monkeypatch, request, recwarn fixtures are from pytest @pytest.mark.parametrize('arg', dwidget_args.values(), ids=dwidget_args.keys()) def test_dock_widget_registration( arg, napari_plugin_manager, request, recwarn ): """Test that dock widgets get validated and registerd correctly.""" class Plugin: @napari_hook_implementation def napari_experimental_provide_dock_widget(): return arg napari_plugin_manager.register(Plugin, name='Plugin') napari_plugin_manager.discover_widgets() widgets = napari_plugin_manager._dock_widgets if '[bad_' in request.node.name: assert len(recwarn) == 1 assert not widgets else: assert len(recwarn) == 0 assert widgets['Plugin']['Widg1'][0] == Widg1 if 'tuple_list' in request.node.name: assert widgets['Plugin']['Widg2'][0] == Widg2 @pytest.fixture def test_plugin_widgets(monkeypatch, napari_plugin_manager): """A smattering of example registered dock widgets and function widgets.""" tnpm = napari_plugin_manager dock_widgets = { "TestP1": {"Widg1": (Widg1, {}), "Widg2": (Widg2, {})}, "TestP2": {"Widg3": (Widg3, {})}, } monkeypatch.setattr(tnpm, "_dock_widgets", dock_widgets) function_widgets = {'TestP3': {'magic': magicfunc}} monkeypatch.setattr(tnpm, "_function_widgets", function_widgets) yield def test_plugin_widgets_menus(test_plugin_widgets, qtbot): """Test the plugin widgets get added to the window menu correctly.""" # only take the plugin actions window = Mock() qtwin = QWidget() qtbot.addWidget(qtwin) with patch.object(window, '_qt_window', qtwin): actions = PluginsMenu(window=window).actions() actions = list(dropwhile(lambda a: a.text() != '', actions)) texts = [a.text() for a in actions][1:] for t in ['TestP1', 'Widg3 (TestP2)', 'magic (TestP3)']: assert t in texts # Expect a submenu ("Test plugin1") with particular entries. tp1 = next(m for m in actions if m.text() == 'TestP1') assert tp1.parent() assert [a.text() for a in tp1.parent().actions()] == ['Widg1', 'Widg2'] def test_making_plugin_dock_widgets(test_plugin_widgets, make_napari_viewer): """Test that we can create dock widgets, and they get the viewer.""" viewer = make_napari_viewer() # only take the plugin actions actions = viewer.window.plugins_menu.actions() actions = list(dropwhile(lambda a: a.text() != '', actions)) # trigger the 'TestP2: Widg3' action tp2 = next(m for m in actions if m.text().endswith('(TestP2)')) tp2.trigger() # make sure that a dock widget was created assert 'Widg3 (TestP2)' in viewer.window._dock_widgets dw = viewer.window._dock_widgets['Widg3 (TestP2)'] assert isinstance(dw.widget(), Widg3) # This widget uses the parameter annotation method to receive a viewer assert isinstance(dw.widget().viewer, napari.Viewer) # Add twice is ok, only does a show tp2.trigger() # trigger the 'TestP1 > Widg2' action (it's in a submenu) tp2 = next(m for m in actions if m.text().endswith('TestP1')) action = tp2.parent().actions()[1] assert action.text() == 'Widg2' action.trigger() # make sure that a dock widget was created assert 'Widg2 (TestP1)' in viewer.window._dock_widgets dw = viewer.window._dock_widgets['Widg2 (TestP1)'] assert isinstance(dw.widget(), Widg2) # This widget uses parameter *name* "napari_viewer" to get a viewer assert isinstance(dw.widget().viewer, napari.Viewer) # Add twice is ok, only does a show action.trigger() # Check that widget is still there when closed. widg = dw.widget() dw.title.hide_button.click() assert widg # Check that widget is destroyed when closed. dw.destroyOnClose() assert action not in viewer.window.plugins_menu.actions() assert not widg.parent() def test_making_function_dock_widgets(test_plugin_widgets, make_napari_viewer): """Test that we can create magicgui widgets, and they get the viewer.""" import magicgui viewer = make_napari_viewer() # only take the plugin actions actions = viewer.window.plugins_menu.actions() actions = dropwhile(lambda a: a.text() != '', actions) # trigger the 'TestP3: magic' action tp3 = next(m for m in actions if m.text().endswith('(TestP3)')) tp3.trigger() # make sure that a dock widget was created assert 'magic (TestP3)' in viewer.window._dock_widgets dw = viewer.window._dock_widgets['magic (TestP3)'] # make sure that it contains a magicgui widget magic_widget = dw.widget()._magic_widget FGui = getattr(magicgui.widgets, 'FunctionGui', None) if FGui is None: # pre magicgui 0.2.6 FGui = magicgui.FunctionGui assert isinstance(magic_widget, FGui) # This magicgui widget uses the parameter annotation to receive a viewer assert isinstance(magic_widget.viewer.value, napari.Viewer) # The function just returns the viewer... make sure we can call it assert isinstance(magic_widget(), napari.Viewer) # Add twice is ok, only does a show tp3.trigger() def test_inject_viewer_proxy(make_napari_viewer): """Test that the injected viewer is a public-only proxy""" viewer = make_napari_viewer() wdg = _instantiate_dock_widget(Widg3, viewer) assert isinstance(wdg.viewer, PublicOnlyProxy) # simulate access from outside napari with patch('napari.utils.misc.ROOT_DIR', new='/some/other/package'): with pytest.warns(FutureWarning): wdg.fail() napari-0.5.0a1/napari/_qt/_tests/test_proxy_fixture.py000066400000000000000000000012131437041365600231140ustar00rootroot00000000000000import pytest from napari.utils import misc def test_proxy_fixture_warning(make_napari_viewer_proxy, monkeypatch): viewer = make_napari_viewer_proxy() monkeypatch.setattr(misc, 'ROOT_DIR', '/some/other/package') with pytest.warns(FutureWarning, match='Private attribute access'): viewer.window._qt_window def test_proxy_fixture_thread_error( make_napari_viewer_proxy, single_threaded_executor ): viewer = make_napari_viewer_proxy() future = single_threaded_executor.submit( viewer.__setattr__, 'status', 'hi' ) with pytest.raises(RuntimeError, match='Setting attributes'): future.result() napari-0.5.0a1/napari/_qt/_tests/test_prune_qt_connections.py000066400000000000000000000015541437041365600244340ustar00rootroot00000000000000from unittest.mock import Mock from qtpy.QtWidgets import QSpinBox from napari.utils.events import EmitterGroup def test_prune_dead_qt(qtbot): qtcalls = 0 class W(QSpinBox): def _set(self, event): self.setValue(event.value) nonlocal qtcalls qtcalls += 1 wdg = W() mock = Mock() group = EmitterGroup(None, False, boom=None) group.boom.connect(mock) group.boom.connect(wdg._set) assert len(group.boom.callbacks) == 2 group.boom(value=1) assert qtcalls == 1 mock.assert_called_once() mock.reset_mock() with qtbot.waitSignal(wdg.destroyed): wdg.close() wdg.deleteLater() group.boom(value=1) mock.assert_called_once() assert len(group.boom.callbacks) == 1 # we've lost the qt connection assert qtcalls == 1 # qwidget didn't get called again napari-0.5.0a1/napari/_qt/_tests/test_qt_notifications.py000066400000000000000000000205541437041365600235530ustar00rootroot00000000000000import threading import warnings from concurrent.futures import Future from dataclasses import dataclass from unittest.mock import MagicMock import dask.array as da import pytest from qtpy.QtCore import Qt, QThread from qtpy.QtWidgets import QPushButton, QWidget from napari._qt.dialogs.qt_notification import ( NapariQtNotification, TracebackDialog, ) from napari._tests.utils import DEFAULT_TIMEOUT_SECS, skip_on_win_ci from napari.utils.notifications import ( ErrorNotification, Notification, NotificationSeverity, notification_manager, ) def _threading_warn(): thr = threading.Thread(target=_warn) thr.start() thr.join(timeout=DEFAULT_TIMEOUT_SECS) def _warn(): warnings.warn('warning!') def _threading_raise(): thr = threading.Thread(target=_raise) thr.start() thr.join(timeout=DEFAULT_TIMEOUT_SECS) def _raise(): raise ValueError("error!") @pytest.fixture def clean_current(monkeypatch, qtbot): from napari._qt.qt_main_window import _QtMainWindow base_show = NapariQtNotification.show widget = QWidget() qtbot.addWidget(widget) mock_window = MagicMock() widget.resized = MagicMock() mock_window._qt_viewer._welcome_widget = widget def mock_current_main_window(*_, **__): """ This return mock main window object to ensure that notification dialog has parent added to qtbot """ return mock_window def store_widget(self, *args, **kwargs): base_show(self, *args, **kwargs) monkeypatch.setattr(NapariQtNotification, "show", store_widget) monkeypatch.setattr(_QtMainWindow, "current", mock_current_main_window) @dataclass class ShowStatus: show_notification_count: int = 0 show_traceback_count: int = 0 @pytest.fixture(autouse=True) def raise_on_show(monkeypatch, qtbot): def raise_prepare(text): def _raise_on_call(self, *args, **kwargs): raise RuntimeError(text) return _raise_on_call monkeypatch.setattr( NapariQtNotification, 'show', raise_prepare("notification show") ) monkeypatch.setattr( TracebackDialog, 'show', raise_prepare("traceback show") ) monkeypatch.setattr( NapariQtNotification, 'close_with_fade', raise_prepare("close_with_fade"), ) @pytest.fixture def count_show(monkeypatch, qtbot): stat = ShowStatus() def mock_show_notif(_): stat.show_notification_count += 1 def mock_show_traceback(_): stat.show_traceback_count += 1 monkeypatch.setattr(NapariQtNotification, "show", mock_show_notif) monkeypatch.setattr(TracebackDialog, "show", mock_show_traceback) return stat @pytest.fixture(autouse=True) def ensure_qtbot(monkeypatch, qtbot): old_notif_init = NapariQtNotification.__init__ old_traceback_init = TracebackDialog.__init__ def mock_notif_init(self, *args, **kwargs): old_notif_init(self, *args, **kwargs) qtbot.add_widget(self) def mock_traceback_init(self, *args, **kwargs): old_traceback_init(self, *args, **kwargs) qtbot.add_widget(self) monkeypatch.setattr(NapariQtNotification, "__init__", mock_notif_init) monkeypatch.setattr(TracebackDialog, "__init__", mock_traceback_init) def test_clean_current_path_exist(make_napari_viewer): """If this test fail then you need to fix also clean_current fixture""" assert isinstance( make_napari_viewer().window._qt_viewer._welcome_widget, QWidget ) @pytest.mark.parametrize( "raise_func,warn_func", [(_raise, _warn), (_threading_raise, _threading_warn)], ) def test_notification_manager_via_gui( count_show, qtbot, raise_func, warn_func, clean_current, monkeypatch ): """ Test that the notification_manager intercepts `sys.excepthook`` and `threading.excepthook`. """ errButton = QPushButton() warnButton = QPushButton() errButton.clicked.connect(raise_func) warnButton.clicked.connect(warn_func) qtbot.addWidget(errButton) qtbot.addWidget(warnButton) monkeypatch.setattr( NapariQtNotification, "show_notification", lambda x: None ) with notification_manager: for btt, expected_message in [ (errButton, 'error!'), (warnButton, 'warning!'), ]: notification_manager.records = [] qtbot.mouseClick(btt, Qt.MouseButton.LeftButton) assert len(notification_manager.records) == 1 assert notification_manager.records[0].message == expected_message notification_manager.records = [] def test_show_notification_from_thread( count_show, monkeypatch, qtbot, clean_current ): from napari.settings import get_settings settings = get_settings() monkeypatch.setattr( settings.application, 'gui_notification_level', NotificationSeverity.INFO, ) class CustomThread(QThread): def run(self): notif = Notification( 'hi', NotificationSeverity.INFO, actions=[('click', lambda x: None)], ) res = NapariQtNotification.show_notification(notif) assert isinstance(res, Future) assert res.result(timeout=DEFAULT_TIMEOUT_SECS) is None assert count_show.show_notification_count == 1 thread = CustomThread() with qtbot.waitSignal(thread.finished): thread.start() @pytest.mark.parametrize('severity', NotificationSeverity.__members__) def test_notification_display( count_show, severity, monkeypatch, clean_current ): """Test that NapariQtNotification can present a Notification event. NOTE: in napari.utils._tests.test_notification_manager, we already test that the notification manager successfully overrides sys.excepthook, and warnings.showwarning... and that it emits an event which is an instance of napari.utils.notifications.Notification. in `get_app()`, we connect `notification_manager.notification_ready` to `NapariQtNotification.show_notification`, so all we have to test here is that show_notification is capable of receiving various event types. (we don't need to test that ) """ from napari.settings import get_settings settings = get_settings() monkeypatch.delenv('NAPARI_CATCH_ERRORS', raising=False) monkeypatch.setattr( settings.application, 'gui_notification_level', NotificationSeverity.INFO, ) notif = Notification('hi', severity, actions=[('click', lambda x: None)]) NapariQtNotification.show_notification(notif) if NotificationSeverity(severity) >= NotificationSeverity.INFO: assert count_show.show_notification_count == 1 else: assert count_show.show_notification_count == 0 dialog = NapariQtNotification.from_notification(notif) assert not dialog.property('expanded') dialog.toggle_expansion() assert dialog.property('expanded') dialog.toggle_expansion() assert not dialog.property('expanded') def test_notification_error(count_show, monkeypatch): from napari.settings import get_settings settings = get_settings() monkeypatch.delenv('NAPARI_CATCH_ERRORS', raising=False) monkeypatch.setattr( NapariQtNotification, "close_with_fade", lambda x, y: None ) monkeypatch.setattr( settings.application, 'gui_notification_level', NotificationSeverity.INFO, ) try: raise ValueError('error!') except ValueError as e: notif = ErrorNotification(e) dialog = NapariQtNotification.from_notification(notif) bttn = dialog.row2_widget.findChild(QPushButton) assert bttn.text() == 'View Traceback' assert count_show.show_traceback_count == 0 bttn.click() assert count_show.show_traceback_count == 1 @skip_on_win_ci @pytest.mark.sync_only def test_notifications_error_with_threading( make_napari_viewer, clean_current, monkeypatch ): """Test notifications of `threading` threads, using a dask example.""" random_image = da.random.random((10, 10)) monkeypatch.setattr( NapariQtNotification, "show_notification", lambda x: None ) with notification_manager: viewer = make_napari_viewer(strict_qt=False) viewer.add_image(random_image) result = da.divide(random_image, da.zeros((10, 10))) viewer.add_image(result) assert len(notification_manager.records) >= 1 notification_manager.records = [] napari-0.5.0a1/napari/_qt/_tests/test_qt_provide_theme.py000066400000000000000000000053771437041365600235420ustar00rootroot00000000000000import warnings from unittest.mock import patch import pytest from napari_plugin_engine import napari_hook_implementation from napari import Viewer from napari._qt import Window from napari._tests.utils import skip_on_win_ci from napari.settings import get_settings from napari.utils.theme import Theme, get_theme @skip_on_win_ci @patch.object(Window, "_remove_theme") @patch.object(Window, "_add_theme") def test_provide_theme_hook_registered_correctly( mock_add_theme, mock_remove_theme, make_napari_viewer, napari_plugin_manager, ): # make a viewer with a plugin & theme registered viewer = make_napari_viewer_with_plugin_theme( make_napari_viewer, napari_plugin_manager, theme_type='dark', name='dark-test-2', ) # set the viewer theme to the plugin theme viewer.theme = "dark-test-2" # triggered when theme was added mock_add_theme.assert_called() mock_remove_theme.assert_not_called() # now, lets unregister the theme # We didn't set the setting, so ensure that no warning with warnings.catch_warnings(): warnings.simplefilter("error") napari_plugin_manager.unregister("TestPlugin") mock_remove_theme.assert_called() @patch.object(Window, "_remove_theme") @patch.object(Window, "_add_theme") def test_plugin_provide_theme_hook_set_settings_correctly( mock_add_theme, mock_remove_theme, make_napari_viewer, napari_plugin_manager, ): # make a viewer with a plugin & theme registered make_napari_viewer_with_plugin_theme( make_napari_viewer, napari_plugin_manager, theme_type='dark', name='dark-test-2', ) # set the plugin theme as a setting get_settings().appearance.theme = "dark-test-2" # triggered when theme was added mock_add_theme.assert_called() mock_remove_theme.assert_not_called() # now, lets unregister the theme # We *did* set the setting, so there should be a warning with pytest.warns(UserWarning, match="The current theme "): napari_plugin_manager.unregister("TestPlugin") mock_remove_theme.assert_called() def make_napari_viewer_with_plugin_theme( make_napari_viewer, napari_plugin_manager, *, theme_type: str, name: str ) -> Viewer: theme = get_theme(theme_type, True) theme["name"] = name class TestPlugin: @napari_hook_implementation def napari_experimental_provide_theme(): return {name: theme} # create instance of viewer to make sure # registration and unregistration methods are called viewer = make_napari_viewer() # register theme napari_plugin_manager.register(TestPlugin) reg = napari_plugin_manager._theme_data["TestPlugin"] assert isinstance(reg[name], Theme) return viewer napari-0.5.0a1/napari/_qt/_tests/test_qt_public_imports.py000066400000000000000000000001121437041365600237210ustar00rootroot00000000000000from napari.qt import * # noqa from napari.qt.threading import * # noqa napari-0.5.0a1/napari/_qt/_tests/test_qt_utils.py000066400000000000000000000071721437041365600220430ustar00rootroot00000000000000import pytest from qtpy.QtCore import QObject, Signal from qtpy.QtWidgets import QMainWindow from napari._qt.utils import ( QBYTE_FLAG, add_flash_animation, is_qbyte, qbytearray_to_str, qt_might_be_rich_text, qt_signals_blocked, str_to_qbytearray, ) from napari.utils._proxies import PublicOnlyProxy class Emitter(QObject): test_signal = Signal() def go(self): self.test_signal.emit() def test_signal_blocker(qtbot): """make sure context manager signal blocker works""" import pytestqt.exceptions obj = Emitter() # make sure signal works with qtbot.waitSignal(obj.test_signal): obj.go() # make sure blocker works with qt_signals_blocked(obj): with pytest.raises(pytestqt.exceptions.TimeoutError): with qtbot.waitSignal(obj.test_signal, timeout=500): obj.go() def test_is_qbyte_valid(): is_qbyte(QBYTE_FLAG) is_qbyte( "!QBYTE_AAAA/wAAAAD9AAAAAgAAAAAAAAECAAACePwCAAAAAvsAAAAcAGwAYQB5AGUAcgAgAGMAbwBuAHQAcgBvAGwAcwEAAAAAAAABFwAAARcAAAEX+wAAABQAbABhAHkAZQByACAAbABpAHMAdAEAAAEXAAABYQAAALcA////AAAAAwAAAAAAAAAA/AEAAAAB+wAAAA4AYwBvAG4AcwBvAGwAZQAAAAAA/////wAAADIA////AAADPAAAAngAAAAEAAAABAAAAAgAAAAI/AAAAAA=" ) def test_str_to_qbytearray_valid(): with pytest.raises(ValueError): str_to_qbytearray("") with pytest.raises(ValueError): str_to_qbytearray("FOOBAR") with pytest.raises(ValueError): str_to_qbytearray( "_AAAA/wAAAAD9AAAAAgAAAAAAAAECAAACePwCAAAAAvsAAAAcAGwAYQB5AGUAcgAgAGMAbwBuAHQAcgBvAGwAcwEAAAAAAAABFwAAARcAAAEX+wAAABQAbABhAHkAZQByACAAbABpAHMAdAEAAAEXAAABYQAAALcA////AAAAAwAAAAAAAAAA/AEAAAAB+wAAAA4AYwBvAG4AcwBvAGwAZQAAAAAA/////wAAADIA////AAADPAAAAngAAAAEAAAABAAAAAgAAAAI/AAAAAA=" ) def test_str_to_qbytearray_invalid(): with pytest.raises(ValueError): str_to_qbytearray("") with pytest.raises(ValueError): str_to_qbytearray( "_AAAA/wAAAAD9AAAAAgAAAAAAAAECAAACePwCAAAAAvsAAAAcAGwAYQB5AGUAcgAgAGMAbwBuAHQAcgBvAGwAcwEAAAAAAAABFwAAARcAAAEX+wAAABQAbABhAHkAZQByACAAbABpAHMAdAEAAAEXAAABYQAAALcA////AAAAAwAAAAAAAAAA/AEAAAAB+wAAAA4AYwBvAG4AcwBvAGwAZQAAAAAA/////wAAADIA////AAADPAAAAngAAAAEAAAABAAAAAgAAAAI/AAAAAA=" ) def test_qbytearray_to_str(qtbot): widget = QMainWindow() qtbot.addWidget(widget) qbyte = widget.saveState() qbyte_string = qbytearray_to_str(qbyte) assert is_qbyte(qbyte_string) def test_qbytearray_to_str_and_back(qtbot): widget = QMainWindow() qtbot.addWidget(widget) qbyte = widget.saveState() assert str_to_qbytearray(qbytearray_to_str(qbyte)) == qbyte def test_add_flash_animation(qtbot): widget = QMainWindow() qtbot.addWidget(widget) assert widget.graphicsEffect() is None add_flash_animation(widget) assert widget.graphicsEffect() is not None assert hasattr(widget, "_flash_animation") qtbot.wait(350) assert widget.graphicsEffect() is None assert not hasattr(widget, "_flash_animation") def test_qt_might_be_rich_text(qtbot): widget = QMainWindow() qtbot.addWidget(widget) assert qt_might_be_rich_text("rich text") assert not qt_might_be_rich_text("plain text") def test_thread_proxy_guard(monkeypatch, qapp, single_threaded_executor): class X: a = 1 monkeypatch.setenv('NAPARI_ENSURE_PLUGIN_MAIN_THREAD', 'True') x = X() x_proxy = PublicOnlyProxy(x) f = single_threaded_executor.submit(x.__setattr__, 'a', 2) f.result() assert x.a == 2 f = single_threaded_executor.submit(x_proxy.__setattr__, 'a', 3) with pytest.raises(RuntimeError): f.result() assert x.a == 2 napari-0.5.0a1/napari/_qt/_tests/test_qt_viewer.py000066400000000000000000000534331437041365600222050ustar00rootroot00000000000000import gc import os import weakref from dataclasses import dataclass from typing import List from unittest import mock import numpy as np import pytest from imageio import imread from qtpy.QtGui import QGuiApplication from qtpy.QtWidgets import QMessageBox from napari._qt.qt_viewer import QtViewer from napari._tests.utils import ( add_layer_by_type, check_viewer_functioning, layer_test_data, skip_local_popups, skip_on_win_ci, ) from napari._vispy.utils.gl import fix_data_dtype from napari.components.viewer_model import ViewerModel from napari.layers import Points from napari.settings import get_settings from napari.utils.interactions import mouse_press_callbacks from napari.utils.theme import available_themes BUILTINS_DISP = 'napari' BUILTINS_NAME = 'builtins' def test_qt_viewer(make_napari_viewer): """Test instantiating viewer.""" viewer = make_napari_viewer() view = viewer.window._qt_viewer assert viewer.title == 'napari' assert view.viewer == viewer assert len(viewer.layers) == 0 assert view.layers.model().rowCount() == 0 assert viewer.dims.ndim == 2 assert view.dims.nsliders == viewer.dims.ndim assert np.sum(view.dims._displayed_sliders) == 0 def test_qt_viewer_with_console(make_napari_viewer): """Test instantiating console from viewer.""" viewer = make_napari_viewer() view = viewer.window._qt_viewer # Check console is created when requested assert view.console is not None assert view.dockConsole.widget() is view.console def test_qt_viewer_toggle_console(make_napari_viewer): """Test instantiating console from viewer.""" viewer = make_napari_viewer() view = viewer.window._qt_viewer # Check console has been created when it is supposed to be shown view.toggle_console_visibility(None) assert view._console is not None assert view.dockConsole.widget() is view.console @skip_local_popups def test_qt_viewer_console_focus(qtbot, make_napari_viewer): """Test console has focus when instantiating from viewer.""" viewer = make_napari_viewer(show=True) view = viewer.window._qt_viewer assert not view.console.hasFocus(), "console has focus before being shown" view.toggle_console_visibility(None) def console_has_focus(): assert ( view.console.hasFocus() ), "console does not have focus when shown" qtbot.waitUntil(console_has_focus) @pytest.mark.parametrize('layer_class, data, ndim', layer_test_data) def test_add_layer(make_napari_viewer, layer_class, data, ndim): viewer = make_napari_viewer(ndisplay=int(np.clip(ndim, 2, 3))) view = viewer.window._qt_viewer add_layer_by_type(viewer, layer_class, data) check_viewer_functioning(viewer, view, data, ndim) def test_new_labels(make_napari_viewer): """Test adding new labels layer.""" # Add labels to empty viewer viewer = make_napari_viewer() view = viewer.window._qt_viewer viewer._new_labels() assert np.max(viewer.layers[0].data) == 0 assert len(viewer.layers) == 1 assert view.layers.model().rowCount() == len(viewer.layers) assert viewer.dims.ndim == 2 assert view.dims.nsliders == viewer.dims.ndim assert np.sum(view.dims._displayed_sliders) == 0 # Add labels with image already present viewer = make_napari_viewer() view = viewer.window._qt_viewer np.random.seed(0) data = np.random.random((10, 15)) viewer.add_image(data) viewer._new_labels() assert np.max(viewer.layers[1].data) == 0 assert len(viewer.layers) == 2 assert view.layers.model().rowCount() == len(viewer.layers) assert viewer.dims.ndim == 2 assert view.dims.nsliders == viewer.dims.ndim assert np.sum(view.dims._displayed_sliders) == 0 def test_new_points(make_napari_viewer): """Test adding new points layer.""" # Add labels to empty viewer viewer = make_napari_viewer() view = viewer.window._qt_viewer viewer.add_points() assert len(viewer.layers[0].data) == 0 assert len(viewer.layers) == 1 assert view.layers.model().rowCount() == len(viewer.layers) assert viewer.dims.ndim == 2 assert view.dims.nsliders == viewer.dims.ndim assert np.sum(view.dims._displayed_sliders) == 0 # Add points with image already present viewer = make_napari_viewer() view = viewer.window._qt_viewer np.random.seed(0) data = np.random.random((10, 15)) viewer.add_image(data) viewer.add_points() assert len(viewer.layers[1].data) == 0 assert len(viewer.layers) == 2 assert view.layers.model().rowCount() == len(viewer.layers) assert viewer.dims.ndim == 2 assert view.dims.nsliders == viewer.dims.ndim assert np.sum(view.dims._displayed_sliders) == 0 def test_new_shapes_empty_viewer(make_napari_viewer): """Test adding new shapes layer.""" # Add labels to empty viewer viewer = make_napari_viewer() view = viewer.window._qt_viewer viewer.add_shapes() assert len(viewer.layers[0].data) == 0 assert len(viewer.layers) == 1 assert view.layers.model().rowCount() == len(viewer.layers) assert viewer.dims.ndim == 2 assert view.dims.nsliders == viewer.dims.ndim assert np.sum(view.dims._displayed_sliders) == 0 # Add points with image already present viewer = make_napari_viewer() view = viewer.window._qt_viewer np.random.seed(0) data = np.random.random((10, 15)) viewer.add_image(data) viewer.add_shapes() assert len(viewer.layers[1].data) == 0 assert len(viewer.layers) == 2 assert view.layers.model().rowCount() == len(viewer.layers) assert viewer.dims.ndim == 2 assert view.dims.nsliders == viewer.dims.ndim assert np.sum(view.dims._displayed_sliders) == 0 def test_z_order_adding_removing_images(make_napari_viewer): """Test z order is correct after adding/ removing images.""" data = np.ones((10, 10)) viewer = make_napari_viewer() vis = viewer.window._qt_viewer.layer_to_visual viewer.add_image(data, colormap='red', name='red') viewer.add_image(data, colormap='green', name='green') viewer.add_image(data, colormap='blue', name='blue') order = [vis[x].order for x in viewer.layers] np.testing.assert_almost_equal(order, list(range(len(viewer.layers)))) # Remove and re-add image viewer.layers.remove('red') order = [vis[x].order for x in viewer.layers] np.testing.assert_almost_equal(order, list(range(len(viewer.layers)))) viewer.add_image(data, colormap='red', name='red') order = [vis[x].order for x in viewer.layers] np.testing.assert_almost_equal(order, list(range(len(viewer.layers)))) # Remove two other images viewer.layers.remove('green') viewer.layers.remove('blue') order = [vis[x].order for x in viewer.layers] np.testing.assert_almost_equal(order, list(range(len(viewer.layers)))) # Add two other layers back viewer.add_image(data, colormap='green', name='green') viewer.add_image(data, colormap='blue', name='blue') order = [vis[x].order for x in viewer.layers] np.testing.assert_almost_equal(order, list(range(len(viewer.layers)))) @skip_on_win_ci def test_screenshot(make_napari_viewer): "Test taking a screenshot" viewer = make_napari_viewer() np.random.seed(0) # Add image data = np.random.random((10, 15)) viewer.add_image(data) # Add labels data = np.random.randint(20, size=(10, 15)) viewer.add_labels(data) # Add points data = 20 * np.random.random((10, 2)) viewer.add_points(data) # Add vectors data = 20 * np.random.random((10, 2, 2)) viewer.add_vectors(data) # Add shapes data = 20 * np.random.random((10, 4, 2)) viewer.add_shapes(data) # Take screenshot with pytest.warns(FutureWarning): screenshot = viewer.window.qt_viewer.screenshot(flash=False) screenshot = viewer.window.screenshot(flash=False, canvas_only=True) assert screenshot.ndim == 3 @pytest.mark.skip("new approach") def test_screenshot_dialog(make_napari_viewer, tmpdir): """Test save screenshot functionality.""" viewer = make_napari_viewer() np.random.seed(0) # Add image data = np.random.random((10, 15)) viewer.add_image(data) # Add labels data = np.random.randint(20, size=(10, 15)) viewer.add_labels(data) # Add points data = 20 * np.random.random((10, 2)) viewer.add_points(data) # Add vectors data = 20 * np.random.random((10, 2, 2)) viewer.add_vectors(data) # Add shapes data = 20 * np.random.random((10, 4, 2)) viewer.add_shapes(data) # Save screenshot input_filepath = os.path.join(tmpdir, 'test-save-screenshot') mock_return = (input_filepath, '') with mock.patch('napari._qt._qt_viewer.QFileDialog') as mocker, mock.patch( 'napari._qt._qt_viewer.QMessageBox' ) as mocker2: mocker.getSaveFileName.return_value = mock_return mocker2.warning.return_value = QMessageBox.Yes viewer.window._qt_viewer._screenshot_dialog() # Assert behaviour is correct expected_filepath = input_filepath + '.png' # add default file extension assert os.path.exists(expected_filepath) output_data = imread(expected_filepath) expected_data = viewer.window._qt_viewer.screenshot(flash=False) assert np.allclose(output_data, expected_data) @pytest.mark.parametrize( "dtype", [ 'int8', 'uint8', 'int16', 'uint16', 'int32', 'float16', 'float32', 'float64', ], ) def test_qt_viewer_data_integrity(make_napari_viewer, dtype): """Test that the viewer doesn't change the underlying array.""" image = np.random.rand(10, 32, 32) image *= 200 if dtype.endswith('8') else 2**14 image = image.astype(dtype) imean = image.mean() viewer = make_napari_viewer() layer = viewer.add_image(image.copy()) data = layer.data datamean = np.mean(data) assert datamean == imean # toggle dimensions viewer.dims.ndisplay = 3 datamean = np.mean(data) assert datamean == imean # back to 2D viewer.dims.ndisplay = 2 datamean = np.mean(data) assert datamean == imean # also check that vispy gets (almost) the same data datamean = np.mean(fix_data_dtype(data)) assert np.allclose(datamean, imean, rtol=5e-04) def test_points_layer_display_correct_slice_on_scale(make_napari_viewer): viewer = make_napari_viewer() data = np.zeros((60, 60, 60)) viewer.add_image(data, scale=[0.29, 0.26, 0.26]) pts = viewer.add_points(name='test', size=1, ndim=3) pts.add((8.7, 0, 0)) viewer.dims.set_point(0, 30 * 0.29) # middle plane request = pts._make_slice_request(viewer.dims) response = request() np.testing.assert_equal(response.indices, [0]) @skip_on_win_ci def test_qt_viewer_clipboard_with_flash(make_napari_viewer, qtbot): viewer = make_napari_viewer() # make sure clipboard is empty QGuiApplication.clipboard().clear() clipboard_image = QGuiApplication.clipboard().image() assert clipboard_image.isNull() # capture screenshot with pytest.warns(FutureWarning): viewer.window.qt_viewer.clipboard(flash=True) viewer.window.clipboard(flash=False, canvas_only=True) clipboard_image = QGuiApplication.clipboard().image() assert not clipboard_image.isNull() # ensure the flash effect is applied assert ( viewer.window._qt_viewer._welcome_widget.graphicsEffect() is not None ) assert hasattr( viewer.window._qt_viewer._welcome_widget, "_flash_animation" ) qtbot.wait(500) # wait for the animation to finish assert viewer.window._qt_viewer._welcome_widget.graphicsEffect() is None assert not hasattr( viewer.window._qt_viewer._welcome_widget, "_flash_animation" ) # clear clipboard and grab image from application view QGuiApplication.clipboard().clear() clipboard_image = QGuiApplication.clipboard().image() assert clipboard_image.isNull() # capture screenshot of the entire window viewer.window.clipboard(flash=True) clipboard_image = QGuiApplication.clipboard().image() assert not clipboard_image.isNull() # ensure the flash effect is applied assert viewer.window._qt_window.graphicsEffect() is not None assert hasattr(viewer.window._qt_window, "_flash_animation") qtbot.wait(500) # wait for the animation to finish assert viewer.window._qt_window.graphicsEffect() is None assert not hasattr(viewer.window._qt_window, "_flash_animation") @skip_on_win_ci def test_qt_viewer_clipboard_without_flash(make_napari_viewer): viewer = make_napari_viewer() # make sure clipboard is empty QGuiApplication.clipboard().clear() clipboard_image = QGuiApplication.clipboard().image() assert clipboard_image.isNull() # capture screenshot with pytest.warns(FutureWarning): viewer.window.qt_viewer.clipboard(flash=False) viewer.window.clipboard(flash=False, canvas_only=True) clipboard_image = QGuiApplication.clipboard().image() assert not clipboard_image.isNull() # ensure the flash effect is not applied assert viewer.window._qt_viewer._welcome_widget.graphicsEffect() is None assert not hasattr( viewer.window._qt_viewer._welcome_widget, "_flash_animation" ) # clear clipboard and grab image from application view QGuiApplication.clipboard().clear() clipboard_image = QGuiApplication.clipboard().image() assert clipboard_image.isNull() # capture screenshot of the entire window viewer.window.clipboard(flash=False) clipboard_image = QGuiApplication.clipboard().image() assert not clipboard_image.isNull() # ensure the flash effect is not applied assert viewer.window._qt_window.graphicsEffect() is None assert not hasattr(viewer.window._qt_window, "_flash_animation") def test_active_keybindings(make_napari_viewer): """Test instantiating viewer.""" viewer = make_napari_viewer() view = viewer.window._qt_viewer # Check only keybinding is Viewer assert len(view._key_map_handler.keymap_providers) == 1 assert view._key_map_handler.keymap_providers[0] == viewer # Add a layer and check it is keybindings are active data = np.random.random((10, 15)) layer_image = viewer.add_image(data) assert viewer.layers.selection.active == layer_image assert len(view._key_map_handler.keymap_providers) == 2 assert view._key_map_handler.keymap_providers[0] == layer_image # Add a layer and check it is keybindings become active layer_image_2 = viewer.add_image(data) assert viewer.layers.selection.active == layer_image_2 assert len(view._key_map_handler.keymap_providers) == 2 assert view._key_map_handler.keymap_providers[0] == layer_image_2 # Change active layer and check it is keybindings become active viewer.layers.selection.active = layer_image assert viewer.layers.selection.active == layer_image assert len(view._key_map_handler.keymap_providers) == 2 assert view._key_map_handler.keymap_providers[0] == layer_image @dataclass class MouseEvent: # mock mouse event class pos: List[int] def test_process_mouse_event(make_napari_viewer): """Test that the correct properties are added to the MouseEvent by _process_mouse_events. """ # make a mock mouse event new_pos = [25, 25] mouse_event = MouseEvent( pos=new_pos, ) data = np.zeros((5, 20, 20, 20), dtype=int) data[1, 0:10, 0:10, 0:10] = 1 viewer = make_napari_viewer() view = viewer.window._qt_viewer labels = viewer.add_labels(data, scale=(1, 2, 1, 1), translate=(5, 5, 5)) @labels.mouse_drag_callbacks.append def on_click(layer, event): np.testing.assert_almost_equal(event.view_direction, [0, 1, 0, 0]) np.testing.assert_array_equal(event.dims_displayed, [1, 2, 3]) assert event.dims_point[0] == data.shape[0] // 2 expected_position = view._map_canvas2world(new_pos) np.testing.assert_almost_equal(expected_position, list(event.position)) viewer.dims.ndisplay = 3 view._process_mouse_event(mouse_press_callbacks, mouse_event) @skip_local_popups def test_memory_leaking(qtbot, make_napari_viewer): data = np.zeros((5, 20, 20, 20), dtype=int) data[1, 0:10, 0:10, 0:10] = 1 viewer = make_napari_viewer() image = weakref.ref(viewer.add_image(data)) labels = weakref.ref(viewer.add_labels(data)) del viewer.layers[0] del viewer.layers[0] qtbot.wait(100) gc.collect() gc.collect() assert image() is None assert labels() is None @skip_on_win_ci @skip_local_popups def test_leaks_image(qtbot, make_napari_viewer): viewer = make_napari_viewer(show=True) lr = weakref.ref(viewer.add_image(np.random.rand(10, 10))) dr = weakref.ref(lr().data) viewer.layers.clear() qtbot.wait(100) gc.collect() gc.collect() assert not lr() assert not dr() @skip_on_win_ci @skip_local_popups def test_leaks_labels(qtbot, make_napari_viewer): viewer = make_napari_viewer(show=True) lr = weakref.ref( viewer.add_labels((np.random.rand(10, 10) * 10).astype(np.uint8)) ) dr = weakref.ref(lr().data) viewer.layers.clear() qtbot.wait(100) gc.collect() gc.collect() assert not lr() assert not dr() @pytest.mark.parametrize("theme", available_themes()) def test_canvas_color(make_napari_viewer, theme): """Test instantiating viewer with different themes. See: https://github.com/napari/napari/issues/3278 """ # This test is to make sure the application starts with # with different themes get_settings().appearance.theme = theme viewer = make_napari_viewer() assert viewer.theme == theme def test_remove_points(make_napari_viewer): viewer = make_napari_viewer() viewer.add_points([(1, 2), (2, 3)]) del viewer.layers[0] viewer.add_points([(1, 2), (2, 3)]) def test_remove_image(make_napari_viewer): viewer = make_napari_viewer() viewer.add_image(np.random.rand(10, 10)) del viewer.layers[0] viewer.add_image(np.random.rand(10, 10)) def test_remove_labels(make_napari_viewer): viewer = make_napari_viewer() viewer.add_labels((np.random.rand(10, 10) * 10).astype(np.uint8)) del viewer.layers[0] viewer.add_labels((np.random.rand(10, 10) * 10).astype(np.uint8)) @pytest.mark.parametrize('multiscale', [False, True]) def test_mixed_2d_and_3d_layers(make_napari_viewer, multiscale): """Test bug in setting corner_pixels from qt_viewer.on_draw""" viewer = make_napari_viewer() img = np.ones((512, 256)) # canvas size must be large enough that img fits in the canvas canvas_size = tuple(3 * s for s in img.shape) expected_corner_pixels = np.asarray([[0, 0], [img.shape[0], img.shape[1]]]) vol = np.stack([img] * 8, axis=0) if multiscale: img = [img[::s, ::s] for s in (1, 2, 4)] viewer.add_image(img) img_multi_layer = viewer.layers[0] viewer.add_image(vol) viewer.dims.order = (0, 1, 2) viewer.window._qt_viewer.canvas.size = canvas_size viewer.window._qt_viewer.on_draw(None) assert np.all(img_multi_layer.corner_pixels == expected_corner_pixels) viewer.dims.order = (2, 0, 1) viewer.window._qt_viewer.on_draw(None) assert np.all(img_multi_layer.corner_pixels == expected_corner_pixels) viewer.dims.order = (1, 2, 0) viewer.window._qt_viewer.on_draw(None) assert np.all(img_multi_layer.corner_pixels == expected_corner_pixels) def test_remove_add_image_3D(make_napari_viewer): """ Test that adding, removing and readding an image layer in 3D does not cause issues due to the vispy node change. See https://github.com/napari/napari/pull/3670 """ viewer = make_napari_viewer(ndisplay=3) img = np.ones((10, 10, 10)) layer = viewer.add_image(img) viewer.layers.remove(layer) viewer.layers.append(layer) @skip_on_win_ci @skip_local_popups def test_qt_viewer_multscale_image_out_of_view(make_napari_viewer): """Test out-of-view multiscale image viewing fix. Just verifies that no RuntimeError is raised in this scenario. see: https://github.com/napari/napari/issues/3863. """ # show=True required to test fix for OpenGL error viewer = make_napari_viewer(ndisplay=2, show=True) viewer.add_shapes( data=[ np.array( [[1500, 4500], [4500, 4500], [4500, 1500], [1500, 1500]], dtype=float, ) ], shape_type=['polygon'], ) viewer.add_image([np.eye(1024), np.eye(512), np.eye(256)]) def test_surface_mixed_dim(make_napari_viewer): """Test that adding a layer that changes the world ndim when ndisplay=3 before the mouse cursor has been updated doesn't raise an error. See PR: https://github.com/napari/napari/pull/3881 """ viewer = make_napari_viewer(ndisplay=3) verts = np.array([[0, 0, 0], [0, 20, 10], [10, 0, -10], [10, 10, -10]]) faces = np.array([[0, 1, 2], [1, 2, 3]]) values = np.linspace(0, 1, len(verts)) data = (verts, faces, values) viewer.add_surface(data) timeseries_values = np.vstack([values, values]) timeseries_data = (verts, faces, timeseries_values) viewer.add_surface(timeseries_data) def test_insert_layer_ordering(make_napari_viewer): """make sure layer ordering is correct in vispy when inserting layers""" viewer = make_napari_viewer() pl1 = Points() pl2 = Points() viewer.layers.append(pl1) viewer.layers.insert(0, pl2) pl1_vispy = viewer.window._qt_viewer.layer_to_visual[pl1].node pl2_vispy = viewer.window._qt_viewer.layer_to_visual[pl2].node assert pl1_vispy.order == 1 assert pl2_vispy.order == 0 def test_create_non_empty_viewer_model(qtbot): viewer_model = ViewerModel() viewer_model.add_points([(1, 2), (2, 3)]) viewer = QtViewer(viewer=viewer_model) viewer.close() viewer.deleteLater() # try to del local reference for gc. del viewer_model del viewer qtbot.wait(50) gc.collect() napari-0.5.0a1/napari/_qt/_tests/test_qt_window.py000066400000000000000000000061171437041365600222100ustar00rootroot00000000000000import platform from unittest.mock import patch import pytest from napari._qt.qt_main_window import Window, _QtMainWindow from napari.utils.theme import ( _themes, get_theme, register_theme, unregister_theme, ) def test_current_viewer(make_napari_viewer): """Test that we can retrieve the "current" viewer window easily. ... where "current" means it was the last viewer the user interacted with. """ assert _QtMainWindow.current() is None # when we create a new viewer it becomes accessible at Viewer.current() v1 = make_napari_viewer(title='v1') assert _QtMainWindow._instances == [v1.window._qt_window] assert _QtMainWindow.current() == v1.window._qt_window v2 = make_napari_viewer(title='v2') assert _QtMainWindow._instances == [ v1.window._qt_window, v2.window._qt_window, ] assert _QtMainWindow.current() == v2.window._qt_window # Viewer.current() will always give the most recently activated viewer. v1.window.activate() assert _QtMainWindow.current() == v1.window._qt_window v2.window.activate() assert _QtMainWindow.current() == v2.window._qt_window # The list remembers the z-order of previous viewers ... v2.close() assert _QtMainWindow.current() == v1.window._qt_window assert _QtMainWindow._instances == [v1.window._qt_window] # and when none are left, Viewer.current() becomes None again v1.close() assert _QtMainWindow._instances == [] assert _QtMainWindow.current() is None def test_set_geometry(make_napari_viewer): viewer = make_napari_viewer() values = (70, 70, 1000, 700) viewer.window.set_geometry(*values) assert viewer.window.geometry() == values @patch.object(Window, "_update_theme_no_event") @patch.object(Window, "_remove_theme") @patch.object(Window, "_add_theme") def test_update_theme( mock_add_theme, mock_remove_theme, mock_update_theme_no_event, make_napari_viewer, ): viewer = make_napari_viewer() blue = get_theme("dark", False) blue.id = "blue" register_theme("blue", blue, "test") # triggered when theme was added mock_add_theme.assert_called() mock_remove_theme.assert_not_called() unregister_theme("blue") # triggered when theme was removed mock_remove_theme.assert_called() mock_update_theme_no_event.assert_not_called() viewer.theme = "light" theme = _themes["light"] theme.icon = "#FF0000" mock_update_theme_no_event.assert_called() def test_lazy_console(make_napari_viewer): v = make_napari_viewer() assert v.window._qt_viewer._console is None v.update_console({"test": "test"}) assert v.window._qt_viewer._console is None @pytest.mark.skipif( platform.system() == "Darwin", reason="Cannot control menu bar on MacOS" ) def test_menubar_shortcut(make_napari_viewer): v = make_napari_viewer() v.show() assert v.window.main_menu.isVisible() assert not v.window._main_menu_shortcut.isEnabled() v.window._toggle_menubar_visible() assert not v.window.main_menu.isVisible() assert v.window._main_menu_shortcut.isEnabled() napari-0.5.0a1/napari/_qt/_tests/test_sigint_interupt.py000066400000000000000000000015131437041365600234170ustar00rootroot00000000000000import os import pytest from qtpy.QtCore import QTimer from napari._qt.utils import _maybe_allow_interrupt @pytest.fixture def platform_simulate_ctrl_c(): import signal from functools import partial if hasattr(signal, "CTRL_C_EVENT"): win32api = pytest.importorskip('win32api') return partial(win32api.GenerateConsoleCtrlEvent, 0, 0) else: # we're not on windows return partial(os.kill, os.getpid(), signal.SIGINT) @pytest.mark.skipif(os.name != "Windows", reason="Windows specific") def test_sigint(qapp, platform_simulate_ctrl_c, make_napari_viewer): def fire_signal(): platform_simulate_ctrl_c() make_napari_viewer() QTimer.singleShot(100, fire_signal) with pytest.raises(KeyboardInterrupt): with _maybe_allow_interrupt(qapp): qapp.exec_() napari-0.5.0a1/napari/_qt/_tests/test_threading_progress.py000066400000000000000000000066151437041365600240710ustar00rootroot00000000000000import pytest from napari._qt import qthreading from napari._qt.widgets.qt_progress_bar import QtLabeledProgressBar pytest.importorskip( 'qtpy', reason='Cannot test threading progress without qtpy.' ) def test_worker_with_progress(qtbot): test_val = [0] def func(): yield 1 yield 1 def test_yield(v): test_val[0] += 1 thread_func = qthreading.thread_worker( func, connect={'yielded': test_yield}, progress={'total': 2}, start_thread=False, ) worker = thread_func() with qtbot.waitSignal(worker.yielded): worker.start() assert worker.pbar.n == test_val[0] def test_function_worker_nonzero_total_warns(): def not_a_generator(): return with pytest.warns(RuntimeWarning): thread_func = qthreading.thread_worker( not_a_generator, progress={'total': 2}, start_thread=False, ) thread_func() def test_worker_may_exceed_total(qtbot): test_val = [0] def func(): yield 1 yield 1 def test_yield(v): test_val[0] += 1 if test_val[0] < 2: assert worker.pbar.n == test_val[0] else: assert worker.pbar.total == 0 thread_func = qthreading.thread_worker( func, progress={'total': 1}, start_thread=False, ) worker = thread_func() worker.yielded.connect(test_yield) with qtbot.waitSignal(worker.yielded) and qtbot.waitSignal( worker.finished ): worker.start() assert test_val[0] == 2 def test_generator_worker_with_description(): def func(): yield 1 thread_func = qthreading.thread_worker( func, progress={'total': 1, 'desc': 'custom'}, start_thread=False, ) worker = thread_func() assert worker.pbar.desc == 'custom' def test_function_worker_with_description(): def func(): for _ in range(10): pass thread_func = qthreading.thread_worker( func, progress={'total': 0, 'desc': 'custom'}, start_thread=False, ) worker = thread_func() assert worker.pbar.desc == 'custom' def test_generator_worker_with_no_total(): def func(): yield 1 thread_func = qthreading.thread_worker( func, progress=True, start_thread=False, ) worker = thread_func() assert worker.pbar.total == 0 def test_function_worker_with_no_total(): def func(): for _ in range(10): pass thread_func = qthreading.thread_worker( func, progress=True, start_thread=False, ) worker = thread_func() assert worker.pbar.total == 0 def test_function_worker_0_total(): def func(): for _ in range(10): pass thread_func = qthreading.thread_worker( func, progress={'total': 0}, start_thread=False, ) worker = thread_func() assert worker.pbar.total == 0 def test_unstarted_worker_no_widget(make_napari_viewer): viewer = make_napari_viewer() def func(): for _ in range(5): yield thread_func = qthreading.thread_worker( func, progress={'total': 5}, start_thread=False, ) thread_func() assert not bool( viewer.window._qt_viewer.window()._activity_dialog.findChildren( QtLabeledProgressBar ) ) napari-0.5.0a1/napari/_qt/code_syntax_highlight.py000066400000000000000000000053231437041365600222020ustar00rootroot00000000000000from pygments import highlight from pygments.formatter import Formatter from pygments.lexers import get_lexer_by_name from qtpy import QtGui # inspired by https://github.com/Vector35/snippets/blob/master/QCodeEditor.py (MIT license) and # https://pygments.org/docs/formatterdevelopment/#html-3-2-formatter def get_text_char_format(style): """ Return a QTextCharFormat with the given attributes. https://pygments.org/docs/formatterdevelopment/#html-3-2-formatter """ text_char_format = QtGui.QTextCharFormat() try: text_char_format.setFontFamilies(["monospace"]) except AttributeError: text_char_format.setFontFamily( "monospace" ) # backward compatibility for pyqt5 5.12.3 if style.get('color'): text_char_format.setForeground(QtGui.QColor(f"#{style['color']}")) if style.get('bgcolor'): text_char_format.setBackground(QtGui.QColor(style['bgcolor'])) if style.get('bold'): text_char_format.setFontWeight(QtGui.QFont.Bold) if style.get('italic'): text_char_format.setFontItalic(True) if style.get("underline"): text_char_format.setFontUnderline(True) # TODO find if it is possible to support border style. return text_char_format class QFormatter(Formatter): def __init__(self, **kwargs) -> None: super().__init__(**kwargs) self.data = [] self._style = { name: get_text_char_format(style) for name, style in self.style } def format(self, tokensource, outfile): """ `outfile` is argument from parent class, but in Qt we do not produce string output, but QTextCharFormat, so it needs to be collected using `self.data`. """ self.data = [] for token, value in tokensource: self.data.extend( [ self._style[token], ] * len(value) ) class Pylighter(QtGui.QSyntaxHighlighter): def __init__(self, parent, lang, theme) -> None: super().__init__(parent) self.formatter = QFormatter(style=theme) self.lexer = get_lexer_by_name(lang) def highlightBlock(self, text): cb = self.currentBlock() p = cb.position() text = self.document().toPlainText() + '\n' highlight(text, self.lexer, self.formatter) # dirty, dirty hack # The core problem is that pygemnts by default use string streams, # that will not handle QTextCharFormat, so wee need use `data` property to work around this. for i in range(len(text)): try: self.setFormat(i, 1, self.formatter.data[p + i]) except IndexError: pass napari-0.5.0a1/napari/_qt/containers/000077500000000000000000000000001437041365600174235ustar00rootroot00000000000000napari-0.5.0a1/napari/_qt/containers/__init__.py000066400000000000000000000011521437041365600215330ustar00rootroot00000000000000from napari._qt.containers._factory import create_model, create_view from napari._qt.containers.qt_layer_list import QtLayerList from napari._qt.containers.qt_layer_model import QtLayerListModel from napari._qt.containers.qt_list_model import QtListModel from napari._qt.containers.qt_list_view import QtListView from napari._qt.containers.qt_tree_model import QtNodeTreeModel from napari._qt.containers.qt_tree_view import QtNodeTreeView __all__ = [ 'create_model', 'create_view', 'QtLayerList', 'QtLayerListModel', 'QtListModel', 'QtListView', 'QtNodeTreeModel', 'QtNodeTreeView', ] napari-0.5.0a1/napari/_qt/containers/_base_item_model.py000066400000000000000000000257261437041365600232600ustar00rootroot00000000000000from __future__ import annotations from collections.abc import MutableSequence from typing import TYPE_CHECKING, Any, Generic, Tuple, TypeVar, Union from qtpy.QtCore import QAbstractItemModel, QModelIndex, Qt from napari.utils.events import disconnect_events from napari.utils.events.containers import SelectableEventedList from napari.utils.translations import trans if TYPE_CHECKING: from qtpy.QtWidgets import QWidget ItemType = TypeVar("ItemType") ItemRole = Qt.UserRole SortRole = Qt.UserRole + 1 _BASE_FLAGS = ( Qt.ItemFlag.ItemIsSelectable | Qt.ItemFlag.ItemIsEditable | Qt.ItemFlag.ItemIsUserCheckable | Qt.ItemFlag.ItemIsDragEnabled | Qt.ItemFlag.ItemIsEnabled ) class _BaseEventedItemModel(QAbstractItemModel, Generic[ItemType]): """A QAbstractItemModel desigend to work with `SelectableEventedList`. :class:`~napari.utils.events.SelectableEventedList` is our pure python model of a mutable sequence that supports the concept of "currently selected/active items". It emits events when the list is altered (e.g., by appending, inserting, removing items), or when the selection model is altered. This class is an adapter between that interface and Qt's `QAbstractItemModel` interface. It allows python users to interact with the list in the "usual" python ways, updating any Qt Views that may be connected, and also updates the python list object if any GUI events occur in the view. For a "plain" (flat) list, use the :class:`napari._qt.containers.QtListModel` subclass. For a nested list-of-lists using the Group/Node classes, use the :class:`napari._qt.containers.QtNodeTreeModel` subclass. For convenience, the :func:`napari._qt.containers.create_model` factory function will return the appropriate `_BaseEventedItemModel` instance given a python `EventedList` object. .. note:: In most cases, if you want a "GUI widget" to go along with an ``EventedList`` object, it will not be necessary to instantiate the ``EventedItemModel`` directly. Instead, use one of the :class:`napari._qt.containers.QtListView` or :class:`napari._qt.containers.QtNodeTreeView` views, or the :func:`napari._qt.containers.create_view` factory function. Key concepts and references: - Qt `Model/View Programming `_ - Qt `Model Subclassing Reference `_ - `Model Index `_ - `Simple Tree Model Example `_ """ _root: SelectableEventedList[ItemType] # ########## Reimplemented Public Qt Functions ################## def __init__( self, root: SelectableEventedList[ItemType], parent: QWidget = None ) -> None: super().__init__(parent=parent) self.setRoot(root) def parent(self, index): """Return the parent of the model item with the given ``index``. (The parent in a basic list is always the root, Tree models will need to reimplement) """ return QModelIndex() def data(self, index: QModelIndex, role: Qt.ItemDataRole) -> Any: """Returns data stored under `role` for the item at `index`. A given `QModelIndex` can store multiple types of data, each with its own "ItemDataRole". ItemType-specific subclasses will likely want to customize this method (and likely `setData` as well) for different data roles. see: https://doc.qt.io/qt-5/qt.html#ItemDataRole-enum """ if role == Qt.DisplayRole: return str(self.getItem(index)) if role == ItemRole: return self.getItem(index) if role == SortRole: return index.row() return None def flags(self, index: QModelIndex) -> Qt.ItemFlags: """Returns the item flags for the given `index`. This describes the properties of a given item in the model. We set them to be editable, checkable, dragable, droppable, etc... If index is not a list, we additionally set `Qt.ItemNeverHasChildren` (for optimization). Editable models must return a value containing `Qt.ItemIsEditable`. See Qt.ItemFlags https://doc.qt.io/qt-5/qt.html#ItemFlag-enum """ if not index.isValid() or index.model() is not self: # we allow drops outside the items return Qt.ItemFlag.ItemIsDropEnabled if isinstance(self.getItem(index), MutableSequence): return _BASE_FLAGS | Qt.ItemFlag.ItemIsDropEnabled return _BASE_FLAGS | Qt.ItemFlag.ItemNeverHasChildren def columnCount(self, parent: QModelIndex) -> int: """Return the number of columns for the children of the given `parent`. In a list view, and most tree views, the number of columns is always 1. """ return 1 def rowCount(self, parent: QModelIndex = None) -> int: """Returns the number of rows under the given parent. When the parent is valid it means that rowCount is returning the number of children of parent. """ if parent is None: parent = QModelIndex() try: return len(self.getItem(parent)) except TypeError: return 0 def index( self, row: int, column: int = 0, parent: QModelIndex = None ) -> QModelIndex: """Return a QModelIndex for item at `row`, `column` and `parent`.""" # NOTE: the use of `self.createIndex(row, col, object)`` will create a # model index that stores a pointer to the object, which can be # retrieved later with index.internalPointer(). That's convenient and # performant, and very important tree structures, but it causes a bug # if integers (or perhaps values that get garbage collected?) are in # the list, because `createIndex` is an overloaded function and # `self.createIndex(row, col, )` will assume that the third # argument *is* the id of the object (not the object itself). This # will then cause a segfault if `index.internalPointer()` is used # later. # so we need to either: # 1. refuse store integers in this model # 2. never store the object (and incur the penalty of # self.getItem(idx) each time you want to get the value of an idx) # 3. Have special treatment when we encounter integers in the model # 4. Wrap every object in *another* object (which is basically what # Qt does with QAbstractItem)... ugh. # # Unfortunately, all of those come at a cost... as this is a very # frequently called function :/ if parent is None: parent = QModelIndex() return ( self.createIndex(row, column, self.getItem(parent)[row]) if self.hasIndex(row, column, parent) else QModelIndex() # instead of index error, Qt wants null index ) def supportedDropActions(self) -> Qt.DropActions: """Returns the drop actions supported by this model. The default implementation returns `Qt.CopyAction`. We re-implement to support only `Qt.MoveAction`. See also dropMimeData(), which must handle each supported drop action type. """ return Qt.MoveAction # ###### Non-Qt methods added for SelectableEventedList Model ############ def setRoot(self, root: SelectableEventedList[ItemType]): """Call during __init__, to set the python model and connections""" if not isinstance(root, SelectableEventedList): raise TypeError( trans._( "root must be an instance of {class_name}", deferred=True, class_name=SelectableEventedList, ) ) current_root = getattr(self, "_root", None) if root is current_root: return if current_root is not None: # we're changing roots... disconnect previous root disconnect_events(self._root.events, self) self._root = root self._root.events.removing.connect(self._on_begin_removing) self._root.events.removed.connect(self._on_end_remove) self._root.events.inserting.connect(self._on_begin_inserting) self._root.events.inserted.connect(self._on_end_insert) self._root.events.moving.connect(self._on_begin_moving) self._root.events.moved.connect(self._on_end_move) self._root.events.connect(self._process_event) def _split_nested_index( self, nested_index: Union[int, Tuple[int, ...]] ) -> Tuple[QModelIndex, int]: """Return (parent_index, row) for a given index.""" if isinstance(nested_index, int): return QModelIndex(), nested_index # Tuple indexes are used in NestableEventedList, so we support them # here so that subclasses needn't reimplmenet our _on_begin_* methods par = QModelIndex() *_p, idx = nested_index for i in _p: par = self.index(i, 0, par) return par, idx def _on_begin_inserting(self, event): """Begins a row insertion operation. See Qt documentation: https://doc.qt.io/qt-5/qabstractitemmodel.html#beginInsertRows """ par, idx = self._split_nested_index(event.index) self.beginInsertRows(par, idx, idx) def _on_end_insert(self): """Must be called after insert operation to update model.""" self.endInsertRows() def _on_begin_removing(self, event): """Begins a row removal operation. See Qt documentation: https://doc.qt.io/qt-5/qabstractitemmodel.html#beginRemoveRows """ par, idx = self._split_nested_index(event.index) self.beginRemoveRows(par, idx, idx) def _on_end_remove(self): """Must be called after remove operation to update model.""" self.endRemoveRows() def _on_begin_moving(self, event): """Begins a row move operation. See Qt documentation: https://doc.qt.io/qt-5/qabstractitemmodel.html#beginMoveRows """ src_par, src_idx = self._split_nested_index(event.index) dest_par, dest_idx = self._split_nested_index(event.new_index) self.beginMoveRows(src_par, src_idx, src_idx, dest_par, dest_idx) def _on_end_move(self): """Must be called after move operation to update model.""" self.endMoveRows() def getItem(self, index: QModelIndex) -> ItemType: """Return python object for a given `QModelIndex`. An invalid `QModelIndex` will return the root object. """ return self._root[index.row()] if index.isValid() else self._root def _process_event(self, event): # for subclasses to handle ItemType-specific data pass napari-0.5.0a1/napari/_qt/containers/_base_item_view.py000066400000000000000000000124031437041365600231160ustar00rootroot00000000000000from __future__ import annotations from itertools import chain, repeat from typing import TYPE_CHECKING, Generic, TypeVar from qtpy.QtCore import QItemSelection, QModelIndex, Qt from qtpy.QtWidgets import QAbstractItemView from napari._qt.containers._base_item_model import ItemRole from napari._qt.containers._factory import create_model ItemType = TypeVar("ItemType") if TYPE_CHECKING: from qtpy.QtCore import QAbstractItemModel from qtpy.QtGui import QKeyEvent from napari._qt.containers._base_item_model import _BaseEventedItemModel from napari.utils.events import Event from napari.utils.events.containers import SelectableEventedList class _BaseEventedItemView(Generic[ItemType]): """A QAbstractItemView mixin desigend to work with `SelectableEventedList`. :class:`~napari.utils.events.SelectableEventedList` is our pure python model of a mutable sequence that supports the concept of "currently selected/active items". It emits events when the list is altered (e.g., by appending, inserting, removing items), or when the selection model is altered. This class is an adapter between that interface and Qt's `QAbstractItemView` interface (see `Qt Model/View Programming `_). It allows python users to interact with the list in the "usual" python ways, while updating any Qt Views that may be connected, and also updates the python list object if any GUI events occur in the view. For a "plain" (flat) list, use the :class:`napari._qt.containers.QtListView` subclass. For a nested list-of-lists using the Group/Node classes, use the :class:`napari._qt.containers.QtNodeTreeView` subclass. For convenience, the :func:`napari._qt.containers.create_view` factory function will return the appropriate `_BaseEventedItemView` instance given a python `EventedList` object. """ # ########## Reimplemented Public Qt Functions ################## def model(self) -> _BaseEventedItemModel[ItemType]: # for type hints return super().model() def keyPressEvent(self, e: QKeyEvent) -> None: """Delete items with delete key.""" if e.key() in (Qt.Key.Key_Backspace, Qt.Key.Key_Delete): self._root.remove_selected() return return super().keyPressEvent(e) def currentChanged( self: QAbstractItemView, current: QModelIndex, previous: QModelIndex ): """The Qt current item has changed. Update the python model.""" self._root.selection._current = current.data(ItemRole) return super().currentChanged(current, previous) def selectionChanged( self: QAbstractItemView, selected: QItemSelection, deselected: QItemSelection, ): """The Qt Selection has changed. Update the python model.""" sel = {i.data(ItemRole) for i in selected.indexes()} desel = {i.data(ItemRole) for i in deselected.indexes()} if not self._root.selection.events.changed._emitting: self._root.selection.update(sel) self._root.selection.difference_update(desel) return super().selectionChanged(selected, deselected) # ###### Non-Qt methods added for SelectableEventedList Model ############ def setRoot(self, root: SelectableEventedList[ItemType]): """Call during __init__, to set the python model.""" self._root = root self.setModel(create_model(root, self)) # connect selection events root.selection.events.changed.connect(self._on_py_selection_change) root.selection.events._current.connect(self._on_py_current_change) self._sync_selection_models() def _on_py_current_change(self, event: Event): """The python model current item has changed. Update the Qt view.""" sm = self.selectionModel() if not event.value: sm.clearCurrentIndex() else: idx = index_of(self.model(), event.value) sm.setCurrentIndex(idx, sm.SelectionFlag.Current) def _on_py_selection_change(self, event: Event): """The python model selection has changed. Update the Qt view.""" sm = self.selectionModel() for is_selected, idx in chain( zip(repeat(sm.SelectionFlag.Select), event.added), zip(repeat(sm.SelectionFlag.Deselect), event.removed), ): model_idx = index_of(self.model(), idx) if model_idx.isValid(): sm.select(model_idx, is_selected) def _sync_selection_models(self): """Clear and re-sync the Qt selection view from the python selection.""" sel_model = self.selectionModel() selection = QItemSelection() for i in self._root.selection: idx = index_of(self.model(), i) selection.select(idx, idx) sel_model.select(selection, sel_model.SelectionFlag.ClearAndSelect) def index_of(model: QAbstractItemModel, obj: ItemType) -> QModelIndex: """Find the `QModelIndex` for a given object in the model.""" fl = Qt.MatchFlag.MatchExactly | Qt.MatchFlag.MatchRecursive hits = model.match( model.index(0, 0, QModelIndex()), Qt.ItemDataRole.UserRole, obj, hits=1, flags=fl, ) return hits[0] if hits else QModelIndex() napari-0.5.0a1/napari/_qt/containers/_factory.py000066400000000000000000000043771437041365600216160ustar00rootroot00000000000000from __future__ import annotations from typing import TYPE_CHECKING, Union from napari.components.layerlist import LayerList from napari.utils.events import SelectableEventedList from napari.utils.translations import trans from napari.utils.tree import Group if TYPE_CHECKING: from qtpy.QtWidgets import QWidget def create_view( obj: Union[SelectableEventedList, Group], parent: QWidget = None ): """Create a `QtListView`, or `QtNodeTreeView` for `obj`. Parameters ---------- obj : SelectableEventedList or Group The python object for which to creat a QtView. parent : QWidget, optional Optional parent widget, by default None Returns ------- Union[QtListView, QtNodeTreeView] A view instance appropriate for `obj`. """ from napari._qt.containers import QtLayerList, QtListView, QtNodeTreeView if isinstance(obj, LayerList): return QtLayerList(obj, parent=parent) if isinstance(obj, Group): return QtNodeTreeView(obj, parent=parent) if isinstance(obj, SelectableEventedList): return QtListView(obj, parent=parent) raise TypeError( trans._( "Cannot create Qt view for obj: {obj}", deferred=True, obj=obj, ) ) def create_model( obj: Union[SelectableEventedList, Group], parent: QWidget = None ): """Create a `QtListModel`, or `QtNodeTreeModel` for `obj`. Parameters ---------- obj : SelectableEventedList or Group The python object for which to creat a QtView. parent : QWidget, optional Optional parent widget, by default None Returns ------- Union[QtListModel, QtNodeTreeModel] A model instance appropriate for `obj`. """ from napari._qt.containers import ( QtLayerListModel, QtListModel, QtNodeTreeModel, ) if isinstance(obj, LayerList): return QtLayerListModel(obj, parent=parent) if isinstance(obj, Group): return QtNodeTreeModel(obj, parent=parent) if isinstance(obj, SelectableEventedList): return QtListModel(obj, parent=parent) raise TypeError( trans._( "Cannot create Qt model for obj: {obj}", deferred=True, obj=obj, ) ) napari-0.5.0a1/napari/_qt/containers/_layer_delegate.py000066400000000000000000000212221437041365600231010ustar00rootroot00000000000000""" General rendering flow: 1. The List/Tree view needs to display or edit an index in the model... 2. It gets the ``itemDelegate`` a. A custom delegate can be set with ``setItemDelegate`` b. ``QStyledItemDelegate`` is the default delegate for all Qt item views, and is installed upon them when they are created. 3. ``itemDelegate.paint`` is called on the index being displayed 4. Each index in the model has various data elements (i.e. name, image, etc..), each of which has a "data role". A model should return the appropriate data for each role by reimplementing ``QAbstractItemModel.data``. a. `QStyledItemDelegate` implements display and editing for the most common datatypes expected by users, including booleans, integers, and strings. b. If the delegate does not support painting of the data types you need or you want to customize the drawing of items, you need to subclass ``QStyledItemDelegate``, and reimplement ``paint()`` and possibly ``sizeHint()``. c. When reimplementing ``paint()``, one typically handles the datatypes they would like to draw and uses the superclass implementation for other types. 5. The default implementation of ``QStyledItemDelegate.paint`` paints the item using the view's ``QStyle`` (which is, by default, an OS specific style... but see ``QCommonStyle`` for a generic implementation) a. It is also possible to override the view's style, using either a subclass of ``QCommonStyle``, for a platform-independent look and feel, or ``QProxyStyle``, which let's you override only certain stylistic elements on any platform, falling back to the system default otherwise. b. ``QStyle`` paints various elements using methods like ``drawPrimitive`` and ``drawControl``. These can be overridden for very fine control. 6. It is hard to use stylesheets with custom ``QStyles``... but it's possible to style sub-controls in ``QAbstractItemView`` (such as ``QTreeView``): https://doc.qt.io/qt-5/stylesheet-reference.html#list-of-sub-controls """ from __future__ import annotations from typing import TYPE_CHECKING from qtpy.QtCore import QPoint, QSize, Qt from qtpy.QtGui import QMouseEvent, QPixmap from qtpy.QtWidgets import QStyledItemDelegate from napari._app_model.constants import MenuId from napari._app_model.context import get_context from napari._qt._qapp_model import build_qmodel_menu from napari._qt.containers._base_item_model import ItemRole from napari._qt.containers.qt_layer_model import ThumbnailRole from napari._qt.qt_resources import QColoredSVGIcon if TYPE_CHECKING: from qtpy import QtCore from qtpy.QtGui import QPainter from qtpy.QtWidgets import QStyleOptionViewItem, QWidget from napari.components.layerlist import LayerList class LayerDelegate(QStyledItemDelegate): """A QItemDelegate specialized for painting Layer objects. In Qt's `Model/View architecture `_. A *delegate* is an object that controls the visual rendering (and editing widgets) of an item in a view. For more, see: https://doc.qt.io/qt-5/model-view-programming.html#delegate-classes This class provides the logic required to paint a Layer item in the :class:`napari._qt.containers.QtLayerList`. The `QStyledItemDelegate` super-class provides most of the logic (including display/editing of the layer name, a visibility checkbox, and an icon for the layer type). This subclass provides additional logic for drawing the layer thumbnail, picking the appropriate icon for the layer, and some additional style/UX issues. """ def paint( self, painter: QPainter, option: QStyleOptionViewItem, index: QtCore.QModelIndex, ): """Paint the item in the model at `index`.""" # update the icon based on layer type self.get_layer_icon(option, index) # paint the standard itemView (includes name, icon, and vis. checkbox) super().paint(painter, option, index) # paint the thumbnail self._paint_thumbnail(painter, option, index) def get_layer_icon( self, option: QStyleOptionViewItem, index: QtCore.QModelIndex ): """Add the appropriate QIcon to the item based on the layer type.""" layer = index.data(ItemRole) if layer is None: return if hasattr(layer, 'is_group') and layer.is_group(): # for layer trees expanded = option.widget.isExpanded(index) icon_name = 'folder-open' if expanded else 'folder' else: icon_name = f'new_{layer._type_string}' try: icon = QColoredSVGIcon.from_resources(icon_name) except ValueError: return # guessing theme rather than passing it through. bg = option.palette.color(option.palette.ColorRole.Window).red() option.icon = icon.colored(theme='dark' if bg < 128 else 'light') option.decorationSize = QSize(18, 18) option.decorationPosition = ( option.Position.Right ) # put icon on the right option.features |= option.ViewItemFeature.HasDecoration def _paint_thumbnail(self, painter, option, index): """paint the layer thumbnail.""" # paint the thumbnail # MAGICNUMBER: numbers from the margin applied in the stylesheet to # QtLayerTreeView::item thumb_rect = option.rect.translated(-2, 2) h = index.data(Qt.ItemDataRole.SizeHintRole).height() - 4 thumb_rect.setWidth(h) thumb_rect.setHeight(h) image = index.data(ThumbnailRole) painter.drawPixmap(thumb_rect, QPixmap.fromImage(image)) def createEditor( self, parent: QWidget, option: QStyleOptionViewItem, index: QtCore.QModelIndex, ) -> QWidget: """User has double clicked on layer name.""" # necessary for geometry, otherwise editor takes up full width. self.get_layer_icon(option, index) editor = super().createEditor(parent, option, index) # make sure editor has same alignment as the display name editor.setAlignment( Qt.Alignment(index.data(Qt.ItemDataRole.TextAlignmentRole)) ) return editor def editorEvent( self, event: QtCore.QEvent, model: QtCore.QAbstractItemModel, option: QStyleOptionViewItem, index: QtCore.QModelIndex, ) -> bool: """Called when an event has occured in the editor. This can be used to customize how the delegate handles mouse/key events """ if ( event.type() == QMouseEvent.MouseButtonRelease and event.button() == Qt.MouseButton.RightButton ): pnt = ( event.globalPosition().toPoint() if hasattr(event, "globalPosition") else event.globalPos() ) self.show_context_menu(index, model, pnt, option.widget) # if the user clicks quickly on the visibility checkbox, we *don't* # want it to be interpreted as a double-click. We want the visibilty # to simply be toggled. if event.type() == QMouseEvent.MouseButtonDblClick: self.initStyleOption(option, index) style = option.widget.style() check_rect = style.subElementRect( style.SubElement.SE_ItemViewItemCheckIndicator, option, option.widget, ) if check_rect.contains(event.pos()): cur_state = index.data(Qt.ItemDataRole.CheckStateRole) if model.flags(index) & Qt.ItemFlag.ItemIsUserTristate: state = Qt.CheckState((cur_state + 1) % 3) else: state = ( Qt.CheckState.Unchecked if cur_state else Qt.CheckState.Checked ) return model.setData( index, state, Qt.ItemDataRole.CheckStateRole ) # refer all other events to the QStyledItemDelegate return super().editorEvent(event, model, option, index) def show_context_menu(self, index, model, pos: QPoint, parent): """Show the layerlist context menu. To add a new item to the menu, update the _LAYER_ACTIONS dict. """ if not hasattr(self, '_context_menu'): self._context_menu = build_qmodel_menu( MenuId.LAYERLIST_CONTEXT, parent=parent ) layer_list: LayerList = model.sourceModel()._root self._context_menu.update_from_context(get_context(layer_list)) self._context_menu.exec_(pos) napari-0.5.0a1/napari/_qt/containers/_tests/000077500000000000000000000000001437041365600207245ustar00rootroot00000000000000napari-0.5.0a1/napari/_qt/containers/_tests/test_factory.py000066400000000000000000000013031437041365600240010ustar00rootroot00000000000000import pytest from napari._qt.containers import ( QtListModel, QtListView, QtNodeTreeModel, QtNodeTreeView, create_view, ) from napari.utils.events.containers import SelectableEventedList from napari.utils.tree import Group, Node class T(Node): def __init__(self, x) -> None: self.x = x @pytest.mark.parametrize( 'cls, exView, exModel', [ (SelectableEventedList, QtListView, QtListModel), (Group, QtNodeTreeView, QtNodeTreeModel), ], ) def test_factory(qtbot, cls, exView, exModel): a = cls([T(1), T(2)]) view = create_view(a) qtbot.addWidget(view) assert isinstance(view, exView) assert isinstance(view.model(), exModel) napari-0.5.0a1/napari/_qt/containers/_tests/test_qt_layer_list.py000066400000000000000000000032031437041365600252060ustar00rootroot00000000000000from typing import Tuple import numpy as np from qtpy.QtCore import QModelIndex, Qt from napari._qt.containers import QtLayerList from napari.components import LayerList from napari.layers import Image def test_set_layer_invisible_makes_item_unchecked(qtbot): view, image = make_qt_layer_list_with_layer(qtbot) assert image.visible assert check_state_at_layer_index(view, 0) == Qt.CheckState.Checked image.visible = False assert check_state_at_layer_index(view, 0) == Qt.CheckState.Unchecked def test_set_item_unchecked_makes_layer_invisible(qtbot): view, image = make_qt_layer_list_with_layer(qtbot) assert check_state_at_layer_index(view, 0) == Qt.CheckState.Checked assert image.visible view.model().setData( layer_to_model_index(view, 0), Qt.CheckState.Unchecked, Qt.ItemDataRole.CheckStateRole, ) assert not image.visible def make_qt_layer_list_with_layer(qtbot) -> Tuple[QtLayerList, Image]: image = Image(np.zeros((4, 3))) layers = LayerList([image]) view = QtLayerList(layers) qtbot.addWidget(view) return view, image def layer_to_model_index(view: QtLayerList, layer_index: int) -> QModelIndex: return view.model().index(layer_index, 0, view.rootIndex()) def check_state_at_layer_index( view: QtLayerList, layer_index: int ) -> Qt.CheckState: model_index = layer_to_model_index(view, layer_index) value = view.model().data(model_index, Qt.ItemDataRole.CheckStateRole) # The data method returns integer value of the enum in some cases, so # ensure it has the enum type for more explicit assertions. return Qt.CheckState(value) napari-0.5.0a1/napari/_qt/containers/_tests/test_qt_list.py000066400000000000000000000071011437041365600240130ustar00rootroot00000000000000from unittest.mock import Mock import pytest from qtpy.QtCore import QEvent, QModelIndex, Qt from qtpy.QtGui import QKeyEvent from napari._qt.containers import QtListModel, QtListView from napari.utils.events._tests.test_evented_list import BASIC_INDICES from napari.utils.events.containers import SelectableEventedList class T: def __init__(self, name) -> None: self.name = name def __str__(self): return str(self.name) def __hash__(self): return id(self) def __eq__(self, o: object) -> bool: return self.name == o def test_list_model(): root: SelectableEventedList[str] = SelectableEventedList('abcdef') model = QtListModel(root) assert all( model.data(model.index(i), Qt.UserRole) == letter for i, letter in enumerate('abcdef') ) assert all( model.data(model.index(i), Qt.DisplayRole) == letter for i, letter in enumerate('abcdef') ) # unknown data role assert not any(model.data(model.index(i), Qt.FontRole) for i in range(5)) assert model.flags(QModelIndex()) & Qt.ItemIsDropEnabled assert not (model.flags(model.index(1)) & Qt.ItemIsDropEnabled) with pytest.raises(TypeError): model.setRoot('asdf') # smoke test that we can change the root model. model.setRoot(SelectableEventedList('zysv')) def test_list_view(qtbot): root: SelectableEventedList[T] = SelectableEventedList(map(T, range(5))) root.selection.clear() assert not root.selection view = QtListView(root) qmodel = view.model() qsel = view.selectionModel() qtbot.addWidget(view) # update selection in python _selection = {root[0], root[2]} root.selection.update(_selection) assert root[2] in root.selection # check selection in Qt idx = {qmodel.getItem(i) for i in qsel.selectedIndexes()} assert idx == _selection # clear selection in Qt qsel.clearSelection() # check selection in python assert not root.selection # update current in python root.selection._current = root[3] # check current in Qt assert root.selection._current == root[3] assert qmodel.getItem(qsel.currentIndex()) == root[3] # clear current in Qt qsel.setCurrentIndex(QModelIndex(), qsel.SelectionFlag.Current) # check current in python assert root.selection._current is None def test_list_view_keypress(qtbot): root: SelectableEventedList[T] = SelectableEventedList(map(T, range(5))) view = QtListView(root) qtbot.addWidget(view) first = root[0] root.selection = {first} assert first in root.selection # delete removes the item from the python model too view.keyPressEvent( QKeyEvent(QEvent.KeyPress, Qt.Key_Delete, Qt.NoModifier) ) assert first not in root @pytest.mark.parametrize('sources, dest, expectation', BASIC_INDICES) def test_move_multiple(sources, dest, expectation): """Test that models stay in sync with complicated moves. This uses mimeData to simulate drag/drop operations. """ root = SelectableEventedList(map(T, range(8))) root.events = Mock(wraps=root.events) assert root != expectation qt_tree = QtListModel(root) dest_mi = qt_tree.index(dest) qt_tree.dropMimeData( qt_tree.mimeData([qt_tree.index(i) for i in sources]), Qt.MoveAction, dest_mi.row(), dest_mi.column(), dest_mi.parent(), ) assert root == qt_tree._root == expectation root.events.moving.assert_called() root.events.moved.assert_called() root.events.reordered.assert_called_with(value=[T(i) for i in expectation]) napari-0.5.0a1/napari/_qt/containers/_tests/test_qt_tree.py000066400000000000000000000121641437041365600240040ustar00rootroot00000000000000import pytest from qtpy.QtCore import QModelIndex, Qt from napari._qt.containers import QtNodeTreeModel, QtNodeTreeView from napari._qt.containers._base_item_view import index_of from napari.utils.events._tests.test_evented_list import NESTED_POS_INDICES from napari.utils.tree import Group, Node @pytest.fixture def tree_model(qapp): root = Group( [ Node(name="1"), Group( [ Node(name="2"), Group([Node(name="3"), Node(name="4")], name="g2"), Node(name="5"), Node(name="6"), Node(name="7"), ], name="g1", ), Node(name="8"), Node(name="9"), ], name="root", ) return QtNodeTreeModel(root) def _recursive_make_group(lst, level=0): """Make a Tree of Group/Node objects from a nested list.""" out = [] for item in lst: if isinstance(item, list): out.append(_recursive_make_group(item, level=level + 1)) else: out.append(Node(name=str(item))) return Group(out, name=f'g{level}') def _assert_models_synced(model: Group, qt_model: QtNodeTreeModel): for item in model.traverse(): model_idx = qt_model.nestedIndex(item.index_from_root()) node = qt_model.getItem(model_idx) assert item.name == node.name def test_move_single_tree_item(tree_model): """Test moving a single item.""" root = tree_model._root assert isinstance(root, Group) _assert_models_synced(root, tree_model) root.move(0, 2) _assert_models_synced(root, tree_model) root.move(3, 1) _assert_models_synced(root, tree_model) @pytest.mark.parametrize('sources, dest, expectation', NESTED_POS_INDICES) def test_nested_move_multiple(qapp, sources, dest, expectation): """Test that models stay in sync with complicated moves. This uses mimeData to simulate drag/drop operations. """ root = _recursive_make_group([0, 1, [20, [210, 211], 22], 3, 4]) qt_tree = QtNodeTreeModel(root) model_indexes = [qt_tree.nestedIndex(i) for i in sources] mime_data = qt_tree.mimeData(model_indexes) dest_mi = qt_tree.nestedIndex(dest) qt_tree.dropMimeData( mime_data, Qt.MoveAction, dest_mi.row(), dest_mi.column(), dest_mi.parent(), ) expected = _recursive_make_group(expectation) _assert_models_synced(expected, qt_tree) def test_qt_tree_model_deletion(qapp): """Test that we can delete items from a QTreeModel""" root = _recursive_make_group([0, 1, [20, [210, 211], 22], 3, 4]) qt_tree = QtNodeTreeModel(root) _assert_models_synced(root, qt_tree) del root[2, 1] e = _recursive_make_group([0, 1, [20, 22], 3, 4]) _assert_models_synced(e, qt_tree) def test_qt_tree_model_insertion(qapp): """Test that we can append and insert items to a QTreeModel.""" root = _recursive_make_group([0, 1, [20, [210, 211], 22], 3, 4]) qt_tree = QtNodeTreeModel(root) _assert_models_synced(root, qt_tree) root[2, 1].append(Node(name='212')) e = _recursive_make_group([0, 1, [20, [210, 211, 212], 22], 3, 4]) _assert_models_synced(e, qt_tree) root.insert(-2, Node(name='9')) e = _recursive_make_group([0, 1, [20, [210, 211, 212], 22], 9, 3, 4]) _assert_models_synced(e, qt_tree) def test_find_nodes(qapp): root = _recursive_make_group([0, 1, [20, [210, 211], 22], 3, 4]) qt_tree = QtNodeTreeModel(root) _assert_models_synced(root, qt_tree) node = Node(name='212') root[2, 1].append(node) assert index_of(qt_tree, node).row() == 2 assert not index_of(qt_tree, Node(name='new node')).isValid() def test_node_tree_view(qtbot): root = _recursive_make_group([0, 1, [20, [210, 211], 22], 3, 4]) root.selection.clear() assert not root.selection view = QtNodeTreeView(root) qmodel = view.model() qsel = view.selectionModel() qtbot.addWidget(view) # update selection in python root.selection.update([root[0], root[2, 0]]) assert root[2, 0] in root.selection # check selection in Qt idx = {qmodel.getItem(i).index_from_root() for i in qsel.selectedIndexes()} assert idx == {(0,), (2, 0)} # clear selection in Qt qsel.clearSelection() # check selection in python assert not root.selection # update current in python root.selection._current = root[2, 1, 0] # check current in Qt assert root.selection._current == root[2, 1, 0] assert qmodel.getItem(qsel.currentIndex()).index_from_root() == (2, 1, 0) # clear current in Qt qsel.setCurrentIndex(QModelIndex(), qsel.SelectionFlag.Current) # check current in python assert root.selection._current is None def test_flags(tree_model): """Some sanity checks on retrieving flags for nested items""" assert not tree_model.hasIndex(5, 0, tree_model.index(1)) last = tree_model._root.pop() tree_model._root[1].append(last) assert tree_model.hasIndex(5, 0, tree_model.index(1)) idx = tree_model.index(5, 0, tree_model.index(1)) assert bool(tree_model.flags(idx) & Qt.ItemFlag.ItemIsEnabled) napari-0.5.0a1/napari/_qt/containers/qt_layer_list.py000066400000000000000000000042351437041365600226540ustar00rootroot00000000000000from __future__ import annotations from typing import TYPE_CHECKING from qtpy.QtCore import QSortFilterProxyModel, Qt from napari._qt.containers._base_item_model import ( SortRole, _BaseEventedItemModel, ) from napari._qt.containers._layer_delegate import LayerDelegate from napari._qt.containers.qt_list_view import QtListView from napari.layers import Layer from napari.utils.translations import trans if TYPE_CHECKING: from qtpy.QtGui import QKeyEvent from qtpy.QtWidgets import QWidget from napari.components.layerlist import LayerList class ReverseProxyModel(QSortFilterProxyModel): """Proxy Model that reverses the view order of a _BaseEventedItemModel.""" def __init__(self, model: _BaseEventedItemModel) -> None: super().__init__() self.setSourceModel(model) self.setSortRole(SortRole) self.sort(0, Qt.SortOrder.DescendingOrder) def dropMimeData(self, data, action, destRow, col, parent): """Handle destination row for dropping with reversed indices.""" row = 0 if destRow == -1 else self.sourceModel().rowCount() - destRow return self.sourceModel().dropMimeData(data, action, row, col, parent) class QtLayerList(QtListView[Layer]): """QItemView subclass specialized for the LayerList. This is as mostly for targetting with QSS, applying the delegate and reversing the view with ReverseProxyModel. """ def __init__(self, root: LayerList, parent: QWidget = None) -> None: super().__init__(root, parent) self.setItemDelegate(LayerDelegate()) self.setToolTip(trans._('Layer list')) font = self.font() font.setPointSize(12) self.setFont(font) # This reverses the order of the items in the view, # so items at the end of the list are at the top. self.setModel(ReverseProxyModel(self.model())) def keyPressEvent(self, e: QKeyEvent) -> None: """Override Qt event to pass events to the viewer.""" if e.key() != Qt.Key.Key_Space: super().keyPressEvent(e) if e.key() not in (Qt.Key.Key_Backspace, Qt.Key.Key_Delete): e.ignore() # pass key events up to viewer napari-0.5.0a1/napari/_qt/containers/qt_layer_model.py000066400000000000000000000063511437041365600230020ustar00rootroot00000000000000import typing from qtpy.QtCore import QModelIndex, QSize, Qt from qtpy.QtGui import QImage from napari._qt.containers.qt_list_model import QtListModel from napari.layers import Layer ThumbnailRole = Qt.UserRole + 2 class QtLayerListModel(QtListModel[Layer]): def data(self, index: QModelIndex, role: Qt.ItemDataRole): """Return data stored under ``role`` for the item at ``index``.""" if not index.isValid(): return None layer = self.getItem(index) if role == Qt.ItemDataRole.DisplayRole: # used for item text return layer.name if role == Qt.ItemDataRole.TextAlignmentRole: # alignment of the text return Qt.AlignCenter if role == Qt.ItemDataRole.EditRole: # used to populate line edit when editing return layer.name if role == Qt.ItemDataRole.ToolTipRole: # for tooltip return layer.get_source_str() if ( role == Qt.ItemDataRole.CheckStateRole ): # the "checked" state of this item return ( Qt.CheckState.Checked if layer.visible else Qt.CheckState.Unchecked ) if role == Qt.ItemDataRole.SizeHintRole: # determines size of item return QSize(200, 34) if role == ThumbnailRole: # return the thumbnail thumbnail = layer.thumbnail return QImage( thumbnail, thumbnail.shape[1], thumbnail.shape[0], QImage.Format_RGBA8888, ) # normally you'd put the icon in DecorationRole, but we do that in the # # LayerDelegate which is aware of the theme. # if role == Qt.ItemDataRole.DecorationRole: # icon to show # pass return super().data(index, role) def setData( self, index: QModelIndex, value: typing.Any, role: int = Qt.ItemDataRole.EditRole, ) -> bool: if role == Qt.ItemDataRole.CheckStateRole: # The item model stores a Qt.CheckState enum value that can be # partially checked, but we only use the unchecked and checked # to correspond to the layer's visibility. # https://doc.qt.io/qt-5/qt.html#CheckState-enum self.getItem(index).visible = value == Qt.CheckState.Checked elif role == Qt.ItemDataRole.EditRole: self.getItem(index).name = value role = Qt.ItemDataRole.DisplayRole else: return super().setData(index, value, role=role) self.dataChanged.emit(index, index, [role]) return True def _process_event(self, event): # The model needs to emit `dataChanged` whenever data has changed # for a given index, so that views can update themselves. # Here we convert native events to the dataChanged signal. if not hasattr(event, 'index'): return role = { 'thumbnail': ThumbnailRole, 'visible': Qt.ItemDataRole.CheckStateRole, 'name': Qt.ItemDataRole.DisplayRole, }.get(event.type) roles = [role] if role is not None else [] row = self.index(event.index) self.dataChanged.emit(row, row, roles) napari-0.5.0a1/napari/_qt/containers/qt_list_model.py000066400000000000000000000063661437041365600226470ustar00rootroot00000000000000import logging import pickle from typing import List, Optional, Sequence, TypeVar from qtpy.QtCore import QMimeData, QModelIndex, Qt from napari._qt.containers._base_item_model import _BaseEventedItemModel logger = logging.getLogger(__name__) ListIndexMIMEType = "application/x-list-index" ItemType = TypeVar("ItemType") class QtListModel(_BaseEventedItemModel[ItemType]): """A QItemModel for a :class:`~napari.utils.events.SelectableEventedList`. Designed to work with :class:`~napari._qt.containers.QtListView`. See docstring of :class:`_BaseEventedItemModel` and :class:`~napari._qt.containers.QtListView` for additional background. """ def mimeTypes(self) -> List[str]: """Returns the list of allowed MIME types. When implementing drag and drop support in a custom model, if you will return data in formats other than the default internal MIME type, reimplement this function to return your list of MIME types. """ return [ListIndexMIMEType, "text/plain"] def mimeData(self, indices: List[QModelIndex]) -> Optional['QMimeData']: """Return an object containing serialized data from `indices`. If the list of indexes is empty, or there are no supported MIME types, None is returned rather than a serialized empty list. """ if not indices: return None items, indices = zip(*[(self.getItem(i), i.row()) for i in indices]) return ItemMimeData(items, indices) def dropMimeData( self, data: QMimeData, action: Qt.DropAction, destRow: int, col: int, parent: QModelIndex, ) -> bool: """Handles `data` from a drag and drop operation ending with `action`. The specified row, column and parent indicate the location of an item in the model where the operation ended. It is the responsibility of the model to complete the action at the correct location. Returns ------- bool ``True`` if the `data` and `action` were handled by the model; otherwise returns ``False``. """ if not data or action != Qt.DropAction.MoveAction: return False if not data.hasFormat(self.mimeTypes()[0]): return False if isinstance(data, ItemMimeData): moving_indices = data.indices logger.debug( "dropMimeData: indices %s ➡ %s", moving_indices, destRow, ) if len(moving_indices) == 1: return self._root.move(moving_indices[0], destRow) else: return bool(self._root.move_multiple(moving_indices, destRow)) return False class ItemMimeData(QMimeData): """An object to store list indices data during a drag operation.""" def __init__( self, items: Sequence[ItemType], indices: Sequence[int] ) -> None: super().__init__() self.items = items self.indices = tuple(sorted(indices)) if items: self.setData(ListIndexMIMEType, pickle.dumps(self.indices)) self.setText(" ".join(str(item) for item in items)) def formats(self) -> List[str]: return [ListIndexMIMEType, "text/plain"] napari-0.5.0a1/napari/_qt/containers/qt_list_view.py000066400000000000000000000030761437041365600225140ustar00rootroot00000000000000from __future__ import annotations from typing import TYPE_CHECKING, TypeVar from qtpy.QtWidgets import QListView from napari._qt.containers._base_item_view import _BaseEventedItemView from napari._qt.containers.qt_list_model import QtListModel if TYPE_CHECKING: from qtpy.QtWidgets import QWidget from napari.utils.events.containers import SelectableEventedList ItemType = TypeVar("ItemType") class QtListView(_BaseEventedItemView[ItemType], QListView): """A QListView for a :class:`~napari.utils.events.SelectableEventedList`. Designed to work with :class:`~napari._qt.containers.QtListModel`. This class is an adapter between :class:`~napari.utils.events.SelectableEventedList` and Qt's `QAbstractItemView` interface (see `Qt Model/View Programming `_). It allows python users to interact with the list in the "usual" python ways, updating any Qt Views that may be connected, and also updates the python list object if any GUI events occur in the view. See docstring of :class:`_BaseEventedItemView` for additional background. """ _root: SelectableEventedList[ItemType] def __init__( self, root: SelectableEventedList[ItemType], parent: QWidget = None ) -> None: super().__init__(parent) self.setDragDropMode(QListView.InternalMove) self.setDragDropOverwriteMode(False) self.setSelectionMode(QListView.ExtendedSelection) self.setRoot(root) def model(self) -> QtListModel[ItemType]: return super().model() napari-0.5.0a1/napari/_qt/containers/qt_tree_model.py000066400000000000000000000205561437041365600226300ustar00rootroot00000000000000import logging import pickle from typing import List, Optional, Tuple, TypeVar from qtpy.QtCore import QMimeData, QModelIndex, Qt from napari._qt.containers._base_item_model import _BaseEventedItemModel from napari.utils.translations import trans from napari.utils.tree import Group, Node logger = logging.getLogger(__name__) NodeType = TypeVar("NodeType", bound=Node) NodeMIMEType = "application/x-tree-node" class QtNodeTreeModel(_BaseEventedItemModel[NodeType]): """A QItemModel for a tree of ``Node`` and ``Group`` objects. Designed to work with :class:`napari.utils.tree.Group` and :class:`~napari._qt.containers.QtNodeTreeView`. See docstring of :class:`_BaseEventedItemModel` and :class:`~napari._qt.containers.QtNodeTreeView` for additional background. """ _root: Group[NodeType] # ########## Reimplemented Public Qt Functions ################## def data(self, index: QModelIndex, role: Qt.ItemDataRole): """Return data stored under ``role`` for the item at ``index``. A given class:`QModelIndex` can store multiple types of data, each with its own "ItemDataRole". """ item = self.getItem(index) if role == Qt.ItemDataRole.DisplayRole: return item._node_name() if role == Qt.ItemDataRole.UserRole: return self.getItem(index) return None def index( self, row: int, column: int = 0, parent: QModelIndex = None ) -> QModelIndex: """Return a QModelIndex for item at `row`, `column` and `parent`.""" # NOTE: self.createIndex(row, col, object) will create a model index # that *stores* a pointer to the object, which can be retrieved later # with index.internalPointer(). That's convenient and performant, but # it comes with a bug if integers are in the list, because # `createIndex` is overloaded and `self.createIndex(row, col, )` # will assume that the third argument *is* the id of the object (not # the object itself). This will then cause a segfault if # `index.internalPointer()` is used later. # XXX: discuss # so we need to either: # 1. refuse store integers in this model # 2. never store the object (and incur the penalty of # self.getItem(idx) each time you want to get the value of an idx) # 3. Have special treatment when we encounter integers in the model if parent is None: parent = QModelIndex() return ( self.createIndex(row, column, self.getItem(parent)[row]) if self.hasIndex(row, column, parent) else QModelIndex() # instead of index error, Qt wants null index ) def getItem(self, index: QModelIndex) -> NodeType: """Return python object for a given `QModelIndex`. An invalid `QModelIndex` will return the root object. """ if index.isValid(): item = index.internalPointer() if item is not None: return item return self._root def parent(self, index: QModelIndex) -> QModelIndex: """Return the parent of the model item with the given ``index``. If the item has no parent, an invalid QModelIndex is returned. """ if not index.isValid(): return QModelIndex() # null index parentItem = self.getItem(index).parent if parentItem is None or parentItem == self._root: return QModelIndex() # A common convention used in models that expose tree data structures # is that only items in the first column have children. So here,the # column of the returned is 0. row = parentItem.index_in_parent() or 0 return self.createIndex(row, 0, parentItem) def mimeTypes(self) -> List[str]: """Returns the list of allowed MIME types. By default, the built-in models and views use an internal MIME type: application/x-qabstractitemmodeldatalist. When implementing drag and drop support in a custom model, if you will return data in formats other than the default internal MIME type, reimplement this function to return your list of MIME types. If you reimplement this function in your custom model, you must also reimplement the member functions that call it: mimeData() and dropMimeData(). Returns ------- list of str MIME types allowed for drag & drop support """ return [NodeMIMEType, "text/plain"] def mimeData(self, indices: List[QModelIndex]) -> Optional['NodeMimeData']: """Return an object containing serialized data from `indices`. The format used to describe the encoded data is obtained from the mimeTypes() function. The implementation uses the default MIME type returned by the default implementation of mimeTypes(). If you reimplement mimeTypes() in your custom model to return more MIME types, reimplement this function to make use of them. """ # If the list of indexes is empty, or there are no supported MIME types # nullptr is returned rather than a serialized empty list. if not indices: return 0 return NodeMimeData([self.getItem(i) for i in indices]) def dropMimeData( self, data: QMimeData, action: Qt.DropAction, destRow: int, col: int, parent: QModelIndex, ) -> bool: """Handles `data` from a drag and drop operation ending with `action`. The specified row, column and parent indicate the location of an item in the model where the operation ended. It is the responsibility of the model to complete the action at the correct location. Returns ------- bool ``True`` if the `data` and `action` were handled by the model; otherwise returns ``False``. """ if not data or action != Qt.DropAction.MoveAction: return False if not data.hasFormat(self.mimeTypes()[0]): return False if isinstance(data, NodeMimeData): dest_idx = self.getItem(parent).index_from_root() dest_idx = dest_idx + (destRow,) moving_indices = data.node_indices() logger.debug( f"dropMimeData: indices {moving_indices} ➡ {dest_idx}" ) if len(moving_indices) == 1: self._root.move(moving_indices[0], dest_idx) else: self._root.move_multiple(moving_indices, dest_idx) return True return False # ###### Non-Qt methods added for Group Model ############ def setRoot(self, root: Group[NodeType]): if not isinstance(root, Group): raise TypeError( trans._( "root node must be an instance of {Group}", deferred=True, Group=Group, ) ) super().setRoot(root) def nestedIndex(self, nested_index: Tuple[int, ...]) -> QModelIndex: """Return a QModelIndex for a given ``nested_index``.""" parent = QModelIndex() if isinstance(nested_index, tuple): if not nested_index: return parent *parents, child = nested_index for i in parents: parent = self.index(i, 0, parent) elif isinstance(nested_index, int): child = nested_index else: raise TypeError( trans._( "nested_index must be an int or tuple of int.", deferred=True, ) ) return self.index(child, 0, parent) class NodeMimeData(QMimeData): """An object to store Node data during a drag operation.""" def __init__(self, nodes: Optional[List[NodeType]] = None) -> None: super().__init__() self.nodes: List[NodeType] = nodes or [] if nodes: self.setData(NodeMIMEType, pickle.dumps(self.node_indices())) self.setText(" ".join(node._node_name() for node in nodes)) def formats(self) -> List[str]: return [NodeMIMEType, "text/plain"] def node_indices(self) -> List[Tuple[int, ...]]: return [node.index_from_root() for node in self.nodes] def node_names(self) -> List[str]: return [node._node_name() for node in self.nodes] napari-0.5.0a1/napari/_qt/containers/qt_tree_view.py000066400000000000000000000045021437041365600224730ustar00rootroot00000000000000from __future__ import annotations from collections.abc import MutableSequence from typing import TYPE_CHECKING, TypeVar from qtpy.QtWidgets import QTreeView from napari._qt.containers._base_item_view import _BaseEventedItemView from napari._qt.containers.qt_tree_model import QtNodeTreeModel from napari.utils.tree import Group, Node if TYPE_CHECKING: from qtpy.QtCore import QModelIndex from qtpy.QtWidgets import QWidget NodeType = TypeVar("NodeType", bound=Node) class QtNodeTreeView(_BaseEventedItemView[NodeType], QTreeView): """A QListView for a :class:`~napari.utils.tree.Group`. Designed to work with :class:`~napari._qt.containers.QtNodeTreeModel`. This class is an adapter between :class:`~napari.utils.tree.Group` and Qt's `QAbstractItemView` interface (see `Qt Model/View Programming `_). It allows python users to interact with a list of lists in the "usual" python ways, updating any Qt Views that may be connected, and also updates the python list object if any GUI events occur in the view. See docstring of :class:`_BaseEventedItemView` for additional background. """ _root: Group[Node] def __init__(self, root: Group[Node], parent: QWidget = None) -> None: super().__init__(parent) self.setHeaderHidden(True) self.setDragDropMode(QTreeView.InternalMove) self.setDragDropOverwriteMode(False) self.setSelectionMode(QTreeView.ExtendedSelection) self.setRoot(root) def setRoot(self, root: Group[Node]): super().setRoot(root) # make tree look like a list if it contains no lists. self.model().rowsRemoved.connect(self._redecorate_root) self.model().rowsInserted.connect(self._redecorate_root) self._redecorate_root() def _redecorate_root(self, parent: QModelIndex = None, *_): """Add a branch/arrow column only if there are Groups in the root. This makes the tree fall back to looking like a simple list if there are no lists in the root level. """ if not parent or not parent.isValid(): hasgroup = any(isinstance(i, MutableSequence) for i in self._root) self.setRootIsDecorated(hasgroup) def model(self) -> QtNodeTreeModel[NodeType]: return super().model() napari-0.5.0a1/napari/_qt/dialogs/000077500000000000000000000000001437041365600167005ustar00rootroot00000000000000napari-0.5.0a1/napari/_qt/dialogs/__init__.py000066400000000000000000000000601437041365600210050ustar00rootroot00000000000000"""Custom dialogs that inherit from QDialog.""" napari-0.5.0a1/napari/_qt/dialogs/_tests/000077500000000000000000000000001437041365600202015ustar00rootroot00000000000000napari-0.5.0a1/napari/_qt/dialogs/_tests/__init__.py000066400000000000000000000000001437041365600223000ustar00rootroot00000000000000napari-0.5.0a1/napari/_qt/dialogs/_tests/test_about.py000066400000000000000000000002731437041365600227260ustar00rootroot00000000000000from napari._qt.dialogs.qt_about import QtAbout from napari._tests.utils import skip_local_popups @skip_local_popups def test_about(qtbot): wdg = QtAbout() qtbot.addWidget(wdg) napari-0.5.0a1/napari/_qt/dialogs/_tests/test_activity_dialog.py000066400000000000000000000100531437041365600247640ustar00rootroot00000000000000import os import sys from contextlib import contextmanager import pytest from napari._qt.widgets.qt_progress_bar import ( QtLabeledProgressBar, QtProgressBarGroup, ) from napari.utils import progress SHOW = bool(sys.platform == 'linux' or os.getenv("CI")) def qt_viewer_has_pbar(qt_viewer): """Returns True if the viewer has an active progress bar, else False""" return bool( qt_viewer.window._qt_viewer.window()._activity_dialog.findChildren( QtLabeledProgressBar ) ) @contextmanager def assert_pbar_added_to(viewer): """Context manager checks that progress bar is added on construction""" assert not qt_viewer_has_pbar(viewer) yield assert qt_viewer_has_pbar(viewer) def activity_button_shows_indicator(activity_dialog): """Returns True if the progress indicator is visible, else False""" return activity_dialog._toggleButton._inProgressIndicator.isVisible() def get_qt_labeled_progress_bar(prog, viewer): """Given viewer and progress, finds associated QtLabeledProgressBar""" activity_dialog = viewer.window._qt_viewer.window()._activity_dialog pbar = activity_dialog.get_pbar_from_prog(prog) return pbar def get_progress_groups(qt_viewer): """Given viewer, find all QtProgressBarGroups in activity dialog""" return qt_viewer.window()._activity_dialog.findChildren(QtProgressBarGroup) def test_activity_dialog_holds_progress(make_napari_viewer): """Progress gets added to dialog & once finished it gets removed""" viewer = make_napari_viewer(show=SHOW) with assert_pbar_added_to(viewer): r = range(100) prog = progress(r) pbar = get_qt_labeled_progress_bar(prog, viewer) assert pbar is not None assert pbar.progress is prog assert pbar.qt_progress_bar.maximum() == prog.total prog.close() assert not pbar.isVisible() def test_progress_with_context(make_napari_viewer): """Test adding/removing of progress bar with context manager""" viewer = make_napari_viewer(show=SHOW) with assert_pbar_added_to(viewer): with progress(range(100)) as prog: pbar = get_qt_labeled_progress_bar(prog, viewer) assert pbar.qt_progress_bar.maximum() == prog.total == 100 def test_closing_viewer_no_error(make_napari_viewer): """Closing viewer with active progress doesn't cause RuntimeError""" viewer = make_napari_viewer(show=SHOW) assert not qt_viewer_has_pbar(viewer) with progress(range(100)): assert qt_viewer_has_pbar(viewer) viewer.close() def test_progress_nested(make_napari_viewer): """Test nested progress bars are added with QtProgressBarGroup""" viewer = make_napari_viewer(show=SHOW) assert not qt_viewer_has_pbar(viewer) with progress(range(10)) as pbr: assert qt_viewer_has_pbar(viewer) pbr2 = progress(range(2), nest_under=pbr) prog_groups = get_progress_groups(viewer.window._qt_viewer) assert len(prog_groups) == 1 # two progress bars + separator assert prog_groups[0].layout().count() == 3 pbr2.close() assert not prog_groups[0].isVisible() @pytest.mark.skipif( not SHOW, reason='viewer needs to be shown to test indicator', ) def test_progress_indicator(make_napari_viewer): viewer = make_napari_viewer(show=SHOW) activity_dialog = viewer.window._qt_viewer.window()._activity_dialog # it's not clear why, but using the context manager here # causes test to fail, so we make the assertions explicitly assert not qt_viewer_has_pbar(viewer) with progress(range(10)): assert qt_viewer_has_pbar(viewer) assert activity_button_shows_indicator(activity_dialog) @pytest.mark.skipif( bool(sys.platform == 'linux'), reason='need to debug sefaults with set_description', ) def test_progress_set_description(make_napari_viewer): viewer = make_napari_viewer(show=SHOW) prog = progress(total=5) prog.set_description("Test") pbar = get_qt_labeled_progress_bar(prog, viewer) assert pbar.description_label.text() == "Test: " prog.close() napari-0.5.0a1/napari/_qt/dialogs/_tests/test_confirm_close_dialog.py000066400000000000000000000032731437041365600257600ustar00rootroot00000000000000from qtpy.QtWidgets import QDialog from napari._qt.dialogs.confirm_close_dialog import ConfirmCloseDialog from napari.settings import get_settings def test_create_application_close(qtbot): dialog = ConfirmCloseDialog(None, close_app=True) qtbot.addWidget(dialog) assert dialog.windowTitle() == 'Close Application?' assert get_settings().application.confirm_close_window assert dialog.close_btn.shortcut().toString() == 'Ctrl+Q' dialog.close_btn.click() assert dialog.result() == QDialog.DialogCode.Accepted assert get_settings().application.confirm_close_window def test_remove_confirmation(qtbot): dialog = ConfirmCloseDialog(None, close_app=True) dialog.do_not_ask.setChecked(True) assert get_settings().application.confirm_close_window dialog.close_btn.click() assert dialog.result() == QDialog.DialogCode.Accepted assert not get_settings().application.confirm_close_window def test_remove_confirmation_reject(qtbot): dialog = ConfirmCloseDialog(None, close_app=True) dialog.do_not_ask.setChecked(True) assert get_settings().application.confirm_close_window dialog.cancel_btn.click() assert dialog.result() == QDialog.DialogCode.Rejected assert get_settings().application.confirm_close_window def test_create_window_close(qtbot): dialog = ConfirmCloseDialog(None, close_app=False) qtbot.addWidget(dialog) assert dialog.windowTitle() == 'Close Window?' assert get_settings().application.confirm_close_window assert dialog.close_btn.shortcut().toString() == 'Ctrl+W' dialog.close_btn.click() assert dialog.result() == QDialog.DialogCode.Accepted assert get_settings().application.confirm_close_window napari-0.5.0a1/napari/_qt/dialogs/_tests/test_installer_process.py000066400000000000000000000151721437041365600253530ustar00rootroot00000000000000import re import sys import time from pathlib import Path from types import MethodType from typing import TYPE_CHECKING import pytest from qtpy.QtCore import QProcessEnvironment from napari._qt.dialogs.qt_package_installer import ( AbstractInstallerTool, CondaInstallerTool, InstallerQueue, InstallerTools, PipInstallerTool, ) if TYPE_CHECKING: from virtualenv.run import Session @pytest.fixture def tmp_virtualenv(tmp_path) -> 'Session': virtualenv = pytest.importorskip('virtualenv') cmd = [str(tmp_path), '--no-setuptools', '--no-wheel', '--activators', ''] return virtualenv.cli_run(cmd) @pytest.fixture def tmp_conda_env(tmp_path): import subprocess try: subprocess.check_output( [ CondaInstallerTool.executable(), 'create', '-yq', '-p', str(tmp_path), '--override-channels', '-c', 'conda-forge', f'python={sys.version_info.major}.{sys.version_info.minor}', ], stderr=subprocess.STDOUT, text=True, timeout=300, ) except subprocess.CalledProcessError as exc: print(exc.output) raise return tmp_path def test_pip_installer_tasks(qtbot, tmp_virtualenv: 'Session', monkeypatch): installer = InstallerQueue() monkeypatch.setattr( PipInstallerTool, "executable", lambda *a: tmp_virtualenv.creator.exe ) with qtbot.waitSignal(installer.allFinished, timeout=20000): installer.install( tool=InstallerTools.PIP, pkgs=['pip-install-test'], ) installer.install( tool=InstallerTools.PIP, pkgs=['typing-extensions'], ) job_id = installer.install( tool=InstallerTools.PIP, pkgs=['requests'], ) assert isinstance(job_id, int) installer.cancel(job_id) assert not installer.hasJobs() pkgs = 0 for pth in tmp_virtualenv.creator.libs: if (pth / 'pip_install_test').exists(): pkgs += 1 if (pth / 'typing_extensions.py').exists(): pkgs += 1 if (pth / 'requests').exists(): raise AssertionError('requests got installed') assert pkgs >= 2, 'package was not installed' with qtbot.waitSignal(installer.allFinished, timeout=10000): job_id = installer.uninstall( tool=InstallerTools.PIP, pkgs=['pip-install-test'], ) for pth in tmp_virtualenv.creator.libs: assert not ( pth / 'pip_install_test' ).exists(), 'pip_install_test still installed' assert not installer.hasJobs() def _assert_exit_code_not_zero( self, exit_code=None, exit_status=None, error=None ): errors = [] if exit_code == 0: errors.append("- 'exit_code' should have been non-zero!") if error is not None: errors.append("- 'error' should have been None!") if errors: raise AssertionError("\n".join(errors)) return self._on_process_done_original(exit_code, exit_status, error) class _NonExistingTool(AbstractInstallerTool): def executable(self): return f"this-tool-does-not-exist-{hash(time.time())}" def arguments(self): return () def environment(self, env=None): return QProcessEnvironment.systemEnvironment() def _assert_error_used(self, exit_code=None, exit_status=None, error=None): errors = [] if error is None: errors.append("- 'error' should have been populated!") if exit_code is not None: errors.append("- 'exit_code' should not have been populated!") if errors: raise AssertionError("\n".join(errors)) return self._on_process_done_original(exit_code, exit_status, error) def test_installer_failures(qtbot, tmp_virtualenv: 'Session', monkeypatch): installer = InstallerQueue() monkeypatch.setattr( PipInstallerTool, "executable", lambda *a: tmp_virtualenv.creator.exe ) # CHECK 1) Errors should trigger finished and allFinished too with qtbot.waitSignal(installer.allFinished, timeout=10000): installer.install( tool=InstallerTools.PIP, pkgs=[f'this-package-does-not-exist-{hash(time.time())}'], ) # Keep a reference before we monkey patch stuff installer._on_process_done_original = installer._on_process_done # CHECK 2) Non-existing packages should return non-zero monkeypatch.setattr( installer, "_on_process_done", MethodType(_assert_exit_code_not_zero, installer), ) with qtbot.waitSignal(installer.allFinished, timeout=10000): installer.install( tool=InstallerTools.PIP, pkgs=[f'this-package-does-not-exist-{hash(time.time())}'], ) # CHECK 3) Non-existing tools should fail to start monkeypatch.setattr( installer, "_on_process_done", MethodType(_assert_error_used, installer), ) monkeypatch.setattr(installer, "_get_tool", lambda *a: _NonExistingTool) with qtbot.waitSignal(installer.allFinished, timeout=10000): installer.install( tool=_NonExistingTool, pkgs=[f'this-package-does-not-exist-{hash(time.time())}'], ) @pytest.mark.skipif( not CondaInstallerTool.available(), reason="Conda is not available." ) def test_conda_installer(qtbot, tmp_conda_env: Path): installer = InstallerQueue() with qtbot.waitSignal(installer.allFinished, timeout=600_000): installer.install( tool=InstallerTools.CONDA, pkgs=['typing-extensions'], prefix=tmp_conda_env, ) conda_meta = tmp_conda_env / "conda-meta" glob_pat = "typing-extensions-*.json" assert not installer.hasJobs() assert list(conda_meta.glob(glob_pat)) with qtbot.waitSignal(installer.allFinished, timeout=600_000): installer.uninstall( tool=InstallerTools.CONDA, pkgs=['typing-extensions'], prefix=tmp_conda_env, ) assert not installer.hasJobs() assert not list(conda_meta.glob(glob_pat)) def test_constraints_are_in_sync(): conda_constraints = sorted(CondaInstallerTool.constraints()) pip_constraints = sorted(PipInstallerTool.constraints()) assert len(conda_constraints) == len(pip_constraints) name_re = re.compile(r"([a-z0-9_\-]+).*") for conda_constraint, pip_constraint in zip( conda_constraints, pip_constraints ): conda_name = name_re.match(conda_constraint).group(1) pip_name = name_re.match(pip_constraint).group(1) assert conda_name == pip_name napari-0.5.0a1/napari/_qt/dialogs/_tests/test_preferences_dialog.py000066400000000000000000000036321437041365600254360ustar00rootroot00000000000000import pytest from pydantic import BaseModel from qtpy.QtCore import Qt from napari._qt.dialogs.preferences_dialog import ( PreferencesDialog, QMessageBox, ) from napari.settings import NapariSettings, get_settings @pytest.fixture def pref(qtbot): dlg = PreferencesDialog() qtbot.addWidget(dlg) settings = get_settings() assert settings.appearance.theme == 'dark' dlg._settings.appearance.theme = 'light' assert get_settings().appearance.theme == 'light' yield dlg def test_prefdialog_populated(pref): subfields = filter( lambda f: isinstance(f.type_, type) and issubclass(f.type_, BaseModel), NapariSettings.__fields__.values(), ) assert pref._stack.count() == len(list(subfields)) def test_preferences_dialog_accept(qtbot, pref): with qtbot.waitSignal(pref.finished): pref.accept() assert get_settings().appearance.theme == 'light' def test_preferences_dialog_ok(qtbot, pref): with qtbot.waitSignal(pref.finished): pref._button_ok.click() assert get_settings().appearance.theme == 'light' def test_preferences_dialog_close(qtbot, pref): with qtbot.waitSignal(pref.finished): pref.close() assert get_settings().appearance.theme == 'light' def test_preferences_dialog_escape(qtbot, pref): with qtbot.waitSignal(pref.finished): qtbot.keyPress(pref, Qt.Key_Escape) assert get_settings().appearance.theme == 'light' def test_preferences_dialog_cancel(qtbot, pref): with qtbot.waitSignal(pref.finished): pref._button_cancel.click() assert get_settings().appearance.theme == 'dark' def test_preferences_dialog_restore(qtbot, pref, monkeypatch): assert get_settings().appearance.theme == 'light' monkeypatch.setattr( QMessageBox, 'question', lambda *a: QMessageBox.RestoreDefaults ) pref._restore_default_dialog() assert get_settings().appearance.theme == 'dark' napari-0.5.0a1/napari/_qt/dialogs/_tests/test_qt_modal.py000066400000000000000000000037211437041365600234150ustar00rootroot00000000000000from unittest.mock import MagicMock import pytest from qtpy.QtCore import Qt from qtpy.QtWidgets import QMainWindow, QWidget from napari._qt.dialogs.qt_modal import QtPopup class TestQtPopup: def test_show_above(self, qtbot): popup = QtPopup(None) qtbot.addWidget(popup) popup.show_above_mouse() popup.close() def test_show_right(self, qtbot): popup = QtPopup(None) qtbot.addWidget(popup) popup.show_right_of_mouse() popup.close() def test_move_to_error_no_parent(self, qtbot): popup = QtPopup(None) qtbot.add_widget(popup) with pytest.raises(ValueError): popup.move_to() @pytest.mark.parametrize("pos", ["top", "bottom", "left", "right"]) def test_move_to(self, pos, qtbot): window = QMainWindow() qtbot.addWidget(window) widget = QWidget() window.setCentralWidget(widget) popup = QtPopup(widget) popup.move_to(pos) def test_move_to_error_wrong_params(self, qtbot): window = QMainWindow() qtbot.addWidget(window) widget = QWidget() window.setCentralWidget(widget) popup = QtPopup(widget) with pytest.raises(ValueError): popup.move_to("dummy_text") with pytest.raises(ValueError): popup.move_to({}) @pytest.mark.parametrize("pos", [[10, 10, 10, 10], (15, 10, 10, 10)]) def test_move_to_cords(self, pos, qtbot): window = QMainWindow() qtbot.addWidget(window) widget = QWidget() window.setCentralWidget(widget) popup = QtPopup(widget) popup.move_to(pos) def test_click(self, qtbot, monkeypatch): popup = QtPopup(None) monkeypatch.setattr(popup, "close", MagicMock()) qtbot.addWidget(popup) qtbot.keyClick(popup, Qt.Key_8) popup.close.assert_not_called() qtbot.keyClick(popup, Qt.Key_Return) popup.close.assert_called_once() napari-0.5.0a1/napari/_qt/dialogs/_tests/test_qt_plugin_dialog.py000066400000000000000000000117311437041365600251360ustar00rootroot00000000000000from typing import Generator, Optional, Tuple import pytest from npe2 import PackageMetadata from napari._qt.dialogs import qt_plugin_dialog def _iter_napari_hub_or_pypi_plugin_info( conda_forge: bool = True, ) -> Generator[Tuple[Optional[PackageMetadata], bool], None, None]: """Mock the hub and pypi methods to collect available plugins. This will mock `napari.plugins.hub.iter_hub_plugin_info` for napari-hub, and `napari.plugins.pypi.iter_napari_plugin_info` for pypi. It will return two fake plugins that will populate the available plugins list (the bottom one). The first plugin will not be available on conda-forge so will be greyed out ("test-name-0"). The second plugin will be available on conda-forge so will be enabled ("test-name-1"). """ # This mock `base_data`` will be the same for both fake plugins. base_data = { "metadata_version": "1.0", "version": "0.1.0", "summary": "some test package", "home_page": "http://napari.org", "author": "test author", "license": "UNKNOWN", } for i in range(2): yield PackageMetadata(name=f"test-name-{i}", **base_data), bool(i) @pytest.fixture def plugin_dialog(qtbot, monkeypatch): """Fixture that provides a plugin dialog for a normal napari install.""" for method_name in ["iter_hub_plugin_info", "iter_napari_plugin_info"]: monkeypatch.setattr( qt_plugin_dialog, method_name, _iter_napari_hub_or_pypi_plugin_info, ) # This is patching `napari.utils.misc.running_as_constructor_app` function # to mock a normal napari install. monkeypatch.setattr( qt_plugin_dialog, "running_as_constructor_app", lambda: False, ) widget = qt_plugin_dialog.QtPluginDialog() widget.show() qtbot.wait(300) qtbot.add_widget(widget) return widget @pytest.fixture def plugin_dialog_constructor(qtbot, monkeypatch): """ Fixture that provides a plugin dialog for a constructor based install. """ for method_name in ["iter_hub_plugin_info", "iter_napari_plugin_info"]: monkeypatch.setattr( qt_plugin_dialog, method_name, _iter_napari_hub_or_pypi_plugin_info, ) # This is patching `napari.utils.misc.running_as_constructor_app` function # to mock a constructor based install. monkeypatch.setattr( qt_plugin_dialog, "running_as_constructor_app", lambda: True, ) widget = qt_plugin_dialog.QtPluginDialog() widget.show() qtbot.wait(300) qtbot.add_widget(widget) return widget def test_filter_not_available_plugins(plugin_dialog_constructor): """ Check that the plugins listed under available plugins are enabled and disabled accordingly. The first plugin ("test-name-0") is not available on conda-forge and should be disabled, and show a tooltip warning. The second plugin ("test-name-1") is available on conda-forge and should be enabled without the tooltip warning. """ item = plugin_dialog_constructor.available_list.item(0) widget = plugin_dialog_constructor.available_list.itemWidget(item) if widget: assert not widget.action_button.isEnabled() assert widget.warning_tooltip.isVisible() item = plugin_dialog_constructor.available_list.item(1) widget = plugin_dialog_constructor.available_list.itemWidget(item) assert widget.action_button.isEnabled() assert not widget.warning_tooltip.isVisible() def test_filter_available_plugins(plugin_dialog): """ Test the dialog is correctly filtering plugins in the available plugins list (the bottom one). """ plugin_dialog.filter("") assert plugin_dialog.available_list.count() == 2 assert plugin_dialog.available_list._count_visible() == 2 plugin_dialog.filter("no-match@123") assert plugin_dialog.available_list._count_visible() == 0 plugin_dialog.filter("") plugin_dialog.filter("test-name-0") assert plugin_dialog.available_list._count_visible() == 1 def test_filter_installed_plugins(plugin_dialog): """ Test the dialog is correctly filtering plugins in the installed plugins list (the top one). """ plugin_dialog.filter("") assert plugin_dialog.installed_list._count_visible() >= 0 plugin_dialog.filter("no-match@123") assert plugin_dialog.installed_list._count_visible() == 0 def test_visible_widgets(plugin_dialog): """ Test that the direct entry button and textbox are visible for normal napari installs. """ assert plugin_dialog.direct_entry_edit.isVisible() assert plugin_dialog.direct_entry_btn.isVisible() def test_constructor_visible_widgets(plugin_dialog_constructor): """ Test that the direct entry button and textbox are hidden for constructor based napari installs. """ assert not plugin_dialog_constructor.direct_entry_edit.isVisible() assert not plugin_dialog_constructor.direct_entry_btn.isVisible() napari-0.5.0a1/napari/_qt/dialogs/_tests/test_qt_plugin_report.py000066400000000000000000000041151437041365600252100ustar00rootroot00000000000000import webbrowser import pytest from napari_plugin_engine import PluginError from qtpy.QtCore import Qt from qtpy.QtGui import QGuiApplication from napari._qt.dialogs import qt_plugin_report # qtbot fixture comes from pytest-qt # test_plugin_manager fixture is provided by napari_plugin_engine._testsupport # monkeypatch fixture is from pytest def test_error_reporter(qtbot, monkeypatch): """test that QtPluginErrReporter shows any instantiated PluginErrors.""" monkeypatch.setattr( qt_plugin_report, 'standard_metadata', lambda x: {'url': 'https://github.com/example/example'}, ) error_message = 'my special error' try: # we need to raise to make sure a __traceback__ is attached to the error. raise PluginError( error_message, plugin_name='test_plugin', plugin="mock" ) except PluginError: pass report_widget = qt_plugin_report.QtPluginErrReporter() qtbot.addWidget(report_widget) # the null option plus the one we created assert report_widget.plugin_combo.count() >= 2 # the message should appear somewhere in the text area report_widget.set_plugin('test_plugin') assert error_message in report_widget.text_area.toPlainText() # mock_webbrowser_open def mock_webbrowser_open(url, new=0): assert new == 2 assert "Errors for plugin 'test_plugin'" in url assert "Traceback from napari" in url monkeypatch.setattr(webbrowser, 'open', mock_webbrowser_open) qtbot.mouseClick(report_widget.github_button, Qt.LeftButton) # make sure we can copy traceback to clipboard report_widget.copyToClipboard() clipboard_text = QGuiApplication.clipboard().text() assert "Errors for plugin 'test_plugin'" in clipboard_text # plugins without errors raise an error with pytest.raises(ValueError): report_widget.set_plugin('non_existent') report_widget.set_plugin(None) assert not report_widget.text_area.toPlainText() def test_dialog_create(qtbot): dialog = qt_plugin_report.QtPluginErrReporter() qtbot.addWidget(dialog) napari-0.5.0a1/napari/_qt/dialogs/_tests/test_reader_dialog.py000066400000000000000000000113631437041365600243770ustar00rootroot00000000000000import os import numpy as np import pytest from npe2 import DynamicPlugin from qtpy.QtWidgets import QLabel, QRadioButton from napari._qt.dialogs.qt_reader_dialog import ( QtReaderDialog, open_with_dialog_choices, prepare_remaining_readers, ) from napari.errors.reader_errors import ReaderPluginError from napari.settings import get_settings @pytest.fixture def reader_dialog(qtbot): def _reader_dialog(**kwargs): widget = QtReaderDialog(**kwargs) widget.show() qtbot.addWidget(widget) return widget return _reader_dialog def test_reader_dialog_buttons(reader_dialog): widg = reader_dialog( readers={'display name': 'plugin-name', 'display 2': 'plugin2'} ) assert len(widg.findChildren(QRadioButton)) == 2 def test_reader_defaults(reader_dialog, tmpdir): file_pth = tmpdir.join('my_file.tif') widg = reader_dialog(pth=file_pth, readers={'p1': 'p1', 'p2': 'p2'}) assert widg.findChild(QLabel).text().startswith('Choose reader') assert widg._get_plugin_choice() == 'p1' assert widg.persist_checkbox.isChecked() def test_reader_with_error_message(reader_dialog): widg = reader_dialog(error_message='Test Error') assert widg.findChild(QLabel).text().startswith('Test Error') def test_reader_dir_with_extension(tmpdir, reader_dialog): dir = tmpdir.mkdir('my_dir.zarr') widg = reader_dialog(pth=dir, readers={'p1': 'p1', 'p2': 'p2'}) assert hasattr(widg, 'persist_checkbox') assert ( widg.persist_checkbox.text() == "Remember this choice for files with a .zarr extension" ) def test_reader_dir(tmpdir, reader_dialog): dir = tmpdir.mkdir('my_dir') widg = reader_dialog(pth=dir, readers={'p1': 'p1', 'p2': 'p2'}) assert ( widg._persist_text == f'Remember this choice for folders labeled as {dir}{os.sep}.' ) def test_get_plugin_choice(tmpdir, reader_dialog): file_pth = tmpdir.join('my_file.tif') widg = reader_dialog(pth=file_pth, readers={'p1': 'p1', 'p2': 'p2'}) reader_btns = widg.reader_btn_group.buttons() reader_btns[1].toggle() assert widg._get_plugin_choice() == 'p2' reader_btns[0].toggle() assert widg._get_plugin_choice() == 'p1' def test_get_persist_choice(tmpdir, reader_dialog): file_pth = tmpdir.join('my_file.tif') widg = reader_dialog(pth=file_pth, readers={'p1': 'p1', 'p2': 'p2'}) assert widg._get_persist_choice() widg.persist_checkbox.toggle() assert not widg._get_persist_choice() def test_prepare_dialog_options_no_readers(): with pytest.raises(ReaderPluginError) as e: prepare_remaining_readers( ['my-file.fake'], 'fake-reader', RuntimeError('Reading failed') ) assert 'Tried to read my-file.fake with plugin fake-reader' in str(e.value) def test_prepare_dialog_options_multiple_plugins(builtins): pth = 'my-file.tif' readers = prepare_remaining_readers( [pth], None, RuntimeError(f'Multiple plugins found capable of reading {pth}'), ) assert builtins.name in readers def test_prepare_dialog_options_removes_plugin(tmp_plugin: DynamicPlugin): tmp2 = tmp_plugin.spawn(register=True) @tmp_plugin.contribute.reader(filename_patterns=['*.fake']) def _(path): ... @tmp2.contribute.reader(filename_patterns=['*.fake']) def _(path): ... readers = prepare_remaining_readers( ['my-file.fake'], tmp_plugin.name, RuntimeError('Reader failed'), ) assert tmp2.name in readers assert tmp_plugin.name not in readers def test_open_with_dialog_choices_persist( builtins, make_napari_viewer, tmp_path ): pth = tmp_path / 'my-file.npy' np.save(pth, np.random.random((10, 10))) viewer = make_napari_viewer() open_with_dialog_choices( display_name=builtins.display_name, persist=True, extension='.npy', readers={builtins.name: builtins.display_name}, paths=[str(pth)], stack=False, qt_viewer=viewer.window._qt_viewer, ) assert len(viewer.layers) == 1 # make sure extension was saved with * assert get_settings().plugins.extension2reader['*.npy'] == builtins.name def test_open_with_dialog_choices_raises(make_napari_viewer): viewer = make_napari_viewer() get_settings().plugins.extension2reader = {} with pytest.raises(ValueError): open_with_dialog_choices( display_name='Fake Plugin', persist=True, extension='.fake', readers={'fake-plugin': 'Fake Plugin'}, paths=['my-file.fake'], stack=False, qt_viewer=viewer.window._qt_viewer, ) # settings weren't saved because reading failed assert not get_settings().plugins.extension2reader napari-0.5.0a1/napari/_qt/dialogs/confirm_close_dialog.py000066400000000000000000000050631437041365600234170ustar00rootroot00000000000000from qtpy.QtGui import QKeySequence from qtpy.QtWidgets import ( QCheckBox, QDialog, QHBoxLayout, QLabel, QPushButton, QVBoxLayout, QWidget, ) from napari.settings import get_settings from napari.utils.translations import trans class ConfirmCloseDialog(QDialog): def __init__(self, parent, close_app=False) -> None: super().__init__(parent) cancel_btn = QPushButton(trans._("Cancel")) close_btn = QPushButton(trans._("Close")) close_btn.setObjectName("warning_icon_btn") icon_label = QWidget() self.do_not_ask = QCheckBox(trans._("Do not ask in future")) if close_app: self.setWindowTitle(trans._('Close Application?')) text = trans._( "Do you want to close the application? ('{shortcut}' to confirm). This will close all Qt Windows in this process", shortcut=QKeySequence('Ctrl+Q').toString( QKeySequence.NativeText ), ) close_btn.setObjectName("error_icon_btn") close_btn.setShortcut(QKeySequence('Ctrl+Q')) icon_label.setObjectName("error_icon_element") else: self.setWindowTitle(trans._('Close Window?')) text = trans._( "Confirm to close window (or press '{shortcut}')", shortcut=QKeySequence('Ctrl+W').toString( QKeySequence.NativeText ), ) close_btn.setObjectName("warning_icon_btn") close_btn.setShortcut(QKeySequence('Ctrl+W')) icon_label.setObjectName("warning_icon_element") cancel_btn.clicked.connect(self.reject) close_btn.clicked.connect(self.accept) layout = QVBoxLayout() layout2 = QHBoxLayout() layout2.addWidget(icon_label) layout3 = QVBoxLayout() layout3.addWidget(QLabel(text)) layout3.addWidget(self.do_not_ask) layout2.addLayout(layout3) layout4 = QHBoxLayout() layout4.addStretch(1) layout4.addWidget(cancel_btn) layout4.addWidget(close_btn) layout.addLayout(layout2) layout.addLayout(layout4) self.setLayout(layout) # for test purposes because of problem with shortcut testing: # https://github.com/pytest-dev/pytest-qt/issues/254 self.close_btn = close_btn self.cancel_btn = cancel_btn def accept(self): if self.do_not_ask.isChecked(): get_settings().application.confirm_close_window = False super().accept() napari-0.5.0a1/napari/_qt/dialogs/preferences_dialog.py000066400000000000000000000202321437041365600230710ustar00rootroot00000000000000import json from enum import EnumMeta from typing import TYPE_CHECKING, Tuple, cast from pydantic.main import BaseModel from qtpy.QtCore import QSize, Qt, Signal from qtpy.QtWidgets import ( QDialog, QHBoxLayout, QListWidget, QMessageBox, QPushButton, QStackedWidget, QVBoxLayout, ) from napari.utils.translations import trans if TYPE_CHECKING: from pydantic.fields import ModelField from qtpy.QtGui import QCloseEvent, QKeyEvent class PreferencesDialog(QDialog): """Preferences Dialog for Napari user settings.""" ui_schema = { "call_order": {"ui:widget": "plugins"}, "highlight_thickness": {"ui:widget": "highlight"}, "shortcuts": {"ui:widget": "shortcuts"}, "extension2reader": {"ui:widget": "extension2reader"}, } resized = Signal(QSize) def __init__(self, parent=None) -> None: from napari.settings import get_settings super().__init__(parent) self.setWindowTitle(trans._("Preferences")) self._settings = get_settings() self._stack = QStackedWidget(self) self._list = QListWidget(self) self._list.setObjectName("Preferences") self._list.currentRowChanged.connect(self._stack.setCurrentIndex) # Set up buttons self._button_cancel = QPushButton(trans._("Cancel")) self._button_cancel.clicked.connect(self.reject) self._button_ok = QPushButton(trans._("OK")) self._button_ok.clicked.connect(self.accept) self._button_ok.setDefault(True) self._button_restore = QPushButton(trans._("Restore defaults")) self._button_restore.clicked.connect(self._restore_default_dialog) # Layout left_layout = QVBoxLayout() left_layout.addWidget(self._list) left_layout.addStretch() left_layout.addWidget(self._button_restore) left_layout.addWidget(self._button_cancel) left_layout.addWidget(self._button_ok) self.setLayout(QHBoxLayout()) self.layout().addLayout(left_layout, 1) self.layout().addWidget(self._stack, 3) # Build dialog from settings self._rebuild_dialog() def keyPressEvent(self, e: 'QKeyEvent'): if e.key() == Qt.Key.Key_Escape: # escape key should just close the window # which implies "accept" e.accept() self.accept() return super().keyPressEvent(e) def resizeEvent(self, event): """Override to emit signal.""" self.resized.emit(event.size()) super().resizeEvent(event) def _rebuild_dialog(self): """Removes settings not to be exposed to user and creates dialog pages.""" # FIXME: this dialog should not need to know about the plugin manager from napari.plugins import plugin_manager self._starting_pm_order = plugin_manager.call_order() self._starting_values = self._settings.dict(exclude={'schema_version'}) self._list.clear() while self._stack.count(): self._stack.removeWidget(self._stack.currentWidget()) for field in self._settings.__fields__.values(): if isinstance(field.type_, type) and issubclass( field.type_, BaseModel ): self._add_page(field) self._list.setCurrentRow(0) def _add_page(self, field: 'ModelField'): """Builds the preferences widget using the json schema builder. Parameters ---------- field : ModelField subfield for which to create a page. """ from napari._vendor.qt_json_builder.qt_jsonschema_form import ( WidgetBuilder, ) schema, values = self._get_page_dict(field) name = field.field_info.title or field.name form = WidgetBuilder().create_form(schema, self.ui_schema) # set state values for widget form.widget.state = values # make settings follow state of the form widget form.widget.on_changed.connect( lambda d: getattr(self._settings, name.lower()).update(d) ) # need to disable async if octree is enabled. # TODO: this shouldn't live here... if there is a coupling/dependency # between these settings, it should be declared in the settings schema if ( name.lower() == 'experimental' and values['octree'] and self._settings.env_settings() .get('experimental', {}) .get('async_') not in (None, '0') ): form_layout = form.widget.layout() for i in range(form_layout.count()): wdg = form_layout.itemAt(i, form_layout.FieldRole).widget() if wdg._name == 'async_': wdg.opacity.setOpacity(0.3) wdg.setDisabled(True) break self._list.addItem(field.field_info.title or field.name) self._stack.addWidget(form) def _get_page_dict(self, field: 'ModelField') -> Tuple[dict, dict]: """Provides the schema, set of values for each setting, and the properties for each setting.""" ftype = cast('BaseModel', field.type_) # TODO make custom shortcuts dialog to properly capture new # functionality once we switch to app-model's keybinding system # then we can remove the below code used for autogeneration if field.name == 'shortcuts': # hardcode workaround because pydantic's schema generation # does not allow you to specify custom JSON serialization schema = { "title": "ShortcutsSettings", "type": "object", "properties": { "shortcuts": { "title": "shortcuts", "description": "Set keyboard shortcuts for actions.", "type": "object", } }, } else: schema = json.loads(ftype.schema_json()) # find enums: for name, subfield in ftype.__fields__.items(): if isinstance(subfield.type_, EnumMeta): enums = [s.value for s in subfield.type_] # type: ignore schema["properties"][name]["enum"] = enums schema["properties"][name]["type"] = "string" # Need to remove certain properties that will not be displayed on the GUI setting = getattr(self._settings, field.name) with setting.enums_as_values(): values = setting.dict() napari_config = getattr(setting, "NapariConfig", None) if hasattr(napari_config, 'preferences_exclude'): for val in napari_config.preferences_exclude: schema['properties'].pop(val, None) values.pop(val, None) return schema, values def _restore_default_dialog(self): """Launches dialog to confirm restore settings choice.""" response = QMessageBox.question( self, trans._("Restore Settings"), trans._("Are you sure you want to restore default settings?"), QMessageBox.RestoreDefaults | QMessageBox.Cancel, QMessageBox.RestoreDefaults, ) if response == QMessageBox.RestoreDefaults: self._settings.reset() self._rebuild_dialog() # TODO: do we need this? def _restart_required_dialog(self): """Displays the dialog informing user a restart is required.""" QMessageBox.information( self, trans._("Restart required"), trans._( "A restart is required for some new settings to have an effect." ), ) def closeEvent(self, event: 'QCloseEvent') -> None: event.accept() self.accept() def reject(self): """Restores the settings in place when dialog was launched.""" self._settings.update(self._starting_values) # FIXME: this dialog should not need to know about the plugin manager if self._starting_pm_order: from napari.plugins import plugin_manager plugin_manager.set_call_order(self._starting_pm_order) super().reject() napari-0.5.0a1/napari/_qt/dialogs/qt_about.py000066400000000000000000000107311437041365600210720ustar00rootroot00000000000000from qtpy import QtGui from qtpy.QtCore import Qt from qtpy.QtWidgets import ( QDialog, QHBoxLayout, QLabel, QPushButton, QTextEdit, QVBoxLayout, ) from napari.utils import citation_text, sys_info from napari.utils.translations import trans class QtAbout(QDialog): """Qt dialog window for displaying 'About napari' information. Parameters ---------- parent : QWidget, optional Parent of the dialog, to correctly inherit and apply theme. Default is None. Attributes ---------- citationCopyButton : napari._qt.qt_about.QtCopyToClipboardButton Button to copy citation information to the clipboard. citationTextBox : qtpy.QtWidgets.QTextEdit Text box containing napari citation information. citation_layout : qtpy.QtWidgets.QHBoxLayout Layout widget for napari citation information. infoCopyButton : napari._qt.qt_about.QtCopyToClipboardButton Button to copy napari version information to the clipboard. info_layout : qtpy.QtWidgets.QHBoxLayout Layout widget for napari version information. infoTextBox : qtpy.QtWidgets.QTextEdit Text box containing napari version information. layout : qtpy.QtWidgets.QVBoxLayout Layout widget for the entire 'About napari' dialog. """ def __init__(self, parent=None) -> None: super().__init__(parent) self.layout = QVBoxLayout() # Description title_label = QLabel( trans._( "napari: a multi-dimensional image viewer for python" ) ) title_label.setTextInteractionFlags( Qt.TextInteractionFlag.TextSelectableByMouse ) self.layout.addWidget(title_label) # Add information self.infoTextBox = QTextEdit() self.infoTextBox.setTextInteractionFlags( Qt.TextInteractionFlag.TextSelectableByMouse ) self.infoTextBox.setLineWrapMode(QTextEdit.NoWrap) # Add text copy button self.infoCopyButton = QtCopyToClipboardButton(self.infoTextBox) self.info_layout = QHBoxLayout() self.info_layout.addWidget(self.infoTextBox, 1) self.info_layout.addWidget( self.infoCopyButton, 0, Qt.AlignmentFlag.AlignTop ) self.info_layout.setAlignment(Qt.AlignmentFlag.AlignTop) self.layout.addLayout(self.info_layout) self.infoTextBox.setText(sys_info(as_html=True)) self.infoTextBox.setMinimumSize( int(self.infoTextBox.document().size().width() + 19), int(min(self.infoTextBox.document().size().height() + 10, 500)), ) self.layout.addWidget(QLabel(trans._('citation information:'))) self.citationTextBox = QTextEdit(citation_text) self.citationTextBox.setFixedHeight(64) self.citationCopyButton = QtCopyToClipboardButton(self.citationTextBox) self.citation_layout = QHBoxLayout() self.citation_layout.addWidget(self.citationTextBox, 1) self.citation_layout.addWidget( self.citationCopyButton, 0, Qt.AlignmentFlag.AlignTop ) self.layout.addLayout(self.citation_layout) self.setLayout(self.layout) @staticmethod def showAbout(parent=None): """Display the 'About napari' dialog box. Parameters ---------- parent : QWidget, optional Parent of the dialog, to correctly inherit and apply theme. Default is None. """ d = QtAbout(parent) d.setObjectName('QtAbout') d.setWindowTitle(trans._('About')) d.setWindowModality(Qt.WindowModality.ApplicationModal) d.exec_() class QtCopyToClipboardButton(QPushButton): """Button to copy text box information to the clipboard. Parameters ---------- text_edit : qtpy.QtWidgets.QTextEdit The text box contents linked to copy to clipboard button. Attributes ---------- text_edit : qtpy.QtWidgets.QTextEdit The text box contents linked to copy to clipboard button. """ def __init__(self, text_edit) -> None: super().__init__() self.setObjectName("QtCopyToClipboardButton") self.text_edit = text_edit self.setToolTip(trans._("Copy to clipboard")) self.clicked.connect(self.copyToClipboard) def copyToClipboard(self): """Copy text to the clipboard.""" cb = QtGui.QGuiApplication.clipboard() cb.setText(str(self.text_edit.toPlainText())) napari-0.5.0a1/napari/_qt/dialogs/qt_activity_dialog.py000066400000000000000000000246711437041365600231430ustar00rootroot00000000000000from pathlib import Path from qtpy.QtCore import QPoint, QSize, Qt from qtpy.QtGui import QMovie from qtpy.QtWidgets import ( QApplication, QDialog, QFrame, QGraphicsOpacityEffect, QHBoxLayout, QLabel, QScrollArea, QSizePolicy, QToolButton, QVBoxLayout, QWidget, ) import napari.resources from napari._qt.widgets.qt_progress_bar import ( QtLabeledProgressBar, QtProgressBarGroup, ) from napari.utils.progress import progress from napari.utils.translations import trans class ActivityToggleItem(QWidget): """Toggle button for Activity Dialog. A progress indicator is displayed when there are active progress bars. """ def __init__(self, parent=None) -> None: super().__init__(parent=parent) self.setLayout(QHBoxLayout()) self._activityBtn = QToolButton() self._activityBtn.setObjectName("QtActivityButton") self._activityBtn.setToolButtonStyle( Qt.ToolButtonStyle.ToolButtonTextBesideIcon ) self._activityBtn.setArrowType(Qt.ArrowType.UpArrow) self._activityBtn.setIconSize(QSize(11, 11)) self._activityBtn.setText(trans._('activity')) self._activityBtn.setCheckable(True) self._inProgressIndicator = QLabel(trans._("in progress..."), self) sp = self._inProgressIndicator.sizePolicy() sp.setRetainSizeWhenHidden(True) self._inProgressIndicator.setSizePolicy(sp) load_gif = str(Path(napari.resources.__file__).parent / "loading.gif") mov = QMovie(load_gif) mov.setScaledSize(QSize(18, 18)) self._inProgressIndicator.setMovie(mov) self._inProgressIndicator.hide() self.layout().addWidget(self._inProgressIndicator) self.layout().addWidget(self._activityBtn) self.layout().setContentsMargins(0, 0, 0, 0) class QtActivityDialog(QDialog): """Activity Dialog for Napari progress bars.""" MIN_WIDTH = 250 MIN_HEIGHT = 185 def __init__(self, parent=None, toggle_button=None) -> None: super().__init__(parent) self._toggleButton = toggle_button self.setObjectName('Activity') self.setMinimumWidth(self.MIN_WIDTH) self.setMinimumHeight(self.MIN_HEIGHT) self.setMaximumHeight(self.MIN_HEIGHT) self.setSizePolicy(QSizePolicy.MinimumExpanding, QSizePolicy.Fixed) self.setWindowFlags( Qt.WindowType.SubWindow | Qt.WindowType.WindowStaysOnTopHint ) self.setModal(False) opacityEffect = QGraphicsOpacityEffect(self) opacityEffect.setOpacity(0.8) self.setGraphicsEffect(opacityEffect) self._baseWidget = QWidget() self._activityLayout = QVBoxLayout() self._activityLayout.addStretch() self._baseWidget.setLayout(self._activityLayout) self._baseWidget.layout().setContentsMargins(0, 0, 0, 0) self._scrollArea = QScrollArea() self._scrollArea.setWidgetResizable(True) self._scrollArea.setWidget(self._baseWidget) self._titleBar = QLabel() title = QLabel('activity', self) title.setObjectName('QtCustomTitleLabel') title.setSizePolicy( QSizePolicy(QSizePolicy.Policy.Maximum, QSizePolicy.Policy.Maximum) ) line = QFrame(self) line.setObjectName("QtCustomTitleBarLine") titleLayout = QHBoxLayout() titleLayout.setSpacing(4) titleLayout.setContentsMargins(8, 1, 8, 0) line.setFixedHeight(1) titleLayout.addWidget(line) titleLayout.addWidget(title) self._titleBar.setLayout(titleLayout) self._baseLayout = QVBoxLayout() self._baseLayout.addWidget(self._titleBar) self._baseLayout.addWidget(self._scrollArea) self.setLayout(self._baseLayout) self.resize(520, self.MIN_HEIGHT) self.move_to_bottom_right() # TODO: what do we do with any existing progress objects in action? # connect callback to handle new progress objects being added/removed progress._all_instances.events.changed.connect( self.handle_progress_change ) def handle_progress_change(self, event): """Handle addition and/or removal of new progress objects Parameters ---------- event : Event EventedSet `changed` event with `added` and `removed` objects """ for prog in event.removed: self.close_progress_bar(prog) for prog in event.added: self.make_new_pbar(prog) def make_new_pbar(self, prog): """Make new `QtLabeledProgressBar` for this `progress` object and add to viewer. Parameters ---------- prog : progress progress object to associated with new progress bar """ prog.gui = True prog.leave = False # make and add progress bar pbar = QtLabeledProgressBar(prog=prog) self.add_progress_bar(pbar, nest_under=prog.nest_under) # connect progress object events to updating progress bar prog.events.value.connect(pbar._set_value) prog.events.description.connect(pbar._set_description) prog.events.overflow.connect(pbar._make_indeterminate) prog.events.eta.connect(pbar._set_eta) prog.events.total.connect(pbar._set_total) # connect pbar close method if we're closed self.destroyed.connect(prog.close) # set its range etc. based on progress object if prog.total is not None: pbar.setRange(prog.n, prog.total) pbar.setValue(prog.n) else: pbar.setRange(0, 0) prog.total = 0 pbar.setDescription(prog.desc) def add_progress_bar(self, pbar, nest_under=None): """Add progress bar to activity_dialog,in QtProgressBarGroup if needed. Check if pbar needs nesting and create QtProgressBarGroup, removing existing separators and creating new ones. Show and start inProgressIndicator to highlight the existence of a progress bar in the dock even when the dock is hidden. Parameters ---------- pbar : QtLabeledProgressBar progress bar to add to activity dialog nest_under : Optional[progress] parent `progress` whose QtLabeledProgressBar we need to nest under """ if nest_under is None: self._activityLayout.addWidget(pbar) else: # TODO: can parent be non gui pbar? parent_pbar = self.get_pbar_from_prog(nest_under) current_pbars = [parent_pbar, pbar] remove_separators(current_pbars) parent_widg = parent_pbar.parent() # if we are already in a group, add pbar to existing group if isinstance(parent_widg, QtProgressBarGroup): nested_layout = parent_widg.layout() # create QtProgressBarGroup for this pbar else: new_group = QtProgressBarGroup(parent_pbar) new_group.destroyed.connect(self.maybe_hide_progress_indicator) nested_layout = new_group.layout() self._activityLayout.addWidget(new_group) # progress bar needs to go before separator new_pbar_index = nested_layout.count() - 1 nested_layout.insertWidget(new_pbar_index, pbar) # show progress indicator and start gif self._toggleButton._inProgressIndicator.movie().start() self._toggleButton._inProgressIndicator.show() pbar.destroyed.connect(self.maybe_hide_progress_indicator) QApplication.processEvents() def get_pbar_from_prog(self, prog): """Given prog `progress` object, find associated `QtLabeledProgressBar` Parameters ---------- prog : progress progress object with associated progress bar Returns ------- QtLabeledProgressBar QtLabeledProgressBar widget associated with this progress object """ if pbars := self._baseWidget.findChildren(QtLabeledProgressBar): for potential_parent in pbars: if potential_parent.progress is prog: return potential_parent def close_progress_bar(self, prog): """Close `QtLabeledProgressBar` and parent `QtProgressBarGroup` if needed Parameters ---------- prog : progress progress object whose QtLabeledProgressBar to close """ current_pbar = self.get_pbar_from_prog(prog) if not current_pbar: return parent_widget = current_pbar.parent() current_pbar.close() current_pbar.deleteLater() if isinstance(parent_widget, QtProgressBarGroup): pbar_children = [ child for child in parent_widget.children() if isinstance(child, QtLabeledProgressBar) ] # only close group if it has no visible progress bars if not any(child.isVisible() for child in pbar_children): parent_widget.close() def move_to_bottom_right(self, offset=(8, 8)): """Position widget at the bottom right edge of the parent.""" if not self.parent(): return sz = self.parent().size() - self.size() - QSize(*offset) self.move(QPoint(sz.width(), sz.height())) def maybe_hide_progress_indicator(self): """Hide progress indicator when all progress bars have finished.""" pbars = self._baseWidget.findChildren(QtLabeledProgressBar) pbar_groups = self._baseWidget.findChildren(QtProgressBarGroup) progress_visible = any(pbar.isVisible() for pbar in pbars) progress_group_visible = any( pbar_group.isVisible() for pbar_group in pbar_groups ) if not progress_visible and not progress_group_visible: self._toggleButton._inProgressIndicator.movie().stop() self._toggleButton._inProgressIndicator.hide() def remove_separators(current_pbars): """Remove any existing line separators from current_pbars as they will get a separator from the group Parameters ---------- current_pbars : List[QtLabeledProgressBar] parent and new progress bar to remove separators from """ for current_pbar in current_pbars: if line_widg := current_pbar.findChild(QFrame, "QtCustomTitleBarLine"): current_pbar.layout().removeWidget(line_widg) line_widg.hide() line_widg.deleteLater() napari-0.5.0a1/napari/_qt/dialogs/qt_modal.py000066400000000000000000000133101437041365600210500ustar00rootroot00000000000000from qtpy.QtCore import QPoint, QRect, Qt from qtpy.QtGui import QCursor, QGuiApplication from qtpy.QtWidgets import QDialog, QFrame, QVBoxLayout from napari.utils.translations import trans class QtPopup(QDialog): """A generic popup window. The seemingly extra frame here is to allow rounded corners on a truly transparent background. New items should be added to QtPopup.frame +---------------------------------- | Dialog | +------------------------------- | | QVBoxLayout | | +---------------------------- | | | QFrame | | | +------------------------- | | | | | | | | (add a new layout here) Parameters ---------- parent : qtpy.QtWidgets:QWidget Parent widget of the popup dialog box. Attributes ---------- frame : qtpy.QtWidgets.QFrame Frame of the popup dialog box. """ def __init__(self, parent) -> None: super().__init__(parent) self.setObjectName("QtModalPopup") self.setModal(False) # if False, then clicking anywhere else closes it self.setWindowFlags(Qt.Popup | Qt.FramelessWindowHint) self.setLayout(QVBoxLayout()) self.frame = QFrame() self.frame.setObjectName("QtPopupFrame") self.layout().addWidget(self.frame) self.layout().setContentsMargins(0, 0, 0, 0) def show_above_mouse(self, *args): """Show popup dialog above the mouse cursor position.""" pos = QCursor().pos() # mouse position szhint = self.sizeHint() pos -= QPoint(szhint.width() // 2, szhint.height() + 14) self.move(pos) self.show() def show_right_of_mouse(self, *args): pos = QCursor().pos() # mouse position szhint = self.sizeHint() pos -= QPoint(-14, szhint.height() // 4) self.move(pos) self.show() def move_to(self, position='top', *, win_ratio=0.9, min_length=0): """Move popup to a position relative to the QMainWindow. Parameters ---------- position : {str, tuple}, optional position in the QMainWindow to show the pop, by default 'top' if str: must be one of {'top', 'bottom', 'left', 'right' } if tuple: must be length 4 with (left, top, width, height) win_ratio : float, optional Fraction of the width (for position = top/bottom) or height (for position = left/right) of the QMainWindow that the popup will occupy. Only valid when isinstance(position, str). by default 0.9 min_length : int, optional Minimum size of the long dimension (width for top/bottom or height fort left/right). Raises ------ ValueError if position is a string and not one of {'top', 'bottom', 'left', 'right' } """ if isinstance(position, str): window = self.parent().window() if self.parent() else None if not window: raise ValueError( trans._( "Specifying position as a string is only possible if the popup has a parent", deferred=True, ) ) left = window.pos().x() top = window.pos().y() if position in ('top', 'bottom'): width = int(window.width() * win_ratio) width = max(width, min_length) left += (window.width() - width) // 2 height = self.sizeHint().height() top += ( 24 if position == 'top' else (window.height() - height - 12) ) elif position in ('left', 'right'): height = int(window.height() * win_ratio) height = max(height, min_length) # 22 is for the title bar top += 22 + (window.height() - height) // 2 width = self.sizeHint().width() left += ( 12 if position == 'left' else (window.width() - width - 12) ) else: raise ValueError( trans._( 'position must be one of ["top", "left", "bottom", "right"]', deferred=True, ) ) elif isinstance(position, (tuple, list)): assert len(position) == 4, '`position` argument must have length 4' left, top, width, height = position else: raise ValueError( trans._( "Wrong type of position {position}", deferred=True, position=position, ) ) # necessary for transparent round corners self.resize(self.sizeHint()) # make sure the popup is completely on the screen # In Qt ≥5.10 we can use screenAt to know which monitor the mouse is on screen_geometry: QRect = QGuiApplication.screenAt( QCursor.pos() ).geometry() left = max( min(screen_geometry.right() - width, left), screen_geometry.left() ) top = max( min(screen_geometry.bottom() - height, top), screen_geometry.top() ) self.setGeometry(left, top, width, height) def keyPressEvent(self, event): """Close window on return, else pass event through to super class. Parameters ---------- event : qtpy.QtCore.QEvent Event from the Qt context. """ if event.key() in (Qt.Key.Key_Return, Qt.Key.Key_Enter): self.close() return super().keyPressEvent(event) napari-0.5.0a1/napari/_qt/dialogs/qt_notification.py000066400000000000000000000366411437041365600224560ustar00rootroot00000000000000from __future__ import annotations from typing import Callable, Optional, Sequence, Tuple, Union from qtpy.QtCore import ( QEasingCurve, QPoint, QPropertyAnimation, QRect, QSize, Qt, QTimer, ) from qtpy.QtWidgets import ( QApplication, QDialog, QGraphicsOpacityEffect, QHBoxLayout, QLabel, QPushButton, QSizePolicy, QTextEdit, QVBoxLayout, QWidget, ) from superqt import QElidingLabel, ensure_main_thread from napari._qt.code_syntax_highlight import Pylighter from napari._qt.qt_resources import QColoredSVGIcon from napari.settings import get_settings from napari.utils.notifications import Notification, NotificationSeverity from napari.utils.theme import get_theme from napari.utils.translations import trans ActionSequence = Sequence[Tuple[str, Callable[['NapariQtNotification'], None]]] class NapariQtNotification(QDialog): """Notification dialog frame, appears at the bottom right of the canvas. By default, only the first line of the notification is shown, and the text is elided. Double-clicking on the text (or clicking the chevron icon) will expand to show the full notification. The dialog will autmatically disappear in ``DISMISS_AFTER`` milliseconds, unless hovered or clicked. Parameters ---------- message : str The message that will appear in the notification severity : str or NotificationSeverity, optional Severity level {'error', 'warning', 'info', 'none'}. Will determine the icon associated with the message. by default NotificationSeverity.WARNING. source : str, optional A source string for the notifcation (intended to show the module and or package responsible for the notification), by default None actions : list of tuple, optional A sequence of 2-tuples, where each tuple is a string and a callable. Each tuple will be used to create button in the dialog, where the text on the button is determine by the first item in the tuple, and a callback function to call when the button is pressed is the second item in the tuple. by default () """ MAX_OPACITY = 0.9 FADE_IN_RATE = 220 FADE_OUT_RATE = 120 DISMISS_AFTER = 4000 MIN_WIDTH = 400 MIN_EXPANSION = 18 message: QElidingLabel source_label: QLabel severity_icon: QLabel def __init__( self, message: str, severity: Union[str, NotificationSeverity] = 'WARNING', source: Optional[str] = None, actions: ActionSequence = (), parent=None, ) -> None: super().__init__(parent=parent) if parent and hasattr(parent, 'resized'): parent.resized.connect(self.move_to_bottom_right) self.setupUi() self.setAttribute(Qt.WidgetAttribute.WA_DeleteOnClose) self.setup_buttons(actions) self.setMouseTracking(True) self._update_icon(str(severity)) self.message.setText(message) if source: self.source_label.setText( trans._('Source: {source}', source=source) ) self.close_button.clicked.connect(self.close) self.expand_button.clicked.connect(self.toggle_expansion) self.timer = QTimer() self.opacity = QGraphicsOpacityEffect() self.setGraphicsEffect(self.opacity) self.opacity_anim = QPropertyAnimation(self.opacity, b"opacity", self) self.geom_anim = QPropertyAnimation(self, b"geometry", self) self.move_to_bottom_right() def _update_icon(self, severity: str): """Update the icon to match the severity level.""" from napari.settings import get_settings from napari.utils.theme import get_theme settings = get_settings() theme = settings.appearance.theme default_color = get_theme(theme, False).icon # FIXME: Should these be defined at the theme level? # Currently there is a warning one colors = { 'error': "#D85E38", 'warning': "#E3B617", 'info': default_color, 'debug': default_color, 'none': default_color, } color = colors.get(severity, default_color) icon = QColoredSVGIcon.from_resources(severity) self.severity_icon.setPixmap(icon.colored(color=color).pixmap(15, 15)) def move_to_bottom_right(self, offset=(8, 8)): """Position widget at the bottom right edge of the parent.""" if not self.parent(): return sz = self.parent().size() - self.size() - QSize(*offset) self.move(QPoint(sz.width(), sz.height())) def slide_in(self): """Run animation that fades in the dialog with a slight slide up.""" geom = self.geometry() self.geom_anim.setDuration(self.FADE_IN_RATE) self.geom_anim.setStartValue(geom.translated(0, 20)) self.geom_anim.setEndValue(geom) self.geom_anim.setEasingCurve(QEasingCurve.OutQuad) # fade in self.opacity_anim.setDuration(self.FADE_IN_RATE) self.opacity_anim.setStartValue(0) self.opacity_anim.setEndValue(self.MAX_OPACITY) self.geom_anim.start() self.opacity_anim.start() def show(self): """Show the message with a fade and slight slide in from the bottom.""" super().show() self.slide_in() if self.DISMISS_AFTER > 0: self.timer.setInterval(self.DISMISS_AFTER) self.timer.setSingleShot(True) self.timer.timeout.connect(self.close_with_fade) self.timer.start() def mouseMoveEvent(self, event): """On hover, stop the self-destruct timer""" self.timer.stop() def mouseDoubleClickEvent(self, event): """Expand the notification on double click.""" self.toggle_expansion() def close(self): self.timer.stop() self.opacity_anim.stop() self.geom_anim.stop() super().close() def close_with_fade(self): """Fade out then close.""" self.timer.stop() self.opacity_anim.stop() self.geom_anim.stop() self.opacity_anim.setDuration(self.FADE_OUT_RATE) self.opacity_anim.setStartValue(self.MAX_OPACITY) self.opacity_anim.setEndValue(0) self.opacity_anim.start() self.opacity_anim.finished.connect(self.close) def deleteLater(self) -> None: """stop all animations and timers before deleting""" self.opacity_anim.stop() self.geom_anim.stop() self.timer.stop() super().deleteLater() def toggle_expansion(self): """Toggle the expanded state of the notification frame.""" self.contract() if self.property('expanded') else self.expand() self.timer.stop() def expand(self): """Expanded the notification so that the full message is visible.""" curr = self.geometry() self.geom_anim.setDuration(100) self.geom_anim.setStartValue(curr) new_height = self.sizeHint().height() if new_height < curr.height(): # new height would shift notification down, ensure some expansion new_height = curr.height() + self.MIN_EXPANSION delta = new_height - curr.height() self.geom_anim.setEndValue( QRect(curr.x(), curr.y() - delta, curr.width(), new_height) ) self.geom_anim.setEasingCurve(QEasingCurve.OutQuad) self.geom_anim.start() self.setProperty('expanded', True) self.style().unpolish(self.expand_button) self.style().polish(self.expand_button) def contract(self): """Contract notification to a single elided line of the message.""" geom = self.geometry() self.geom_anim.setDuration(100) self.geom_anim.setStartValue(geom) dlt = geom.height() - self.minimumHeight() self.geom_anim.setEndValue( QRect(geom.x(), geom.y() + dlt, geom.width(), geom.height() - dlt) ) self.geom_anim.setEasingCurve(QEasingCurve.OutQuad) self.geom_anim.start() self.setProperty('expanded', False) self.style().unpolish(self.expand_button) self.style().polish(self.expand_button) def setupUi(self): """Set up the UI during initialization.""" self.setWindowFlags(Qt.WindowType.SubWindow) self.setMinimumWidth(self.MIN_WIDTH) self.setMaximumWidth(self.MIN_WIDTH) self.setMinimumHeight(40) self.setSizeGripEnabled(False) self.setModal(False) self.verticalLayout = QVBoxLayout(self) self.verticalLayout.setContentsMargins(2, 2, 2, 2) self.verticalLayout.setSpacing(0) self.row1_widget = QWidget(self) self.row1 = QHBoxLayout(self.row1_widget) self.row1.setContentsMargins(12, 12, 12, 8) self.row1.setSpacing(4) self.severity_icon = QLabel(self.row1_widget) self.severity_icon.setObjectName("severity_icon") self.severity_icon.setMinimumWidth(30) self.severity_icon.setMaximumWidth(30) self.row1.addWidget( self.severity_icon, alignment=Qt.AlignmentFlag.AlignTop ) self.message = QElidingLabel() self.message.setWordWrap(True) self.message.setTextInteractionFlags(Qt.TextSelectableByMouse) self.message.setMinimumWidth(self.MIN_WIDTH - 200) self.message.setSizePolicy( QSizePolicy.Expanding, QSizePolicy.Expanding ) self.row1.addWidget(self.message, alignment=Qt.AlignmentFlag.AlignTop) self.expand_button = QPushButton(self.row1_widget) self.expand_button.setObjectName("expand_button") self.expand_button.setCursor(Qt.PointingHandCursor) self.expand_button.setMaximumWidth(20) self.expand_button.setFlat(True) self.row1.addWidget( self.expand_button, alignment=Qt.AlignmentFlag.AlignTop ) self.close_button = QPushButton(self.row1_widget) self.close_button.setObjectName("close_button") self.close_button.setCursor(Qt.PointingHandCursor) self.close_button.setMaximumWidth(20) self.close_button.setFlat(True) self.row1.addWidget( self.close_button, alignment=Qt.AlignmentFlag.AlignTop ) self.verticalLayout.addWidget(self.row1_widget, 1) self.row2_widget = QWidget(self) self.row2_widget.hide() self.row2 = QHBoxLayout(self.row2_widget) self.source_label = QLabel(self.row2_widget) self.source_label.setObjectName("source_label") self.row2.addWidget( self.source_label, alignment=Qt.AlignmentFlag.AlignBottom ) self.row2.addStretch() self.row2.setContentsMargins(12, 2, 16, 12) self.row2_widget.setMaximumHeight(34) self.row2_widget.setStyleSheet( 'QPushButton{' 'padding: 4px 12px 4px 12px; ' 'font-size: 11px;' 'min-height: 18px; border-radius: 0;}' ) self.verticalLayout.addWidget(self.row2_widget, 0) self.setProperty('expanded', False) self.resize(self.MIN_WIDTH, 40) def setup_buttons(self, actions: ActionSequence = ()): """Add buttons to the dialog. Parameters ---------- actions : tuple, optional A sequence of 2-tuples, where each tuple is a string and a callable. Each tuple will be used to create button in the dialog, where the text on the button is determine by the first item in the tuple, and a callback function to call when the button is pressed is the second item in the tuple. by default () """ if isinstance(actions, dict): actions = list(actions.items()) for text, callback in actions: btn = QPushButton(text) def call_back_with_self(callback_, self): """ We need a higher order function this to capture the reference to self. """ def _inner(): return callback_(self) return _inner btn.clicked.connect(call_back_with_self(callback, self)) btn.clicked.connect(self.close_with_fade) self.row2.addWidget(btn) if actions: self.row2_widget.show() self.setMinimumHeight( self.row2_widget.maximumHeight() + self.minimumHeight() ) def sizeHint(self): """Return the size required to show the entire message.""" return QSize( super().sizeHint().width(), self.row2_widget.height() + self.message.sizeHint().height(), ) @classmethod def from_notification( cls, notification: Notification, parent: QWidget = None ) -> NapariQtNotification: from napari.utils.notifications import ErrorNotification if isinstance(notification, ErrorNotification): def show_tb(notification_dialog): tbdialog = TracebackDialog( notification, notification_dialog.parent() ) tbdialog.show() actions = tuple(notification.actions) + ( (trans._('View Traceback'), show_tb), ) else: actions = notification.actions return cls( message=notification.message, severity=notification.severity, source=notification.source, actions=actions, parent=parent, ) @classmethod @ensure_main_thread def show_notification(cls, notification: Notification): from napari._qt.qt_main_window import _QtMainWindow from napari.settings import get_settings settings = get_settings() # after https://github.com/napari/napari/issues/2370, # the os.getenv can be removed (and NAPARI_CATCH_ERRORS retired) if ( notification.severity >= settings.application.gui_notification_level and _QtMainWindow.current() ): canvas = _QtMainWindow.current()._qt_viewer._welcome_widget cls.from_notification(notification, canvas).show() def _debug_tb(tb): import pdb from napari._qt.utils import event_hook_removed QApplication.processEvents() QApplication.processEvents() with event_hook_removed(): print("Entering debugger. Type 'q' to return to napari.\n") pdb.post_mortem(tb) print("\nDebugging finished. Napari active again.") class TracebackDialog(QDialog): def __init__(self, exception, parent=None) -> None: super().__init__(parent=parent) self.exception = exception self.setModal(True) self.setLayout(QVBoxLayout()) self.resize(650, 270) text = QTextEdit() theme = get_theme(get_settings().appearance.theme, as_dict=False) _highlight = Pylighter( # noqa: F841 text.document(), "python", theme.syntax_style ) text.setText(exception.as_text()) text.setReadOnly(True) self.btn = QPushButton(trans._('Enter Debugger')) self.btn.clicked.connect(self._enter_debug_mode) self.layout().addWidget(text) self.layout().addWidget(self.btn, 0, Qt.AlignmentFlag.AlignRight) def _enter_debug_mode(self): self.btn.setText( trans._( 'Now Debugging. Please quit debugger in console to continue' ) ) _debug_tb(self.exception.__traceback__) self.btn.setText(trans._('Enter Debugger')) napari-0.5.0a1/napari/_qt/dialogs/qt_package_installer.py000066400000000000000000000406541437041365600234370ustar00rootroot00000000000000""" A tool-agnostic installation logic for the plugin manager. The main object is `InstallerQueue`, a `QProcess` subclass with the notion of a job queue. The queued jobs are represented by a `deque` of `*InstallerTool` dataclasses that contain the executable path, arguments and environment modifications. Available actions for each tool are `install`, `uninstall` and `cancel`. """ import atexit import contextlib import os import sys from collections import deque from dataclasses import dataclass from enum import auto from functools import lru_cache from logging import getLogger from pathlib import Path from subprocess import call from tempfile import gettempdir, mkstemp from typing import Deque, Optional, Sequence, Tuple from npe2 import PluginManager from qtpy.QtCore import QObject, QProcess, QProcessEnvironment, Signal from qtpy.QtWidgets import QTextEdit from napari._version import version as _napari_version from napari._version import version_tuple as _napari_version_tuple from napari.plugins import plugin_manager from napari.plugins.pypi import _user_agent from napari.utils._appdirs import user_plugin_dir, user_site_packages from napari.utils.misc import StringEnum, running_as_bundled_app from napari.utils.translations import trans JobId = int log = getLogger(__name__) class InstallerActions(StringEnum): "Available actions for the plugin manager" INSTALL = auto() UNINSTALL = auto() CANCEL = auto() class InstallerTools(StringEnum): "Available tools for InstallerQueue jobs" CONDA = auto() PIP = auto() @dataclass(frozen=True) class AbstractInstallerTool: action: InstallerActions pkgs: Tuple[str, ...] origins: Tuple[str, ...] = () prefix: Optional[str] = None @property def ident(self): return hash((self.action, *self.pkgs, *self.origins, self.prefix)) # abstract method @classmethod def executable(cls): "Path to the executable that will run the task" raise NotImplementedError() # abstract method def arguments(self): "Arguments supplied to the executable" raise NotImplementedError() # abstract method def environment( self, env: QProcessEnvironment = None ) -> QProcessEnvironment: "Changes needed in the environment variables." raise NotImplementedError() @staticmethod def constraints() -> Sequence[str]: """ Version constraints to limit unwanted changes in installation. """ return [f"napari=={_napari_version}"] @classmethod def available(cls) -> bool: """ Check if the tool is available by performing a little test """ raise NotImplementedError() class PipInstallerTool(AbstractInstallerTool): @classmethod def executable(cls): return str(_get_python_exe()) @classmethod def available(cls): return call([cls.executable(), "-m", "pip", "--version"]) == 0 def arguments(self) -> Tuple[str, ...]: args = ['-m', 'pip'] if self.action == InstallerActions.INSTALL: args += ['install', '--upgrade', '-c', self._constraints_file()] for origin in self.origins: args += ['--extra-index-url', origin] elif self.action == InstallerActions.UNINSTALL: args += ['uninstall', '-y'] else: raise ValueError(f"Action '{self.action}' not supported!") if 10 <= log.getEffectiveLevel() < 30: # DEBUG level args.append('-vvv') if self.prefix is not None: args.extend(['--prefix', str(self.prefix)]) elif running_as_bundled_app( check_conda=False ) and sys.platform.startswith('linux'): args += [ '--no-warn-script-location', '--prefix', user_plugin_dir(), ] return (*args, *self.pkgs) def environment( self, env: QProcessEnvironment = None ) -> QProcessEnvironment: if env is None: env = QProcessEnvironment.systemEnvironment() combined_paths = os.pathsep.join( [ user_site_packages(), env.systemEnvironment().value("PYTHONPATH"), ] ) env.insert("PYTHONPATH", combined_paths) env.insert("PIP_USER_AGENT_USER_DATA", _user_agent()) return env @classmethod @lru_cache(maxsize=0) def _constraints_file(cls) -> str: _, path = mkstemp("-napari-constraints.txt", text=True) with open(path, "w") as f: f.write("\n".join(cls.constraints())) atexit.register(os.unlink, path) return path class CondaInstallerTool(AbstractInstallerTool): @classmethod def executable(cls): bat = ".bat" if os.name == "nt" else "" for path in ( Path(os.environ.get('MAMBA_EXE', '')), Path(os.environ.get('CONDA_EXE', '')), # $CONDA is usually only available on GitHub Actions Path(os.environ.get('CONDA', '')) / 'condabin' / f'conda{bat}', ): if path.is_file(): return str(path) return f'conda{bat}' # cross our fingers 'conda' is in PATH @classmethod def available(cls): executable = cls.executable() try: return call([executable, "--version"]) == 0 except FileNotFoundError: # pragma: no cover return False def arguments(self) -> Tuple[str, ...]: prefix = self.prefix or self._default_prefix() args = [self.action.value, '-y', '--prefix', prefix] args.append('--override-channels') for channel in (*self.origins, *self._default_channels()): args.extend(["-c", channel]) return (*args, *self.pkgs) def environment( self, env: QProcessEnvironment = None ) -> QProcessEnvironment: if env is None: env = QProcessEnvironment.systemEnvironment() self._add_constraints_to_env(env) if 10 <= log.getEffectiveLevel() < 30: # DEBUG level env.insert('CONDA_VERBOSITY', '3') if os.name == "nt": if not env.contains("TEMP"): temp = gettempdir() env.insert("TMP", temp) env.insert("TEMP", temp) if not env.contains("USERPROFILE"): env.insert("HOME", os.path.expanduser("~")) env.insert("USERPROFILE", os.path.expanduser("~")) return env @staticmethod def constraints() -> Sequence[str]: # FIXME # dev or rc versions might not be available in public channels # but only installed locally - if we try to pin those, mamba # will fail to pin it because there's no record of that version # in the remote index, only locally; to work around this bug # we will have to pin to e.g. 0.4.* instead of 0.4.17.* for now version_lower = _napari_version.lower() is_dev = "rc" in version_lower or "dev" in version_lower pin_level = 2 if is_dev else 3 version = ".".join([str(x) for x in _napari_version_tuple[:pin_level]]) return [f"napari={version}"] def _add_constraints_to_env( self, env: QProcessEnvironment ) -> QProcessEnvironment: PINNED = 'CONDA_PINNED_PACKAGES' constraints = self.constraints() if env.contains(PINNED): constraints.append(env.value(PINNED)) env.insert(PINNED, "&".join(constraints)) return env def _default_channels(self): return ('conda-forge',) def _default_prefix(self): if (Path(sys.prefix) / "conda-meta").is_dir(): return sys.prefix raise ValueError("Prefix has not been specified!") class InstallerQueue(QProcess): """Queue for installation and uninstallation tasks in the plugin manager.""" # emitted when all jobs are finished # not to be confused with finished, which is emitted when each job is finished allFinished = Signal() def __init__(self, parent: Optional[QObject] = None) -> None: super().__init__(parent) self._queue: Deque[AbstractInstallerTool] = deque() self._output_widget = None self.setProcessChannelMode(QProcess.MergedChannels) self.readyReadStandardOutput.connect(self._on_stdout_ready) self.readyReadStandardError.connect(self._on_stderr_ready) self.finished.connect(self._on_process_finished) self.errorOccurred.connect(self._on_error_occurred) # -------------------------- Public API ------------------------------ def install( self, tool: InstallerTools, pkgs: Sequence[str], *, prefix: Optional[str] = None, origins: Sequence[str] = (), **kwargs, ) -> JobId: """Install packages in `pkgs` into `prefix` using `tool` with additional `origins` as source for `pkgs`. Parameters ---------- tool : InstallerTools Which type of installation tool to use. pkgs : Sequence[str] List of packages to install. prefix : Optional[str], optional Optional prefix to install packages into. origins : Optional[Sequence[str]], optional Additional sources for packages to be downloaded from. Returns ------- JobId : int ID that can be used to cancel the process. """ item = self._build_queue_item( tool=tool, action=InstallerActions.INSTALL, pkgs=pkgs, prefix=prefix, origins=origins, **kwargs, ) return self._queue_item(item) def uninstall( self, tool: InstallerTools, pkgs: Sequence[str], *, prefix: Optional[str] = None, **kwargs, ) -> JobId: """Uninstall packages in `pkgs` from `prefix` using `tool`. Parameters ---------- tool : InstallerTools Which type of installation tool to use. pkgs : Sequence[str] List of packages to uninstall. prefix : Optional[str], optional Optional prefix from which to uninstall packages. Returns ------- JobId : int ID that can be used to cancel the process. """ item = self._build_queue_item( tool=tool, action=InstallerActions.UNINSTALL, pkgs=pkgs, prefix=prefix, **kwargs, ) return self._queue_item(item) def cancel(self, job_id: Optional[JobId] = None): """Cancel `job_id` if it is running. Parameters ---------- job_id : Optional[JobId], optional Job ID to cancel. If not provided, cancel all jobs. """ if job_id is None: # cancel all jobs self._queue.clear() self._end_process() return for i, item in enumerate(self._queue): if item.ident == job_id: if i == 0: # first in queue, currently running self._end_process() else: # still pending, just remove from queue self._queue.remove(item) return msg = f"No job with id {job_id}. Current queue:\n - " msg += "\n - ".join( [ f"{item.ident} -> {item.executable()} {item.arguments()}" for item in self._queue ] ) raise ValueError(msg) def waitForFinished(self, msecs: int = 10000) -> bool: """Block and wait for all jobs to finish. Parameters ---------- msecs : int, optional Time to wait, by default 10000 """ while self.hasJobs(): super().waitForFinished(msecs) return True def hasJobs(self) -> bool: """True if there are jobs remaining in the queue.""" return bool(self._queue) def set_output_widget(self, output_widget: QTextEdit): if output_widget: self._output_widget = output_widget # -------------------------- Private methods ------------------------------ def _log(self, msg: str): log.debug(msg) if self._output_widget: self._output_widget.append(msg) def _get_tool(self, tool: InstallerTools): if tool == InstallerTools.PIP: return PipInstallerTool if tool == InstallerTools.CONDA: return CondaInstallerTool raise ValueError(f"InstallerTool {tool} not recognized!") def _build_queue_item( self, tool: InstallerTools, action: InstallerActions, pkgs: Sequence[str], prefix: Optional[str] = None, origins: Sequence[str] = (), **kwargs, ) -> AbstractInstallerTool: return self._get_tool(tool)( pkgs=pkgs, action=action, origins=origins, prefix=prefix, **kwargs ) def _queue_item(self, item: AbstractInstallerTool) -> JobId: self._queue.append(item) self._process_queue() return item.ident def _process_queue(self): if not self._queue: self.allFinished.emit() return tool = self._queue[0] self.setProgram(str(tool.executable())) self.setProcessEnvironment(tool.environment()) self.setArguments([str(arg) for arg in tool.arguments()]) # this might throw a warning because the same process # was already running but it's ok self._log( trans._( "Starting '{program}' with args {args}", program=self.program(), args=self.arguments(), ) ) self.start() def _end_process(self): if os.name == 'nt': # TODO: this might be too agressive and won't allow rollbacks! # investigate whether we can also do .terminate() self.kill() else: self.terminate() if self._output_widget: self._output_widget.append( trans._("\nTask was cancelled by the user.") ) def _on_process_finished( self, exit_code: int, exit_status: QProcess.ExitStatus ): try: current = self._queue[0] except IndexError: current = None if ( current and current.action == InstallerActions.UNINSTALL and exit_status == QProcess.ExitStatus.NormalExit and exit_code == 0 ): pm2 = PluginManager.instance() npe1_plugins = set(plugin_manager.iter_available()) for pkg in current.pkgs: if pkg in pm2: pm2.unregister(pkg) elif pkg in npe1_plugins: plugin_manager.unregister(pkg) else: log.warning( 'Cannot unregister %s, not a known napari plugin.', pkg ) self._on_process_done(exit_code=exit_code, exit_status=exit_status) def _on_error_occurred(self, error: QProcess.ProcessError): self._on_process_done(error=error) def _on_process_done( self, exit_code: Optional[int] = None, exit_status: Optional[QProcess.ExitStatus] = None, error: Optional[QProcess.ProcessError] = None, ): with contextlib.suppress(IndexError): self._queue.popleft() if error: msg = trans._( "Task finished with errors! Error: {error}.", error=error ) else: msg = trans._( "Task finished with exit code {exit_code} with status {exit_status}.", exit_code=exit_code, exit_status=exit_status, ) self._log(msg) self._process_queue() def _on_stdout_ready(self): text = self.readAllStandardOutput().data().decode() if text: self._log(text) def _on_stderr_ready(self): text = self.readAllStandardError().data().decode() if text: self._log(text) def _get_python_exe(): # Note: is_bundled_app() returns False even if using a Briefcase bundle... # Workaround: see if sys.executable is set to something something napari on Mac if sys.executable.endswith("napari") and sys.platform == 'darwin': # sys.prefix should be /Contents/Resources/Support/Python/Resources if (python := Path(sys.prefix) / "bin" / "python3").is_file(): return str(python) return sys.executable napari-0.5.0a1/napari/_qt/dialogs/qt_plugin_dialog.py000066400000000000000000000723021437041365600225770ustar00rootroot00000000000000import os from enum import Enum, auto from functools import partial from importlib.metadata import PackageNotFoundError, metadata from pathlib import Path from typing import Optional, Sequence, Tuple from npe2 import PackageMetadata, PluginManager from qtpy.QtCore import QEvent, QPoint, QSize, Qt, Slot from qtpy.QtGui import QFont, QMovie from qtpy.QtWidgets import ( QCheckBox, QDialog, QFrame, QHBoxLayout, QLabel, QLineEdit, QListWidget, QListWidgetItem, QPushButton, QSizePolicy, QSplitter, QTextEdit, QVBoxLayout, QWidget, ) from superqt import QElidingLabel import napari.resources from napari._qt.dialogs.qt_package_installer import ( InstallerActions, InstallerQueue, InstallerTools, ) from napari._qt.qt_resources import QColoredSVGIcon from napari._qt.qthreading import create_worker from napari._qt.widgets.qt_message_popup import WarnPopup from napari._qt.widgets.qt_tooltip import QtToolTipLabel from napari.plugins import plugin_manager from napari.plugins.hub import iter_hub_plugin_info from napari.plugins.pypi import iter_napari_plugin_info from napari.plugins.utils import normalized_name from napari.settings import get_settings from napari.utils.misc import ( parse_version, running_as_bundled_app, running_as_constructor_app, ) from napari.utils.translations import trans class PluginListItem(QFrame): def __init__( self, package_name: str, version: str = '', url: str = '', summary: str = '', author: str = '', license: str = "UNKNOWN", *, plugin_name: str = None, parent: QWidget = None, enabled: bool = True, installed: bool = False, npe_version=1, ) -> None: super().__init__(parent) self.setup_ui(enabled) self.plugin_name.setText(package_name) self.package_name.setText(version) self.summary.setText(summary) self.package_author.setText(author) self.cancel_btn.setVisible(False) self.help_button.setText(trans._("Website")) self.help_button.setObjectName("help_button") self._handle_npe2_plugin(npe_version) if installed: self.enabled_checkbox.show() self.action_button.setText(trans._("uninstall")) self.action_button.setObjectName("remove_button") else: self.enabled_checkbox.hide() self.action_button.setText(trans._("install")) self.action_button.setObjectName("install_button") def _handle_npe2_plugin(self, npe_version): if npe_version in (None, 1): return opacity = 0.4 if npe_version == 'shim' else 1 lbl = trans._('npe1 (adapted)') if npe_version == 'shim' else 'npe2' npe2_icon = QLabel(self) icon = QColoredSVGIcon.from_resources('logo_silhouette') npe2_icon.setPixmap( icon.colored(color='#33F0FF', opacity=opacity).pixmap(20, 20) ) self.row1.insertWidget(2, QLabel(lbl)) self.row1.insertWidget(2, npe2_icon) def _get_dialog(self) -> QDialog: p = self.parent() while not isinstance(p, QDialog) and p.parent(): p = p.parent() return p def set_busy(self, text: str, update: bool = False): self.item_status.setText(text) self.cancel_btn.setVisible(True) if not update: self.action_button.setVisible(False) else: self.update_btn.setVisible(False) def setup_ui(self, enabled=True): self.v_lay = QVBoxLayout(self) self.v_lay.setContentsMargins(-1, 6, -1, 6) self.v_lay.setSpacing(0) self.row1 = QHBoxLayout() self.row1.setSpacing(6) self.enabled_checkbox = QCheckBox(self) self.enabled_checkbox.setChecked(enabled) self.enabled_checkbox.stateChanged.connect(self._on_enabled_checkbox) self.enabled_checkbox.setToolTip(trans._("enable/disable")) sizePolicy = QSizePolicy(QSizePolicy.Fixed, QSizePolicy.Fixed) sizePolicy.setHorizontalStretch(0) sizePolicy.setVerticalStretch(0) sizePolicy.setHeightForWidth( self.enabled_checkbox.sizePolicy().hasHeightForWidth() ) self.enabled_checkbox.setSizePolicy(sizePolicy) self.enabled_checkbox.setMinimumSize(QSize(20, 0)) self.enabled_checkbox.setText("") self.row1.addWidget(self.enabled_checkbox) self.plugin_name = QLabel(self) sizePolicy = QSizePolicy(QSizePolicy.Preferred, QSizePolicy.Minimum) sizePolicy.setHorizontalStretch(0) sizePolicy.setVerticalStretch(0) sizePolicy.setHeightForWidth( self.plugin_name.sizePolicy().hasHeightForWidth() ) self.plugin_name.setSizePolicy(sizePolicy) font15 = QFont() font15.setPointSize(15) self.plugin_name.setFont(font15) self.row1.addWidget(self.plugin_name) icon = QColoredSVGIcon.from_resources("warning") self.warning_tooltip = QtToolTipLabel(self) # TODO: This color should come from the theme but the theme needs # to provide the right color. Default warning should be orange, not # red. Code example: # theme_name = get_settings().appearance.theme # napari.utils.theme.get_theme(theme_name, as_dict=False).warning.as_hex() self.warning_tooltip.setPixmap( icon.colored(color="#E3B617").pixmap(15, 15) ) self.warning_tooltip.setVisible(False) self.row1.addWidget(self.warning_tooltip) self.item_status = QLabel(self) self.item_status.setObjectName("small_italic_text") self.item_status.setSizePolicy(sizePolicy) self.row1.addWidget(self.item_status) self.row1.addStretch() self.package_name = QLabel(self) self.package_name.setAlignment( Qt.AlignmentFlag.AlignRight | Qt.AlignmentFlag.AlignTrailing | Qt.AlignmentFlag.AlignVCenter ) self.row1.addWidget(self.package_name) self.cancel_btn = QPushButton("cancel", self) self.cancel_btn.setSizePolicy(QSizePolicy.Fixed, QSizePolicy.Fixed) self.cancel_btn.setObjectName("remove_button") self.row1.addWidget(self.cancel_btn) self.update_btn = QPushButton(self) self.update_btn.setSizePolicy(QSizePolicy.Fixed, QSizePolicy.Fixed) self.update_btn.setObjectName("install_button") self.row1.addWidget(self.update_btn) self.update_btn.setVisible(False) self.help_button = QPushButton(self) self.action_button = QPushButton(self) sizePolicy = QSizePolicy(QSizePolicy.Fixed, QSizePolicy.Fixed) sizePolicy.setHorizontalStretch(0) sizePolicy.setVerticalStretch(0) sizePolicy.setHeightForWidth( self.action_button.sizePolicy().hasHeightForWidth() ) self.help_button.setSizePolicy(sizePolicy) self.action_button.setSizePolicy(sizePolicy) self.row1.addWidget(self.help_button) self.row1.addWidget(self.action_button) self.v_lay.addLayout(self.row1) self.row2 = QHBoxLayout() self.error_indicator = QPushButton() self.error_indicator.setObjectName("warning_icon") self.error_indicator.setCursor(Qt.CursorShape.PointingHandCursor) self.error_indicator.hide() self.row2.addWidget(self.error_indicator) self.row2.setContentsMargins(-1, 4, 0, -1) self.summary = QElidingLabel(parent=self) sizePolicy = QSizePolicy( QSizePolicy.MinimumExpanding, QSizePolicy.Preferred ) sizePolicy.setHorizontalStretch(0) sizePolicy.setVerticalStretch(0) sizePolicy.setHeightForWidth( self.summary.sizePolicy().hasHeightForWidth() ) self.summary.setSizePolicy(sizePolicy) self.summary.setObjectName("small_text") self.row2.addWidget(self.summary) self.package_author = QLabel(self) sizePolicy = QSizePolicy(QSizePolicy.Preferred, QSizePolicy.Preferred) sizePolicy.setHorizontalStretch(0) sizePolicy.setVerticalStretch(0) sizePolicy.setHeightForWidth( self.package_author.sizePolicy().hasHeightForWidth() ) self.package_author.setSizePolicy(sizePolicy) self.package_author.setObjectName("small_text") self.row2.addWidget(self.package_author) self.v_lay.addLayout(self.row2) def _on_enabled_checkbox(self, state: int): """Called with `state` when checkbox is clicked.""" enabled = bool(state) plugin_name = self.plugin_name.text() pm2 = PluginManager.instance() if plugin_name in pm2: pm2.enable(plugin_name) if state else pm2.disable(plugin_name) return for npe1_name, _, distname in plugin_manager.iter_available(): if distname and (normalized_name(distname) == plugin_name): plugin_manager.set_blocked(npe1_name, not enabled) def show_warning(self, message: str = ""): """Show warning icon and tooltip.""" self.warning_tooltip.setVisible(bool(message)) self.warning_tooltip.setToolTip(message) class QPluginList(QListWidget): def __init__(self, parent: QWidget, installer: InstallerQueue) -> None: super().__init__(parent) self.installer = installer self.setSortingEnabled(True) self._remove_list = [] def _count_visible(self) -> int: """Return the number of visible items. Visible items are the result of the normal `count` method minus any hidden items. """ hidden = 0 count = self.count() for i in range(count): item = self.item(i) hidden += item.isHidden() return count - hidden @Slot(PackageMetadata) def addItem( self, project_info: PackageMetadata, installed=False, plugin_name=None, enabled=True, npe_version=None, ): pkg_name = project_info.name # don't add duplicates if ( self.findItems(pkg_name, Qt.MatchFlag.MatchFixedString) and not plugin_name ): return # including summary here for sake of filtering below. searchable_text = f"{pkg_name} {project_info.summary}" item = QListWidgetItem(searchable_text, self) item.version = project_info.version super().addItem(item) widg = PluginListItem( package_name=pkg_name, version=project_info.version, url=project_info.home_page, summary=project_info.summary, author=project_info.author, license=project_info.license, parent=self, plugin_name=plugin_name, enabled=enabled, installed=installed, npe_version=npe_version, ) item.widget = widg item.npe_version = npe_version action_name = 'uninstall' if installed else 'install' item.setSizeHint(widg.sizeHint()) self.setItemWidget(item, widg) if project_info.home_page: import webbrowser widg.help_button.clicked.connect( partial(webbrowser.open, project_info.home_page) ) else: widg.help_button.setVisible(False) widg.action_button.clicked.connect( partial(self.handle_action, item, pkg_name, action_name) ) widg.update_btn.clicked.connect( partial( self.handle_action, item, pkg_name, InstallerActions.INSTALL, update=True, ) ) widg.cancel_btn.clicked.connect( partial( self.handle_action, item, pkg_name, InstallerActions.CANCEL ) ) item.setSizeHint(widg.sizeHint()) self.setItemWidget(item, widg) def handle_action( self, item: QListWidgetItem, pkg_name: str, action_name: InstallerActions, update: bool = False, ): # TODO: 'tool' should be configurable per item, depending on dropdown tool = ( InstallerTools.CONDA if running_as_constructor_app() else InstallerTools.PIP ) widget = item.widget item.setText(f"0-{item.text()}") self._remove_list.append((pkg_name, item)) self._warn_dialog = None # TODO: NPE version unknown before installing if item.npe_version != 1 and action_name == InstallerActions.UNINSTALL: # show warning pop up dialog message = trans._( 'When installing/uninstalling npe2 plugins, you must ' 'restart napari for UI changes to take effect.' ) self._warn_dialog = WarnPopup(text=message) delta_x = 75 global_point = widget.action_button.mapToGlobal( widget.action_button.rect().topLeft() ) global_point = QPoint(global_point.x() - delta_x, global_point.y()) self._warn_dialog.move(global_point) if action_name == InstallerActions.INSTALL: if update: if hasattr(item, 'latest_version'): pkg_name += f"=={item.latest_version}" widget.set_busy(trans._("updating..."), update) widget.action_button.setDisabled(True) else: widget.set_busy(trans._("installing..."), update) job_id = self.installer.install( tool=tool, pkgs=[pkg_name], # origins="TODO", ) widget.setProperty("current_job_id", job_id) if self._warn_dialog: self._warn_dialog.exec_() self.scrollToTop() elif action_name == InstallerActions.UNINSTALL: widget.set_busy(trans._("uninstalling..."), update) widget.update_btn.setDisabled(True) job_id = self.installer.uninstall( tool=tool, pkgs=[pkg_name], # origins="TODO", ) widget.setProperty("current_job_id", job_id) if self._warn_dialog: self._warn_dialog.exec_() self.scrollToTop() elif action_name == InstallerActions.CANCEL: widget.set_busy(trans._("cancelling..."), update) try: job_id = widget.property("current_job_id") self.installer.cancel(job_id) finally: widget.setProperty("current_job_id", None) @Slot(PackageMetadata, bool) def tag_outdated(self, project_info: PackageMetadata, is_available: bool): if not is_available: return for item in self.findItems( project_info.name, Qt.MatchFlag.MatchStartsWith ): current = item.version latest = project_info.version if parse_version(current) >= parse_version(latest): continue if hasattr(item, 'outdated'): # already tagged it continue item.outdated = True item.latest_version = latest widg = self.itemWidget(item) widg.update_btn.setVisible(True) widg.update_btn.setText( trans._("update (v{latest})", latest=latest) ) def tag_unavailable(self, project_info: PackageMetadata): """ Tag list items as unavailable for install with conda-forge. This will disable the item and the install button and add a warning icon with a hover tooltip. """ for item in self.findItems( project_info.name, Qt.MatchFlag.MatchStartsWith ): widget = self.itemWidget(item) widget.show_warning( trans._( "Plugin not yet available for installation within the bundle application" ) ) widget.setObjectName("unavailable") widget.style().unpolish(widget) widget.style().polish(widget) widget.action_button.setEnabled(False) widget.warning_tooltip.setVisible(True) def filter(self, text: str): """Filter items to those containing `text`.""" if text: # PySide has some issues, so we compare using id # See: https://bugreports.qt.io/browse/PYSIDE-74 shown = [ id(it) for it in self.findItems(text, Qt.MatchFlag.MatchContains) ] for i in range(self.count()): item = self.item(i) item.setHidden(id(item) not in shown) else: for i in range(self.count()): item = self.item(i) item.setHidden(False) class RefreshState(Enum): REFRESHING = auto() OUTDATED = auto() DONE = auto() class QtPluginDialog(QDialog): def __init__(self, parent=None) -> None: super().__init__(parent) self.refresh_state = RefreshState.DONE self.already_installed = set() self.installer = InstallerQueue() self.setWindowTitle(trans._('Plugin Manager')) self.setup_ui() self.installer.set_output_widget(self.stdout_text) self.installer.started.connect(self._on_installer_start) self.installer.finished.connect(self._on_installer_done) self.refresh() def _on_installer_start(self): self.cancel_all_btn.setVisible(True) self.working_indicator.show() self.process_success_indicator.hide() self.process_error_indicator.hide() self.close_btn.setDisabled(True) def _on_installer_done(self, exit_code): self.working_indicator.hide() if exit_code: self.process_error_indicator.show() else: self.process_success_indicator.show() self.cancel_all_btn.setVisible(False) self.close_btn.setDisabled(False) self.refresh() def closeEvent(self, event): if self.close_btn.isEnabled(): super().closeEvent(event) event.ignore() def refresh(self): if self.refresh_state != RefreshState.DONE: self.refresh_state = RefreshState.OUTDATED return self.refresh_state = RefreshState.REFRESHING self.installed_list.clear() self.available_list.clear() # fetch installed from npe2 import PluginManager from napari.plugins import plugin_manager self.already_installed = set() def _add_to_installed(distname, enabled, npe_version=1): norm_name = normalized_name(distname or '') if distname: try: meta = metadata(distname) except PackageNotFoundError: self.refresh_state = RefreshState.OUTDATED return # a race condition has occurred and the package is uninstalled by another thread if len(meta) == 0: # will not add builtins. return self.already_installed.add(norm_name) else: meta = {} self.installed_list.addItem( PackageMetadata( metadata_version="1.0", name=norm_name, version=meta.get('version', ''), summary=meta.get('summary', ''), home_page=meta.get('url', ''), author=meta.get('author', ''), license=meta.get('license', ''), ), installed=True, enabled=enabled, npe_version=npe_version, ) pm2 = PluginManager.instance() discovered = pm2.discover() for manifest in pm2.iter_manifests(): distname = normalized_name(manifest.name or '') if distname in self.already_installed or distname == 'napari': continue enabled = not pm2.is_disabled(manifest.name) # if it's an Npe1 adaptor, call it v1 npev = 'shim' if manifest.npe1_shim else 2 _add_to_installed(distname, enabled, npe_version=npev) plugin_manager.discover() # since they might not be loaded yet for plugin_name, _, distname in plugin_manager.iter_available(): # not showing these in the plugin dialog if plugin_name in ('napari_plugin_engine',): continue if normalized_name(distname or '') in self.already_installed: continue _add_to_installed( distname, not plugin_manager.is_blocked(plugin_name) ) self.installed_label.setText( trans._( "Installed Plugins ({amount})", amount=len(self.already_installed), ) ) # fetch available plugins settings = get_settings() use_hub = ( running_as_bundled_app() or running_as_constructor_app() or settings.plugins.plugin_api.name == "napari_hub" ) if use_hub: conda_forge = running_as_constructor_app() self.worker = create_worker( iter_hub_plugin_info, conda_forge=conda_forge ) else: self.worker = create_worker(iter_napari_plugin_info) self.worker.yielded.connect(self._handle_yield) self.worker.finished.connect(self.working_indicator.hide) self.worker.finished.connect(self._update_count_in_label) self.worker.finished.connect(self._end_refresh) self.worker.start() if discovered: message = trans._( 'When installing/uninstalling npe2 plugins, ' 'you must restart napari for UI changes to take effect.' ) self._warn_dialog = WarnPopup(text=message) global_point = self.process_error_indicator.mapToGlobal( self.process_error_indicator.rect().topLeft() ) global_point = QPoint(global_point.x(), global_point.y() - 75) self._warn_dialog.move(global_point) self._warn_dialog.exec_() def setup_ui(self): self.resize(1080, 640) vlay_1 = QVBoxLayout(self) self.h_splitter = QSplitter(self) vlay_1.addWidget(self.h_splitter) self.h_splitter.setOrientation(Qt.Orientation.Horizontal) self.v_splitter = QSplitter(self.h_splitter) self.v_splitter.setOrientation(Qt.Orientation.Vertical) self.v_splitter.setMinimumWidth(500) installed = QWidget(self.v_splitter) lay = QVBoxLayout(installed) lay.setContentsMargins(0, 2, 0, 2) self.installed_label = QLabel(trans._("Installed Plugins")) self.packages_filter = QLineEdit() self.packages_filter.setPlaceholderText(trans._("filter...")) self.packages_filter.setMaximumWidth(350) self.packages_filter.setClearButtonEnabled(True) mid_layout = QVBoxLayout() mid_layout.addWidget(self.packages_filter) mid_layout.addWidget(self.installed_label) lay.addLayout(mid_layout) self.installed_list = QPluginList(installed, self.installer) self.packages_filter.textChanged.connect(self.installed_list.filter) lay.addWidget(self.installed_list) uninstalled = QWidget(self.v_splitter) lay = QVBoxLayout(uninstalled) lay.setContentsMargins(0, 2, 0, 2) self.avail_label = QLabel(trans._("Available Plugins")) mid_layout = QHBoxLayout() mid_layout.addWidget(self.avail_label) mid_layout.addStretch() lay.addLayout(mid_layout) self.available_list = QPluginList(uninstalled, self.installer) self.packages_filter.textChanged.connect(self.available_list.filter) lay.addWidget(self.available_list) self.stdout_text = QTextEdit(self.v_splitter) self.stdout_text.setReadOnly(True) self.stdout_text.setObjectName("plugin_manager_process_status") self.stdout_text.hide() buttonBox = QHBoxLayout() self.working_indicator = QLabel(trans._("loading ..."), self) sp = self.working_indicator.sizePolicy() sp.setRetainSizeWhenHidden(True) self.working_indicator.setSizePolicy(sp) self.process_error_indicator = QLabel(self) self.process_error_indicator.setObjectName("error_label") self.process_error_indicator.hide() self.process_success_indicator = QLabel(self) self.process_success_indicator.setObjectName("success_label") self.process_success_indicator.hide() load_gif = str(Path(napari.resources.__file__).parent / "loading.gif") mov = QMovie(load_gif) mov.setScaledSize(QSize(18, 18)) self.working_indicator.setMovie(mov) mov.start() visibility_direct_entry = not running_as_constructor_app() self.direct_entry_edit = QLineEdit(self) self.direct_entry_edit.installEventFilter(self) self.direct_entry_edit.setPlaceholderText( trans._('install by name/url, or drop file...') ) self.direct_entry_edit.setVisible(visibility_direct_entry) self.direct_entry_btn = QPushButton(trans._("Install"), self) self.direct_entry_btn.setVisible(visibility_direct_entry) self.direct_entry_btn.clicked.connect(self._install_packages) self.show_status_btn = QPushButton(trans._("Show Status"), self) self.show_status_btn.setFixedWidth(100) self.cancel_all_btn = QPushButton(trans._("cancel all actions"), self) self.cancel_all_btn.setObjectName("remove_button") self.cancel_all_btn.setVisible(False) self.cancel_all_btn.clicked.connect(self.installer.cancel) self.close_btn = QPushButton(trans._("Close"), self) self.close_btn.clicked.connect(self.accept) self.close_btn.setObjectName("close_button") buttonBox.addWidget(self.show_status_btn) buttonBox.addWidget(self.working_indicator) buttonBox.addWidget(self.direct_entry_edit) buttonBox.addWidget(self.direct_entry_btn) if not visibility_direct_entry: buttonBox.addStretch() buttonBox.addWidget(self.process_success_indicator) buttonBox.addWidget(self.process_error_indicator) buttonBox.addSpacing(20) buttonBox.addWidget(self.cancel_all_btn) buttonBox.addSpacing(20) buttonBox.addWidget(self.close_btn) buttonBox.setContentsMargins(0, 0, 4, 0) vlay_1.addLayout(buttonBox) self.show_status_btn.setCheckable(True) self.show_status_btn.setChecked(False) self.show_status_btn.toggled.connect(self._toggle_status) self.v_splitter.setStretchFactor(1, 2) self.h_splitter.setStretchFactor(0, 2) self.packages_filter.setFocus() def _update_count_in_label(self): count = self.available_list.count() self.avail_label.setText( trans._("Available Plugins ({count})", count=count) ) def _end_refresh(self): refresh_state = self.refresh_state self.refresh_state = RefreshState.DONE if refresh_state == RefreshState.OUTDATED: self.refresh() def eventFilter(self, watched, event): if event.type() == QEvent.DragEnter: # we need to accept this event explicitly to be able # to receive QDropEvents! event.accept() if event.type() == QEvent.Drop: md = event.mimeData() if md.hasUrls(): files = [url.toLocalFile() for url in md.urls()] self.direct_entry_edit.setText(files[0]) return True return super().eventFilter(watched, event) def _toggle_status(self, show): if show: self.show_status_btn.setText(trans._("Hide Status")) self.stdout_text.show() else: self.show_status_btn.setText(trans._("Show Status")) self.stdout_text.hide() def _install_packages(self, packages: Sequence[str] = ()): if not packages: _packages = self.direct_entry_edit.text() packages = ( [_packages] if os.path.exists(_packages) else _packages.split() ) self.direct_entry_edit.clear() if packages: self.installer.install(packages) def _handle_yield(self, data: Tuple[PackageMetadata, bool]): project_info, is_available = data if project_info.name in self.already_installed: self.installed_list.tag_outdated(project_info, is_available) else: self.available_list.addItem(project_info) if not is_available: self.available_list.tag_unavailable(project_info) self.filter() def filter(self, text: Optional[str] = None) -> None: """Filter by text or set current text as filter.""" if text is None: text = self.packages_filter.text() else: self.packages_filter.setText(text) self.installed_list.filter(text) self.available_list.filter(text) if __name__ == "__main__": from qtpy.QtWidgets import QApplication app = QApplication([]) w = QtPluginDialog() w.show() app.exec_() napari-0.5.0a1/napari/_qt/dialogs/qt_plugin_report.py000066400000000000000000000174361437041365600226620ustar00rootroot00000000000000"""Provides a QtPluginErrReporter that allows the user report plugin errors. """ from typing import Optional from napari_plugin_engine import standard_metadata from qtpy.QtCore import Qt from qtpy.QtGui import QGuiApplication from qtpy.QtWidgets import ( QComboBox, QDialog, QHBoxLayout, QLabel, QPushButton, QTextEdit, QVBoxLayout, QWidget, ) from napari._qt.code_syntax_highlight import Pylighter from napari.plugins.exceptions import format_exceptions from napari.settings import get_settings from napari.utils.theme import get_theme from napari.utils.translations import trans class QtPluginErrReporter(QDialog): """Dialog that allows users to review and report PluginError tracebacks. Parameters ---------- parent : QWidget, optional Optional parent widget for this widget. initial_plugin : str, optional If provided, errors from ``initial_plugin`` will be shown when the dialog is created, by default None Attributes ---------- text_area : qtpy.QtWidgets.QTextEdit The text area where traceback information will be shown. plugin_combo : qtpy.QtWidgets.QComboBox The dropdown menu used to select the current plugin github_button : qtpy.QtWidgets.QPushButton A button that, when pressed, will open an issue at the current plugin's github issue tracker, prepopulated with a formatted traceback. Button is only visible if a github URL is detected in the package metadata for the current plugin. clipboard_button : qtpy.QtWidgets.QPushButton A button that, when pressed, copies the current traceback information to the clipboard. (HTML tags are removed in the copied text.) plugin_meta : qtpy.QtWidgets.QLabel A label that will show available plugin metadata (such as home page). """ NULL_OPTION = trans._('select plugin... ') def __init__( self, *, parent: Optional[QWidget] = None, initial_plugin: Optional[str] = None, ) -> None: super().__init__(parent) from napari.plugins import plugin_manager self.plugin_manager = plugin_manager self.setWindowTitle(trans._('Recorded Plugin Exceptions')) self.setWindowModality(Qt.WindowModality.NonModal) self.layout = QVBoxLayout() self.layout.setSpacing(0) self.layout.setContentsMargins(10, 10, 10, 10) self.setLayout(self.layout) self.text_area = QTextEdit() theme = get_theme(get_settings().appearance.theme, as_dict=False) self._highlight = Pylighter( self.text_area.document(), "python", theme.syntax_style ) self.text_area.setTextInteractionFlags( Qt.TextInteractionFlag.TextSelectableByMouse ) self.text_area.setMinimumWidth(360) # Create plugin dropdown menu self.plugin_combo = QComboBox() self.plugin_combo.addItem(self.NULL_OPTION) bad_plugins = [e.plugin_name for e in self.plugin_manager.get_errors()] self.plugin_combo.addItems(list(sorted(set(bad_plugins)))) self.plugin_combo.currentTextChanged.connect(self.set_plugin) self.plugin_combo.setCurrentText(self.NULL_OPTION) # create github button (gets connected in self.set_plugin) self.github_button = QPushButton(trans._('Open issue on GitHub'), self) self.github_button.setToolTip( trans._( "Open a web browser to submit this error log\nto the developer's GitHub issue tracker", ) ) self.github_button.hide() # create copy to clipboard button self.clipboard_button = QPushButton() self.clipboard_button.hide() self.clipboard_button.setObjectName("QtCopyToClipboardButton") self.clipboard_button.setToolTip( trans._("Copy error log to clipboard") ) self.clipboard_button.clicked.connect(self.copyToClipboard) # plugin_meta contains a URL to the home page, (and/or other details) self.plugin_meta = QLabel('', parent=self) self.plugin_meta.setObjectName("pluginInfo") self.plugin_meta.setTextFormat(Qt.TextFormat.RichText) self.plugin_meta.setTextInteractionFlags( Qt.TextInteractionFlag.TextBrowserInteraction ) self.plugin_meta.setOpenExternalLinks(True) self.plugin_meta.setAlignment(Qt.AlignmentFlag.AlignRight) # make layout row_1_layout = QHBoxLayout() row_1_layout.setContentsMargins(11, 5, 10, 0) row_1_layout.addStretch(1) row_1_layout.addWidget(self.plugin_meta) row_2_layout = QHBoxLayout() row_2_layout.setContentsMargins(11, 5, 10, 0) row_2_layout.addWidget(self.plugin_combo) row_2_layout.addStretch(1) row_2_layout.addWidget(self.github_button) row_2_layout.addWidget(self.clipboard_button) row_2_layout.setSpacing(5) self.layout.addLayout(row_1_layout) self.layout.addLayout(row_2_layout) self.layout.addWidget(self.text_area, 1) self.setMinimumWidth(750) self.setMinimumHeight(600) if initial_plugin: self.set_plugin(initial_plugin) def set_plugin(self, plugin: str) -> None: """Set the current plugin shown in the dropdown and text area. Parameters ---------- plugin : str name of a plugin that has created an error this session. """ self.github_button.hide() self.clipboard_button.hide() try: self.github_button.clicked.disconnect() # when disconnecting a non-existent signal # PySide2 raises runtimeError, PyQt5 raises TypeError except (RuntimeError, TypeError): pass if not plugin or (plugin == self.NULL_OPTION): self.plugin_meta.setText('') self.text_area.setText('') return if not self.plugin_manager.get_errors(plugin): raise ValueError( trans._( "No errors reported for plugin '{plugin}'", plugin=plugin ) ) self.plugin_combo.setCurrentText(plugin) err_string = format_exceptions(plugin, as_html=False, color="NoColor") self.text_area.setText(err_string) self.clipboard_button.show() # set metadata and outbound links/buttons err0 = self.plugin_manager.get_errors(plugin)[0] meta = standard_metadata(err0.plugin) if err0.plugin else {} meta_text = '' if not meta: self.plugin_meta.setText(meta_text) return url = meta.get('url') if url: meta_text += ( 'plugin home page:  ' f'{url}' ) if 'github.com' in url: def onclick(): import webbrowser err = format_exceptions(plugin, as_html=False) err = ( "\n\n\n\n" "
\nTraceback from napari" f"\n\n```\n{err}\n```\n
" ) url = f'{meta.get("url")}/issues/new?&body={err}' webbrowser.open(url, new=2) self.github_button.clicked.connect(onclick) self.github_button.show() self.plugin_meta.setText(meta_text) def copyToClipboard(self) -> None: """Copy current plugin traceback info to clipboard as plain text.""" plugin = self.plugin_combo.currentText() err_string = format_exceptions(plugin, as_html=False) cb = QGuiApplication.clipboard() cb.setText(err_string) napari-0.5.0a1/napari/_qt/dialogs/qt_reader_dialog.py000066400000000000000000000235221437041365600225430ustar00rootroot00000000000000import os from typing import Dict, List, Optional, Tuple, Union from qtpy.QtWidgets import ( QButtonGroup, QCheckBox, QDialog, QDialogButtonBox, QLabel, QRadioButton, QVBoxLayout, QWidget, ) from napari.errors import ReaderPluginError from napari.plugins.utils import get_potential_readers from napari.settings import get_settings from napari.utils.translations import trans class QtReaderDialog(QDialog): """Dialog for user to select a reader plugin for a given file extension or folder""" def __init__( self, pth: str = '', parent: QWidget = None, readers: Dict[str, str] = None, error_message: str = '', persist_checked: bool = True, ) -> None: if readers is None: readers = {} super().__init__(parent) self.setObjectName('Choose reader') self.setWindowTitle(trans._('Choose reader')) self._current_file = pth self._extension = os.path.splitext(pth)[1] self._persist_text = trans._( 'Remember this choice for files with a {extension} extension', extension=self._extension, ) if os.path.isdir(pth): self._extension = os.path.realpath(pth) if not self._extension.endswith( '.zarr' ) and not self._extension.endswith(os.sep): self._extension = self._extension + os.sep self._persist_text = trans._( 'Remember this choice for folders labeled as {extension}.', extension=self._extension, ) self._reader_buttons = [] self.setup_ui(error_message, readers, persist_checked) def setup_ui(self, error_message, readers, persist_checked): """Build UI using given error_messsage and readers dict""" # add instruction label layout = QVBoxLayout() if error_message: error_message += "\n" label = QLabel( f"{error_message}Choose reader for {self._current_file}:" ) layout.addWidget(label) # add radio button for each reader plugin self.reader_btn_group = QButtonGroup(self) self.add_reader_buttons(layout, readers) if self.reader_btn_group.buttons(): self.reader_btn_group.buttons()[0].toggle() # OK & cancel buttons for the dialog btns = QDialogButtonBox.Ok | QDialogButtonBox.Cancel self.btn_box = QDialogButtonBox(btns) self.btn_box.accepted.connect(self.accept) self.btn_box.rejected.connect(self.reject) # checkbox to remember the choice if os.path.isdir(self._current_file): existing_pref = get_settings().plugins.extension2reader.get( self._extension ) isdir = True else: existing_pref = get_settings().plugins.extension2reader.get( '*' + self._extension ) isdir = False if existing_pref: if isdir: self._persist_text = trans._( 'Override existing preference for folders labeled as {extension}: {pref}', extension=self._extension, pref=existing_pref, ) else: self._persist_text = trans._( 'Override existing preference for files with a {extension} extension: {pref}', extension=self._extension, pref=existing_pref, ) self.persist_checkbox = QCheckBox(self._persist_text) self.persist_checkbox.toggle() self.persist_checkbox.setChecked(persist_checked) layout.addWidget(self.persist_checkbox) layout.addWidget(self.btn_box) self.setLayout(layout) def add_reader_buttons(self, layout, readers): """Add radio button to layout for each reader in readers""" for display_name in sorted(readers.values()): button = QRadioButton(f"{display_name}") self.reader_btn_group.addButton(button) layout.addWidget(button) def _get_plugin_choice(self): """Get user's plugin choice based on the checked button""" checked_btn = self.reader_btn_group.checkedButton() if checked_btn: return checked_btn.text() def _get_persist_choice(self): """Get persistence checkbox choice""" return ( hasattr(self, 'persist_checkbox') and self.persist_checkbox.isChecked() ) def get_user_choices(self) -> Tuple[str, bool]: """Execute dialog and get user choices""" display_name = '' persist_choice = False dialog_result = self.exec_() # user pressed cancel if dialog_result: # grab the selected radio button text display_name = self._get_plugin_choice() # grab the persistence checkbox choice persist_choice = self._get_persist_choice() return display_name, persist_choice def handle_gui_reading( paths: List[str], qt_viewer, stack: Union[bool, List[List[str]]], plugin_name: Optional[str] = None, error: Optional[ReaderPluginError] = None, plugin_override: bool = False, **kwargs, ): """Present reader dialog to choose reader and open paths based on result. This function is called whenever ViewerModel._open_or_get_error returns an error from a GUI interaction e.g. dragging & dropping a file or using the File -> Open dialogs. It prepares remaining readers and error message for display, opens the reader dialog and based on user entry opens paths using the chosen plugin. Any errors raised in the process of reading with the chosen plugin are reraised. Parameters ---------- paths : list[str] list of paths to open, as strings qt_viewer : QtViewer QtViewer to associate dialog with stack : bool or list[list[str]] True if list of paths should be stacked, otherwise False. Can also be a list containing lists of files to stack plugin_name : str | None name of plugin already tried, if any error : ReaderPluginError | None previous error raised in the process of opening plugin_override : bool True when user is forcing a plugin choice, otherwise False. Dictates whether checkbox to remember choice is unchecked by default """ _path = paths[0] readers = prepare_remaining_readers(paths, plugin_name, error) error_message = str(error) if error else '' readerDialog = QtReaderDialog( parent=qt_viewer, pth=_path, error_message=error_message, readers=readers, persist_checked=not plugin_override, ) display_name, persist = readerDialog.get_user_choices() if display_name: open_with_dialog_choices( display_name, persist, readerDialog._extension, readers, paths, stack, qt_viewer, **kwargs, ) def prepare_remaining_readers( paths: List[str], plugin_name: Optional[str] = None, error: Optional[ReaderPluginError] = None, ): """Remove tried plugin from readers and raise error if no readers remain. Parameters ---------- paths : List[str] paths to open plugin_name : str | None name of plugin previously tried, if any error : ReaderPluginError | None previous error raised in the process of opening Returns ------- readers: Dict[str, str] remaining readers to present to user Raises ------ ReaderPluginError raises previous error if no readers are left to try """ readers = get_potential_readers(paths[0]) # remove plugin we already tried e.g. prefered plugin if plugin_name in readers: del readers[plugin_name] # if there's no other readers left, raise the exception if not readers and error: raise ReaderPluginError( trans._( "Tried to read {path_message} with plugin {plugin}, because it was associated with that file extension/because it is the only plugin capable of reading that path, but it gave an error. Try associating a different plugin or installing a different plugin for this kind of file.", path_message=f"[{paths[0]}, ...]" if len(paths) > 1 else paths[0], plugin=plugin_name, ), plugin_name, paths, ) from error return readers def open_with_dialog_choices( display_name: str, persist: bool, extension: str, readers: Dict[str, str], paths: List[str], stack: bool, qt_viewer, **kwargs, ): """Open paths with chosen plugin from reader dialog, persisting if chosen. Parameters ---------- display_name : str display name of plugin to use persist : bool True if user chose to persist plugin association, otherwise False extension : str file extension for association of preferences readers : Dict[str, str] plugin-name: display-name dictionary of remaining readers paths : List[str] paths to open stack : bool True if files should be opened as a stack, otherwise False qt_viewer : QtViewer viewer to add layers to """ # TODO: disambiguate with reader title plugin_name = [ p_name for p_name, d_name in readers.items() if d_name == display_name ][0] # may throw error, but we let it this time qt_viewer.viewer.open(paths, stack=stack, plugin=plugin_name, **kwargs) if persist: if not extension.endswith(os.sep): extension = '*' + extension get_settings().plugins.extension2reader = { **get_settings().plugins.extension2reader, extension: plugin_name, } napari-0.5.0a1/napari/_qt/dialogs/screenshot_dialog.py000066400000000000000000000042031437041365600227450ustar00rootroot00000000000000import os from pathlib import Path from typing import Any, Callable from qtpy.QtWidgets import QFileDialog, QMessageBox from napari.utils.misc import in_ipython from napari.utils.translations import trans HOME_DIRECTORY = str(Path.home()) class ScreenshotDialog(QFileDialog): """ Dialog to chose save location of screenshot. Parameters ---------- save_function : Callable[[str], Any], Function to be called on success of selecting save location parent : QWidget, optional Optional parent widget for this widget.. directory : str, optional Starting directory to be set to File Dialog """ def __init__( self, save_function: Callable[[str], Any], parent=None, directory=HOME_DIRECTORY, history=None, ) -> None: super().__init__(parent, trans._("Save screenshot")) self.setAcceptMode(QFileDialog.AcceptSave) self.setFileMode(QFileDialog.AnyFile) self.setNameFilter( trans._("Image files (*.png *.bmp *.gif *.tif *.tiff)") ) self.setDirectory(directory) self.setHistory(history) if in_ipython(): self.setOptions(QFileDialog.DontUseNativeDialog) self.save_function = save_function def accept(self): save_path = self.selectedFiles()[0] if os.path.splitext(save_path)[1] == "": save_path = save_path + ".png" if os.path.exists(save_path): res = QMessageBox().warning( self, trans._("Confirm overwrite"), trans._( "{save_path} already exists. Do you want to replace it?", save_path=save_path, ), QMessageBox.Yes | QMessageBox.No, QMessageBox.No, ) if res != QMessageBox.Yes: # standard accept return 1, reject 0. This inform that dialog should be reopened super().accept() self.exec_() self.save_function(save_path) return super().accept() napari-0.5.0a1/napari/_qt/experimental/000077500000000000000000000000001437041365600177535ustar00rootroot00000000000000napari-0.5.0a1/napari/_qt/experimental/__init__.py000066400000000000000000000000001437041365600220520ustar00rootroot00000000000000napari-0.5.0a1/napari/_qt/experimental/qt_chunk_receiver.py000066400000000000000000000102401437041365600240220ustar00rootroot00000000000000"""QtChunkReceiver and QtGuiEvent classes. """ from qtpy.QtCore import QObject, Signal from napari.components.experimental.chunk import chunk_loader from napari.utils.events import EmitterGroup, Event, EventEmitter class QtGuiEvent(QObject): """Fires an event in the GUI thread. Listens to an event in any thread. When that event fires, it uses a Qt Signal/Slot to fire a gui_event in the GUI thread. If the original event is already in the GUI thread that's fine, the gui_event will be immediately fired the GUI thread. Parameters ---------- parent : QObject Parent Qt object. emitter : EventEmitter The event we are listening to. Attributes ---------- emitter : EventEmitter The event we are listening to. events : EmitterGroup The only event we report is events.gui_event. Notes ----- Qt's signal/slot mechanism is the only way we know of to "call" from a worker thread to the GUI thread. When Qt signals from a worker thread it posts a message to the GUI thread. When the GUI thread is next processing messages it will receive that message and call into the Slot to deliver the message/event. If the original event was already in the GUI thread that's fine, the resulting event will just be triggered right away. """ signal = Signal(Event) def __init__(self, parent: QObject, emitter: EventEmitter) -> None: super().__init__(parent) emitter.connect(self._on_event) self.emitter = emitter self.events = EmitterGroup(source=self, gui_event=None) self.signal.connect(self._slot) def _on_event(self, event) -> None: """Event was fired, we could be in any thread.""" self.signal.emit(event) def _slot(self, event) -> None: """Slot is always called in the GUI thread.""" self.events.gui_event(original_event=event) def close(self): """Viewer is closing.""" self.gui_event.disconnect() self.emitter.disconnect() class QtChunkReceiver: """Passes loaded chunks to their layer. Parameters ---------- parent : QObject Parent Qt object. Attributes ---------- gui_event : QtGuiEvent We use this to call _on_chunk_loaded_gui() in the GUI thread. Notes ----- ChunkLoader._done "may" be called in a worker thread. The concurrent.futures documentation only guarantees that the future's done handler will be called in a thread in the correct process, it does not say which thread. We need to call Layer.on_chunk_loaded() to deliver the loaded chunk to the Layer. We do not want to make this call from a worker thread, because our model code is not thread safe. We don't want the GUI thread and the worker thread changing things at the same time, both triggering events, potentially calling into vispy or other things that also aren't thread safe. We could add locks, but it's simpler and better if we just call Layer.on_chunk_loaded() from the GUI thread. This class QtChunkReceiver listens to the ChunkLoader's chunk_loaded event. It then uses QtUiEvent to call its own _on_chunk_loaded_gui() in the GUI thread. From that method it can safely call Layer.on_chunk_loaded. If ChunkLoader's chunk_loaded event is already in the GUI thread for some reason, this class will still work fine, it will just run 100% in the GUI thread. """ def __init__(self, parent: QObject) -> None: listen_event = chunk_loader.events.chunk_loaded self.gui_event = QtGuiEvent(parent, listen_event) self.gui_event.events.gui_event.connect(self._on_chunk_loaded_gui) @staticmethod def _on_chunk_loaded_gui(event) -> None: """A chunk was loaded. This method is called in the GUI thread. Parameters ---------- event : Event The event object from the original event. """ layer = event.original_event.layer request = event.original_event.request layer.on_chunk_loaded(request) # Pass the chunk to its layer. def close(self): """Viewer is closing.""" self.gui_event.close() napari-0.5.0a1/napari/_qt/experimental/qt_poll.py000066400000000000000000000106071437041365600220030ustar00rootroot00000000000000"""QtPoll class. Poll visuals or other objects so they can do things even when the mouse/camera are not moving. Usually for just a short period of time. """ import time from typing import Optional from qtpy.QtCore import QEvent, QObject, QTimer from napari.utils.events import EmitterGroup # When running a timer we use this interval. POLL_INTERVAL_MS = 16 # About 60HZ, needs to be an int for QTimer setInterval # If called more often than this we ignore it. Our _on_camera() method can # be called multiple times in on frame. It can get called because the # "center" changed and then the "zoom" changed even if it was really from # the same camera movement. IGNORE_INTERVAL_MS = 10 class QtPoll(QObject): """Polls anything once per frame via an event. QtPoll was first created for VispyTiledImageLayer. It polls the visual when the camera moves. However, we also want visuals to keep loading chunks even when the camera stops. We want the visual to finish up anything that was in progress. Before it goes fully idle. QtPoll will poll those visuals using a timer. If the visual says the event was "handled" it means the visual has more work to do. If that happens, QtPoll will continue to poll and draw the visual it until the visual is done with the in-progress work. An analogy is a snow globe. The user moving the camera shakes up the snow globe. We need to keep polling/drawing things until all the snow settles down. Then everything will stay completely still until the camera is moved again, shaking up the globe once more. Parameters ---------- parent : QObject Parent Qt object. camera : Camera The viewer's main camera. """ def __init__(self, parent: QObject) -> None: super().__init__(parent) self.events = EmitterGroup(source=self, poll=None) self.timer = QTimer() self.timer.setInterval(POLL_INTERVAL_MS) self.timer.timeout.connect(self._on_timer) self._interval = IntervalTimer() def on_camera(self) -> None: """Called when camera view changes.""" # When the mouse button is down and the camera is being zoomed # or panned, timer events are starved out. So we call poll # explicitly here. It will start the timer if needed so that # polling can continue even after the camera stops moving. self._poll() def wake_up(self) -> None: """Wake up QtPoll so it starts polling.""" # Start the timer so that we start polling. We used to poll once # right away here, but it led to crashes. Because we polled during # a paintGL event? if not self.timer.isActive(): self.timer.start() def _on_timer(self) -> None: """Called when the timer is running. The timer is running which means someone we are polling still has work to do. """ self._poll() def _poll(self) -> None: """Called on camera move or with the timer.""" # Between timers and camera and wake_up() we might be called multiple # times in quick succession. Use an IntervalTimer to ignore these # near-duplicate calls. if self._interval.elapsed_ms < IGNORE_INTERVAL_MS: return # Poll all listeners. event = self.events.poll() # Listeners will "handle" the event if they need more polling. If # no one needs polling, then we can stop the timer. if not event.handled: self.timer.stop() return # Someone handled the event, so they want to be polled even if # the mouse doesn't move. So start the timer if needed. if not self.timer.isActive(): self.timer.start() def closeEvent(self, _event: QEvent) -> None: """Cleanup and close. Parameters ---------- _event : QEvent The close event. """ self.timer.stop() self.deleteLater() class IntervalTimer: """Time the interval between subsequent calls to our elapsed property.""" def __init__(self) -> None: self._last: Optional[float] = None @property def elapsed_ms(self) -> float: """The elapsed time since the last call to this property.""" now = time.time() elapsed_seconds = 0 if self._last is None else now - self._last self._last = now return elapsed_seconds * 1000 napari-0.5.0a1/napari/_qt/layer_controls/000077500000000000000000000000001437041365600203155ustar00rootroot00000000000000napari-0.5.0a1/napari/_qt/layer_controls/__init__.py000066400000000000000000000002141437041365600224230ustar00rootroot00000000000000from napari._qt.layer_controls.qt_layer_controls_container import ( QtLayerControlsContainer, ) __all__ = ["QtLayerControlsContainer"] napari-0.5.0a1/napari/_qt/layer_controls/_tests/000077500000000000000000000000001437041365600216165ustar00rootroot00000000000000napari-0.5.0a1/napari/_qt/layer_controls/_tests/__init__.py000066400000000000000000000000001437041365600237150ustar00rootroot00000000000000napari-0.5.0a1/napari/_qt/layer_controls/_tests/test_qt_image_base_layer_.py000066400000000000000000000136011437041365600273430ustar00rootroot00000000000000import os from unittest.mock import patch import numpy as np import pytest from qtpy.QtCore import Qt from qtpy.QtWidgets import QPushButton from napari._qt.layer_controls.qt_image_controls_base import ( QContrastLimitsPopup, QRangeSliderPopup, QtBaseImageControls, QtLayerControls, range_to_decimals, ) from napari.layers import Image, Surface _IMAGE = np.arange(100).astype(np.uint16).reshape((10, 10)) _SURF = ( np.random.random((10, 2)), np.random.randint(10, size=(6, 3)), np.arange(100).astype(float), ) @pytest.mark.parametrize('layer', [Image(_IMAGE), Surface(_SURF)]) def test_base_controls_creation(qtbot, layer): """Check basic creation of QtBaseImageControls works""" qtctrl = QtBaseImageControls(layer) qtbot.addWidget(qtctrl) original_clims = tuple(layer.contrast_limits) slider_clims = qtctrl.contrastLimitsSlider.value() assert slider_clims[0] == 0 assert slider_clims[1] == 99 assert tuple(slider_clims) == original_clims @patch.object(QRangeSliderPopup, 'show') @pytest.mark.parametrize('layer', [Image(_IMAGE), Surface(_SURF)]) def test_clim_right_click_shows_popup(mock_show, qtbot, layer): """Right clicking on the contrast limits slider should show a popup.""" qtctrl = QtBaseImageControls(layer) qtbot.addWidget(qtctrl) qtbot.mousePress(qtctrl.contrastLimitsSlider, Qt.RightButton) assert hasattr(qtctrl, 'clim_popup') # this mock doesn't seem to be working on cirrus windows # but it works on local windows tests... if not (os.name == 'nt' and os.getenv("CI")): mock_show.assert_called_once() @pytest.mark.parametrize('layer', [Image(_IMAGE), Surface(_SURF)]) def test_changing_model_updates_view(qtbot, layer): """Changing the model attribute should update the view""" qtctrl = QtBaseImageControls(layer) qtbot.addWidget(qtctrl) new_clims = (20, 40) layer.contrast_limits = new_clims assert tuple(qtctrl.contrastLimitsSlider.value()) == new_clims @patch.object(QRangeSliderPopup, 'show') @pytest.mark.parametrize( 'layer', [Image(_IMAGE), Image(_IMAGE.astype(np.int32)), Surface(_SURF)] ) def test_range_popup_clim_buttons(mock_show, qtbot, qapp, layer): """The buttons in the clim_popup should adjust the contrast limits value""" qtctrl = QtBaseImageControls(layer) qtbot.addWidget(qtctrl) original_clims = tuple(layer.contrast_limits) layer.contrast_limits = (20, 40) qtbot.mousePress(qtctrl.contrastLimitsSlider, Qt.RightButton) # pressing the reset button returns the clims to the default values reset_button = qtctrl.clim_popup.findChild( QPushButton, "reset_clims_button" ) reset_button.click() qapp.processEvents() assert tuple(qtctrl.contrastLimitsSlider.value()) == original_clims rangebtn = qtctrl.clim_popup.findChild( QPushButton, "full_clim_range_button" ) # data in this test is uint16 or int32 for Image, and float for Surface. # Surface will not have a "full range button" if np.issubdtype(layer.dtype, np.integer): info = np.iinfo(layer.dtype) rangebtn.click() qapp.processEvents() assert tuple(layer.contrast_limits_range) == (info.min, info.max) min_ = qtctrl.contrastLimitsSlider.minimum() max_ = qtctrl.contrastLimitsSlider.maximum() assert (min_, max_) == (info.min, info.max) else: assert rangebtn is None @pytest.mark.parametrize('mag', list(range(-16, 16, 4))) def test_clim_slider_step_size_and_precision(qtbot, mag): """Make sure the slider has a reasonable step size and precision. ...across a broad range of orders of magnitude. """ layer = Image(np.random.rand(20, 20) * 10**mag) popup = QContrastLimitsPopup(layer) qtbot.addWidget(popup) # scale precision with the log of the data range order of magnitude # eg. 0 - 1 (0 order of mag) -> 3 decimal places # 0 - 10 (1 order of mag) -> 2 decimals # 0 - 100 (2 orders of mag) -> 1 decimal # ≥ 3 orders of mag -> no decimals # no more than 64 decimals decimals = range_to_decimals(layer.contrast_limits, layer.dtype) assert popup.slider.decimals() == decimals # the slider step size should also be inversely proportional to the data # range, with 1000 steps across the data range assert popup.slider.singleStep() == 10**-decimals def test_qt_image_controls_change_contrast(qtbot): layer = Image(np.random.rand(8, 8)) qtctrl = QtBaseImageControls(layer) qtbot.addWidget(qtctrl) qtctrl.contrastLimitsSlider.setValue((0.1, 0.8)) assert tuple(layer.contrast_limits) == (0.1, 0.8) def test_tensorstore_clim_popup(): """Regression to test, makes sure it works with tensorstore dtype""" ts = pytest.importorskip('tensorstore') layer = Image(ts.array(np.random.rand(20, 20))) QContrastLimitsPopup(layer) def test_blending_opacity_slider(qtbot): """Tests whether opacity slider is disabled for minimum and opaque blending.""" layer = Image(np.random.rand(8, 8)) qtctrl = QtLayerControls(layer) qtbot.addWidget(qtctrl) assert layer.blending == 'translucent' # check that the opacity slider is present by default assert qtctrl.opacitySlider.isEnabled() # set minimum blending, the opacity slider should be disabled layer.blending = 'minimum' assert not qtctrl.opacitySlider.isEnabled() # set the blending to 'additive' confirm the slider is enabled layer.blending = 'additive' assert layer.blending == 'additive' assert qtctrl.opacitySlider.isEnabled() # set opaque blending, the opacity slider should be disabled layer.blending = 'opaque' assert layer.blending == 'opaque' assert not qtctrl.opacitySlider.isEnabled() # set the blending back to 'translucent' confirm the slider is enabled layer.blending = 'translucent' assert layer.blending == 'translucent' assert qtctrl.opacitySlider.isEnabled() napari-0.5.0a1/napari/_qt/layer_controls/_tests/test_qt_image_layer.py000066400000000000000000000110231437041365600262060ustar00rootroot00000000000000import numpy as np from napari._qt.layer_controls.qt_image_controls import QtImageControls from napari.layers import Image def test_interpolation_combobox(qtbot): """Changing the model attribute should update the view""" layer = Image(np.random.rand(8, 8)) qtctrl = QtImageControls(layer) qtbot.addWidget(qtctrl) combo = qtctrl.interpComboBox opts = {combo.itemText(i) for i in range(combo.count())} assert opts == {'cubic', 'linear', 'kaiser', 'nearest', 'spline36'} # programmatically adding approved interpolation works layer.interpolation2d = 'lanczos' assert combo.findText('lanczos') == 5 def test_rendering_combobox(qtbot): """Changing the model attribute should update the view""" layer = Image(np.random.rand(8, 8)) qtctrl = QtImageControls(layer) qtbot.addWidget(qtctrl) combo = qtctrl.renderComboBox opts = {combo.itemText(i) for i in range(combo.count())} rendering_options = { 'translucent', 'additive', 'iso', 'mip', 'minip', 'attenuated_mip', 'average', } assert opts == rendering_options # programmatically updating rendering mode updates the combobox layer.rendering = 'iso' assert combo.findText('iso') == combo.currentIndex() def test_depiction_combobox_changes(qtbot): """Changing the model attribute should update the view.""" layer = Image(np.random.rand(10, 15, 20)) qtctrl = QtImageControls(layer) qtctrl.ndisplay = 3 qtbot.addWidget(qtctrl) combo_box = qtctrl.depictionComboBox opts = {combo_box.itemText(i) for i in range(combo_box.count())} depiction_options = { 'volume', 'plane', } assert opts == depiction_options layer.depiction = 'plane' assert combo_box.findText('plane') == combo_box.currentIndex() layer.depiction = 'volume' assert combo_box.findText('volume') == combo_box.currentIndex() def test_plane_controls_show_hide_on_depiction_change(qtbot): """Changing depiction mode should show/hide plane controls in 3D.""" layer = Image(np.random.rand(10, 15, 20)) qtctrl = QtImageControls(layer) qtbot.addWidget(qtctrl) qtctrl.ndisplay = 3 layer.depiction = 'volume' assert qtctrl.planeThicknessSlider.isHidden() assert qtctrl.planeThicknessLabel.isHidden() assert qtctrl.planeNormalButtons.isHidden() assert qtctrl.planeNormalLabel.isHidden() layer.depiction = 'plane' assert not qtctrl.planeThicknessSlider.isHidden() assert not qtctrl.planeThicknessLabel.isHidden() assert not qtctrl.planeNormalButtons.isHidden() assert not qtctrl.planeNormalLabel.isHidden() def test_plane_controls_show_hide_on_ndisplay_change(qtbot): """Changing ndisplay should show/hide plane controls if depicting a plane.""" layer = Image(np.random.rand(10, 15, 20)) layer.depiction = 'plane' qtctrl = QtImageControls(layer) qtbot.addWidget(qtctrl) assert qtctrl.ndisplay == 2 assert qtctrl.planeThicknessSlider.isHidden() assert qtctrl.planeThicknessLabel.isHidden() assert qtctrl.planeNormalButtons.isHidden() assert qtctrl.planeNormalLabel.isHidden() qtctrl.ndisplay = 3 assert not qtctrl.planeThicknessSlider.isHidden() assert not qtctrl.planeThicknessLabel.isHidden() assert not qtctrl.planeNormalButtons.isHidden() assert not qtctrl.planeNormalLabel.isHidden() def test_plane_slider_value_change(qtbot): """Changing the model should update the view.""" layer = Image(np.random.rand(10, 15, 20)) qtctrl = QtImageControls(layer) qtbot.addWidget(qtctrl) layer.plane.thickness *= 2 assert qtctrl.planeThicknessSlider.value() == layer.plane.thickness def test_auto_contrast_buttons(qtbot): layer = Image(np.arange(8**3).reshape(8, 8, 8), contrast_limits=(0, 1)) qtctrl = QtImageControls(layer) qtbot.addWidget(qtctrl) assert layer.contrast_limits == [0, 1] qtctrl.autoScaleBar._once_btn.click() assert layer.contrast_limits == [0, 63] # change slice layer._slice_dims((1, 8, 8)) # hasn't changed yet assert layer.contrast_limits == [0, 63] # with auto_btn, it should always change qtctrl.autoScaleBar._auto_btn.click() assert layer.contrast_limits == [64, 127] layer._slice_dims((2, 8, 8)) assert layer.contrast_limits == [128, 191] layer._slice_dims((3, 8, 8)) assert layer.contrast_limits == [192, 255] # once button turns off continuous qtctrl.autoScaleBar._once_btn.click() layer._slice_dims((4, 8, 8)) assert layer.contrast_limits == [192, 255] napari-0.5.0a1/napari/_qt/layer_controls/_tests/test_qt_labels_layer.py000066400000000000000000000042221437041365600263710ustar00rootroot00000000000000import numpy as np from napari._qt.layer_controls.qt_labels_controls import QtLabelsControls from napari.layers import Labels from napari.utils.colormaps import colormap_utils np.random.seed(0) _LABELS = np.random.randint(5, size=(10, 15)) _COLOR = {1: 'white', 2: 'blue', 3: 'green', 4: 'red', 5: 'yellow'} def test_changing_layer_color_mode_updates_combo_box(qtbot): """Updating layer color mode changes the combo box selection""" layer = Labels(_LABELS, color=_COLOR) qtctrl = QtLabelsControls(layer) qtbot.addWidget(qtctrl) original_color_mode = layer.color_mode assert original_color_mode == qtctrl.colorModeComboBox.currentText() layer.color_mode = 'auto' assert layer.color_mode == qtctrl.colorModeComboBox.currentText() def test_rendering_combobox(qtbot): """Changing the model attribute should update the view""" layer = Labels(_LABELS) qtctrl = QtLabelsControls(layer) qtbot.addWidget(qtctrl) combo = qtctrl.renderComboBox opts = {combo.itemText(i) for i in range(combo.count())} rendering_options = {'translucent', 'iso_categorical'} assert opts == rendering_options # programmatically updating rendering mode updates the combobox new_mode = 'iso_categorical' layer.rendering = new_mode assert combo.findText(new_mode) == combo.currentIndex() def test_changing_colormap_updates_colorbox(qtbot): """Test that changing the colormap on a layer will update color swatch in the combo box""" layer = Labels(_LABELS, color=_COLOR) qtctrl = QtLabelsControls(layer) qtbot.addWidget(qtctrl) color_box = qtctrl.colorBox layer.selected_label = 1 # For a paint event, which does not occur in a headless qtbot color_box.paintEvent(None) np.testing.assert_equal( color_box.color, np.round(np.asarray(layer._selected_color) * 255 * layer.opacity), ) layer.colormap = colormap_utils.label_colormap(num_colors=5) # For a paint event, which does not occur in a headless qtbot color_box.paintEvent(None) np.testing.assert_equal( color_box.color, np.round(np.asarray(layer._selected_color) * 255 * layer.opacity), ) napari-0.5.0a1/napari/_qt/layer_controls/_tests/test_qt_layer_controls.py000066400000000000000000000110411437041365600267670ustar00rootroot00000000000000from collections import namedtuple import numpy as np import pytest from qtpy.QtWidgets import QAbstractButton from napari._qt.layer_controls.qt_layer_controls_container import ( create_qt_layer_controls, layer_to_controls, ) from napari._qt.layer_controls.qt_shapes_controls import QtShapesControls from napari.layers import Labels, Points, Shapes LayerTypeWithData = namedtuple('LayerTypeWithData', ['type', 'data']) _POINTS = LayerTypeWithData(type=Points, data=np.random.random((5, 2))) _SHAPES = LayerTypeWithData(type=Shapes, data=np.random.random((10, 4, 2))) _LINES_DATA = np.random.random((6, 2, 2)) def test_create_shape(qtbot): shapes = _SHAPES.type(_SHAPES.data) ctrl = create_qt_layer_controls(shapes) qtbot.addWidget(ctrl) assert isinstance(ctrl, QtShapesControls) def test_unknown_raises(qtbot): class Test: """Unmatched class""" with pytest.raises(TypeError): create_qt_layer_controls(Test()) def test_inheritance(qtbot): class QtLinesControls(QtShapesControls): """Yes I'm the same""" class Lines(Shapes): """Here too""" lines = Lines(_LINES_DATA) layer_to_controls[Lines] = QtLinesControls ctrl = create_qt_layer_controls(lines) qtbot.addWidget(ctrl) assert isinstance(ctrl, QtLinesControls) @pytest.mark.parametrize('layer_type_with_data', [_POINTS, _SHAPES]) def test_text_set_visible_updates_checkbox(qtbot, layer_type_with_data): text = { 'string': {'constant': 'test'}, 'visible': True, } layer = layer_type_with_data.type(layer_type_with_data.data, text=text) ctrl = create_qt_layer_controls(layer) qtbot.addWidget(ctrl) assert ctrl.textDispCheckBox.isChecked() layer.text.visible = False assert not ctrl.textDispCheckBox.isChecked() @pytest.mark.parametrize('layer_type_with_data', [_POINTS, _SHAPES]) def test_set_text_then_set_visible_updates_checkbox( qtbot, layer_type_with_data ): layer = layer_type_with_data.type(layer_type_with_data.data) ctrl = create_qt_layer_controls(layer) qtbot.addWidget(ctrl) layer.text = { 'string': {'constant': 'another_test'}, 'visible': False, } assert not ctrl.textDispCheckBox.isChecked() layer.text.visible = True assert ctrl.textDispCheckBox.isChecked() # The following tests handle changes to the layer's visible and # editable state for layer control types that have controls to edit # the layer. For more context see: # https://github.com/napari/napari/issues/1346 @pytest.fixture( params=( (Labels, np.zeros((3, 4), dtype=int)), (Points, np.empty((0, 2))), (Shapes, np.empty((0, 2, 4))), ) ) def editable_layer(request): LayerType, data = request.param return LayerType(data) def test_make_visible_when_editable_enables_edit_buttons( qtbot, editable_layer ): editable_layer.editable = True editable_layer.visible = False controls = make_layer_controls(qtbot, editable_layer) assert_no_edit_buttons_enabled(controls) editable_layer.visible = True assert_all_edit_buttons_enabled(controls) def test_make_not_visible_when_editable_disables_edit_buttons( qtbot, editable_layer ): editable_layer.editable = True editable_layer.visible = True controls = make_layer_controls(qtbot, editable_layer) assert_all_edit_buttons_enabled(controls) editable_layer.visible = False assert_no_edit_buttons_enabled(controls) def test_make_editable_when_visible_enables_edit_buttons( qtbot, editable_layer ): editable_layer.editable = False editable_layer.visible = True controls = make_layer_controls(qtbot, editable_layer) assert_no_edit_buttons_enabled(controls) editable_layer.editable = True assert_all_edit_buttons_enabled(controls) def test_make_not_editable_when_visible_disables_edit_buttons( qtbot, editable_layer ): editable_layer.editable = True editable_layer.visible = True controls = make_layer_controls(qtbot, editable_layer) assert_all_edit_buttons_enabled(controls) editable_layer.editable = False assert_no_edit_buttons_enabled(controls) def make_layer_controls(qtbot, layer): QtLayerControlsType = layer_to_controls[type(layer)] controls = QtLayerControlsType(layer) qtbot.addWidget(controls) return controls def assert_all_edit_buttons_enabled(controls) -> None: assert all(map(QAbstractButton.isEnabled, controls._EDIT_BUTTONS)) def assert_no_edit_buttons_enabled(controls) -> None: assert not any(map(QAbstractButton.isEnabled, controls._EDIT_BUTTONS)) napari-0.5.0a1/napari/_qt/layer_controls/_tests/test_qt_points_layer.py000066400000000000000000000062741437041365600264540ustar00rootroot00000000000000import numpy as np import pytest from napari._qt.layer_controls.qt_points_controls import QtPointsControls from napari.layers import Points def test_out_of_slice_display_checkbox(qtbot): """Changing the model attribute should update the view""" layer = Points(np.random.rand(10, 2)) qtctrl = QtPointsControls(layer) qtbot.addWidget(qtctrl) combo = qtctrl.outOfSliceCheckBox assert layer.out_of_slice_display is False combo.setChecked(True) assert layer.out_of_slice_display is True def test_current_size_display_in_range(qtbot): """Changing the model attribute should update the view""" layer = Points(np.random.rand(10, 2)) qtctrl = QtPointsControls(layer) qtbot.addWidget(qtctrl) slider = qtctrl.sizeSlider slider.setValue(10) # Initial values assert slider.maximum() == 100 assert slider.minimum() == 1 assert slider.value() == 10 assert layer.current_size == 10 # Size event needs to be triggered manually, because no points are selected. layer.current_size = 5 layer.events.size() assert slider.maximum() == 100 assert slider.minimum() == 1 assert slider.value() == 5 assert layer.current_size == 5 # Size event needs to be triggered manually, because no points are selected. layer.current_size = 100 layer.events.size() assert slider.maximum() == 100 assert slider.minimum() == 1 assert slider.value() == 100 assert layer.current_size == 100 # Size event needs to be triggered manually, because no points are selected. layer.current_size = 200 layer.events.size() assert slider.maximum() == 201 assert slider.minimum() == 1 assert slider.value() == 200 assert layer.current_size == 200 # Size event needs to be triggered manually, because no points are selected. with pytest.warns(RuntimeWarning): layer.current_size = -1000 layer.events.size() assert slider.maximum() == 201 assert slider.minimum() == 1 assert slider.value() == 200 assert layer.current_size == 200 layer.current_size = [20, 20] layer.events.size() assert slider.maximum() == 201 assert slider.minimum() == 1 assert slider.value() == 200 assert layer.current_size == [20, 20] with pytest.warns(RuntimeWarning): layer.current_size = [20, -20] layer.events.size() assert slider.maximum() == 201 assert slider.minimum() == 1 assert slider.value() == 200 assert layer.current_size == [20, 20] def test_current_size_slider_properly_initialized(qtbot): """Changing the model attribute should update the view""" layer = Points(np.random.rand(10, 2), size=np.linspace(-2, 200, 10)) qtctrl = QtPointsControls(layer) qtbot.addWidget(qtctrl) slider = qtctrl.sizeSlider assert slider.maximum() == 201 assert slider.minimum() == 1 assert slider.value() == 10 assert layer.current_size == 10 layer = Points(np.random.rand(10, 2), size=np.linspace(-2, 50, 10)) qtctrl = QtPointsControls(layer) qtbot.addWidget(qtctrl) slider = qtctrl.sizeSlider assert slider.maximum() == 100 assert slider.minimum() == 1 assert slider.value() == 10 assert layer.current_size == 10 napari-0.5.0a1/napari/_qt/layer_controls/_tests/test_qt_shapes_layer.py000066400000000000000000000025021437041365600264110ustar00rootroot00000000000000import numpy as np from napari._qt.layer_controls.qt_shapes_controls import QtShapesControls from napari.layers import Shapes from napari.utils.colormaps.standardize_color import transform_color _SHAPES = np.random.random((10, 4, 2)) def test_shape_controls_face_color(qtbot): """Check updating of face color updates QtShapesControls.""" layer = Shapes(_SHAPES) qtctrl = QtShapesControls(layer) qtbot.addWidget(qtctrl) target_color = transform_color(layer.current_face_color)[0] np.testing.assert_almost_equal(qtctrl.faceColorEdit.color, target_color) # Update current face color layer.current_face_color = 'red' target_color = transform_color(layer.current_face_color)[0] np.testing.assert_almost_equal(qtctrl.faceColorEdit.color, target_color) def test_shape_controls_edge_color(qtbot): """Check updating of edge color updates QtShapesControls.""" layer = Shapes(_SHAPES) qtctrl = QtShapesControls(layer) qtbot.addWidget(qtctrl) target_color = transform_color(layer.current_edge_color)[0] np.testing.assert_almost_equal(qtctrl.edgeColorEdit.color, target_color) # Update current edge color layer.current_edge_color = 'red' target_color = transform_color(layer.current_edge_color)[0] np.testing.assert_almost_equal(qtctrl.edgeColorEdit.color, target_color) napari-0.5.0a1/napari/_qt/layer_controls/_tests/test_qt_tracks_layer.py000066400000000000000000000026501437041365600264210ustar00rootroot00000000000000import numpy as np import pytest from qtpy.QtCore import Qt from napari._qt.layer_controls.qt_tracks_controls import QtTracksControls from napari.layers import Tracks _TRACKS = np.zeros((2, 4)) _PROPERTIES = {'speed': [50, 30], 'time': [0, 1]} def test_tracks_controls_color_by(qtbot): """Check updating of the color_by combobox.""" inital_color_by = 'time' with pytest.warns(UserWarning) as wrn: layer = Tracks( _TRACKS, properties=_PROPERTIES, color_by=inital_color_by ) assert "Previous color_by key 'time' not present" in str(wrn[0].message) qtctrl = QtTracksControls(layer) qtbot.addWidget(qtctrl) # verify the color_by argument is initialized correctly assert layer.color_by == inital_color_by assert qtctrl.color_by_combobox.currentText() == inital_color_by # update color_by from the layer model layer_update_color_by = 'speed' layer.color_by = layer_update_color_by assert layer.color_by == layer_update_color_by assert qtctrl.color_by_combobox.currentText() == layer_update_color_by # update color_by from the qt controls qt_update_color_by = 'track_id' speed_index = qtctrl.color_by_combobox.findText( qt_update_color_by, Qt.MatchFixedString ) qtctrl.color_by_combobox.setCurrentIndex(speed_index) assert layer.color_by == qt_update_color_by assert qtctrl.color_by_combobox.currentText() == qt_update_color_by napari-0.5.0a1/napari/_qt/layer_controls/qt_colormap_combobox.py000066400000000000000000000045111437041365600251000ustar00rootroot00000000000000from qtpy.QtCore import QModelIndex, QRect from qtpy.QtGui import QImage, QPainter from qtpy.QtWidgets import ( QComboBox, QListView, QStyledItemDelegate, QStyleOptionViewItem, ) from napari.utils.colormaps import ( display_name_to_name, ensure_colormap, make_colorbar, ) COLORMAP_WIDTH = 50 TEXT_WIDTH = 130 ENTRY_HEIGHT = 20 PADDING = 1 class ColorStyledDelegate(QStyledItemDelegate): """Class for paint :py:class:`~.ColorComboBox` elements when list trigger Parameters ---------- base_height : int Height of single list element. color_dict: dict Dict mapping name to colors. """ def __init__(self, base_height: int, **kwargs) -> None: super().__init__(**kwargs) self.base_height = base_height def paint( self, painter: QPainter, style: QStyleOptionViewItem, model: QModelIndex, ): style2 = QStyleOptionViewItem(style) cbar_rect = QRect( style.rect.x(), style.rect.y() + PADDING, style.rect.width() - TEXT_WIDTH, style.rect.height() - 2 * PADDING, ) text_rect = QRect( style.rect.width() - TEXT_WIDTH, style.rect.y() + PADDING, style.rect.width(), style.rect.height() - 2 * PADDING, ) style2.rect = text_rect super().paint(painter, style2, model) name = display_name_to_name(model.data()) cbar = make_colorbar(ensure_colormap(name), (18, 100)) image = QImage( cbar, cbar.shape[1], cbar.shape[0], QImage.Format_RGBA8888, ) painter.drawImage(cbar_rect, image) def sizeHint(self, style: QStyleOptionViewItem, model: QModelIndex): res = super().sizeHint(style, model) res.setHeight(self.base_height) res.setWidth(max(500, res.width())) return res class QtColormapComboBox(QComboBox): """Combobox showing colormaps Parameters ---------- parent : QWidget Parent widget of comboxbox. """ def __init__(self, parent) -> None: super().__init__(parent) view = QListView() view.setMinimumWidth(COLORMAP_WIDTH + TEXT_WIDTH) view.setItemDelegate(ColorStyledDelegate(ENTRY_HEIGHT)) self.setView(view) napari-0.5.0a1/napari/_qt/layer_controls/qt_image_controls.py000066400000000000000000000372621437041365600244120ustar00rootroot00000000000000from typing import TYPE_CHECKING from qtpy.QtCore import Qt from qtpy.QtWidgets import ( QComboBox, QHBoxLayout, QLabel, QPushButton, QSlider, QWidget, ) from superqt import QLabeledDoubleSlider from napari._qt.layer_controls.qt_image_controls_base import ( QtBaseImageControls, ) from napari._qt.utils import qt_signals_blocked from napari._qt.widgets._slider_compat import QDoubleSlider from napari.layers.image._image_constants import ( ImageRendering, Interpolation, VolumeDepiction, ) from napari.utils.action_manager import action_manager from napari.utils.translations import trans if TYPE_CHECKING: import napari.layers class QtImageControls(QtBaseImageControls): """Qt view and controls for the napari Image layer. Parameters ---------- layer : napari.layers.Image An instance of a napari Image layer. Attributes ---------- attenuationSlider : qtpy.QtWidgets.QSlider Slider controlling attenuation rate for `attenuated_mip` mode. attenuationLabel : qtpy.QtWidgets.QLabel Label for the attenuation slider widget. interpComboBox : qtpy.QtWidgets.QComboBox Dropdown menu to select the interpolation mode for image display. interpLabel : qtpy.QtWidgets.QLabel Label for the interpolation dropdown menu. isoThresholdSlider : qtpy.QtWidgets.QSlider Slider controlling the isosurface threshold value for rendering. isoThresholdLabel : qtpy.QtWidgets.QLabel Label for the isosurface threshold slider widget. layer : napari.layers.Image An instance of a napari Image layer. renderComboBox : qtpy.QtWidgets.QComboBox Dropdown menu to select the rendering mode for image display. renderLabel : qtpy.QtWidgets.QLabel Label for the rendering mode dropdown menu. """ layer: 'napari.layers.Image' def __init__(self, layer) -> None: super().__init__(layer) self.layer.events.interpolation2d.connect( self._on_interpolation_change ) self.layer.events.interpolation3d.connect( self._on_interpolation_change ) self.layer.events.rendering.connect(self._on_rendering_change) self.layer.events.iso_threshold.connect(self._on_iso_threshold_change) self.layer.events.attenuation.connect(self._on_attenuation_change) self.layer.events.depiction.connect(self._on_depiction_change) self.layer.plane.events.thickness.connect( self._on_plane_thickness_change ) self.interpComboBox = QComboBox(self) self.interpComboBox.currentTextChanged.connect( self.changeInterpolation ) self.interpLabel = QLabel(trans._('interpolation:')) renderComboBox = QComboBox(self) rendering_options = [i.value for i in ImageRendering] renderComboBox.addItems(rendering_options) index = renderComboBox.findText( self.layer.rendering, Qt.MatchFlag.MatchFixedString ) renderComboBox.setCurrentIndex(index) renderComboBox.currentTextChanged.connect(self.changeRendering) self.renderComboBox = renderComboBox self.renderLabel = QLabel(trans._('rendering:')) self.depictionComboBox = QComboBox(self) depiction_options = [d.value for d in VolumeDepiction] self.depictionComboBox.addItems(depiction_options) index = self.depictionComboBox.findText( self.layer.depiction, Qt.MatchFlag.MatchFixedString ) self.depictionComboBox.setCurrentIndex(index) self.depictionComboBox.currentTextChanged.connect(self.changeDepiction) self.depictionLabel = QLabel(trans._('depiction:')) # plane controls self.planeNormalButtons = PlaneNormalButtons(self) self.planeNormalLabel = QLabel(trans._('plane normal:')) action_manager.bind_button( 'napari:orient_plane_normal_along_z', self.planeNormalButtons.zButton, ) action_manager.bind_button( 'napari:orient_plane_normal_along_y', self.planeNormalButtons.yButton, ) action_manager.bind_button( 'napari:orient_plane_normal_along_x', self.planeNormalButtons.xButton, ) action_manager.bind_button( 'napari:orient_plane_normal_along_view_direction', self.planeNormalButtons.obliqueButton, ) self.planeThicknessSlider = QLabeledDoubleSlider( Qt.Orientation.Horizontal, self ) self.planeThicknessLabel = QLabel(trans._('plane thickness:')) self.planeThicknessSlider.setFocusPolicy(Qt.NoFocus) self.planeThicknessSlider.setMinimum(1) self.planeThicknessSlider.setMaximum(50) self.planeThicknessSlider.setValue(self.layer.plane.thickness) self.planeThicknessSlider.valueChanged.connect( self.changePlaneThickness ) sld = QDoubleSlider(Qt.Orientation.Horizontal, parent=self) sld.setFocusPolicy(Qt.FocusPolicy.NoFocus) cmin, cmax = self.layer.contrast_limits_range sld.setMinimum(cmin) sld.setMaximum(cmax) sld.setValue(self.layer.iso_threshold) sld.valueChanged.connect(self.changeIsoThreshold) self.isoThresholdSlider = sld self.isoThresholdLabel = QLabel(trans._('iso threshold:')) sld = QSlider(Qt.Orientation.Horizontal, parent=self) sld.setFocusPolicy(Qt.FocusPolicy.NoFocus) sld.setMinimum(0) sld.setMaximum(100) sld.setSingleStep(1) sld.setValue(int(self.layer.attenuation * 200)) sld.valueChanged.connect(self.changeAttenuation) self.attenuationSlider = sld self.attenuationLabel = QLabel(trans._('attenuation:')) self._on_ndisplay_changed() colormap_layout = QHBoxLayout() if hasattr(self.layer, 'rgb') and self.layer.rgb: colormap_layout.addWidget(QLabel("RGB")) self.colormapComboBox.setVisible(False) self.colorbarLabel.setVisible(False) else: colormap_layout.addWidget(self.colorbarLabel) colormap_layout.addWidget(self.colormapComboBox) colormap_layout.addStretch(1) self.layout().addRow(self.opacityLabel, self.opacitySlider) self.layout().addRow( trans._('contrast limits:'), self.contrastLimitsSlider ) self.layout().addRow(trans._('auto-contrast:'), self.autoScaleBar) self.layout().addRow(trans._('gamma:'), self.gammaSlider) self.layout().addRow(trans._('colormap:'), colormap_layout) self.layout().addRow(trans._('blending:'), self.blendComboBox) self.layout().addRow(self.interpLabel, self.interpComboBox) self.layout().addRow(self.depictionLabel, self.depictionComboBox) self.layout().addRow(self.renderLabel, self.renderComboBox) self.layout().addRow(self.isoThresholdLabel, self.isoThresholdSlider) self.layout().addRow(self.attenuationLabel, self.attenuationSlider) self.layout().addRow(self.planeNormalLabel, self.planeNormalButtons) self.layout().addRow( self.planeThicknessLabel, self.planeThicknessSlider ) def changeInterpolation(self, text): """Change interpolation mode for image display. Parameters ---------- text : str Interpolation mode used by vispy. Must be one of our supported modes: 'bessel', 'bicubic', 'linear', 'blackman', 'catrom', 'gaussian', 'hamming', 'hanning', 'hermite', 'kaiser', 'lanczos', 'mitchell', 'nearest', 'spline16', 'spline36' """ if self.ndisplay == 2: self.layer.interpolation2d = text else: self.layer.interpolation3d = text def changeRendering(self, text): """Change rendering mode for image display. Parameters ---------- text : str Rendering mode used by vispy. Selects a preset rendering mode in vispy that determines how volume is displayed: * translucent: voxel colors are blended along the view ray until the result is opaque. * mip: maximum intensity projection. Cast a ray and display the maximum value that was encountered. * additive: voxel colors are added along the view ray until the result is saturated. * iso: isosurface. Cast a ray until a certain threshold is encountered. At that location, lighning calculations are performed to give the visual appearance of a surface. * attenuated_mip: attenuated maximum intensity projection. Cast a ray and attenuate values based on integral of encountered values, display the maximum value that was encountered after attenuation. This will make nearer objects appear more prominent. """ self.layer.rendering = text self._update_rendering_parameter_visibility() def changeDepiction(self, text): self.layer.depiction = text self._update_plane_parameter_visibility() def changePlaneThickness(self, value: float): self.layer.plane.thickness = value def changeIsoThreshold(self, value): """Change isosurface threshold on the layer model. Parameters ---------- value : float Threshold for isosurface. """ with self.layer.events.blocker(self._on_iso_threshold_change): self.layer.iso_threshold = value def _on_contrast_limits_change(self): with self.layer.events.blocker(self._on_iso_threshold_change): cmin, cmax = self.layer.contrast_limits_range self.isoThresholdSlider.setMinimum(cmin) self.isoThresholdSlider.setMaximum(cmax) return super()._on_contrast_limits_change() def _on_iso_threshold_change(self): """Receive layer model isosurface change event and update the slider.""" with self.layer.events.iso_threshold.blocker(): self.isoThresholdSlider.setValue(self.layer.iso_threshold) def changeAttenuation(self, value): """Change attenuation rate for attenuated maximum intensity projection. Parameters ---------- value : Float Attenuation rate for attenuated maximum intensity projection. """ with self.layer.events.blocker(self._on_attenuation_change): self.layer.attenuation = value / 200 def _on_attenuation_change(self): """Receive layer model attenuation change event and update the slider.""" with self.layer.events.attenuation.blocker(): self.attenuationSlider.setValue(int(self.layer.attenuation * 200)) def _on_interpolation_change(self, event): """Receive layer interpolation change event and update dropdown menu. Parameters ---------- event : napari.utils.event.Event The napari event that triggered this method. """ interp_string = event.value.value with self.layer.events.interpolation.blocker(), self.layer.events.interpolation2d.blocker(), self.layer.events.interpolation3d.blocker(): if self.interpComboBox.findText(interp_string) == -1: self.interpComboBox.addItem(interp_string) self.interpComboBox.setCurrentText(interp_string) def _on_rendering_change(self): """Receive layer model rendering change event and update dropdown menu.""" with self.layer.events.rendering.blocker(): index = self.renderComboBox.findText( self.layer.rendering, Qt.MatchFlag.MatchFixedString ) self.renderComboBox.setCurrentIndex(index) self._update_rendering_parameter_visibility() def _on_depiction_change(self): """Receive layer model depiction change event and update combobox.""" with self.layer.events.depiction.blocker(): index = self.depictionComboBox.findText( self.layer.depiction, Qt.MatchFlag.MatchFixedString ) self.depictionComboBox.setCurrentIndex(index) self._update_plane_parameter_visibility() def _on_plane_thickness_change(self): with self.layer.plane.events.blocker(): self.planeThicknessSlider.setValue(self.layer.plane.thickness) def _update_rendering_parameter_visibility(self): """Hide isosurface rendering parameters if they aren't needed.""" rendering = ImageRendering(self.layer.rendering) iso_threshold_visible = rendering == ImageRendering.ISO self.isoThresholdLabel.setVisible(iso_threshold_visible) self.isoThresholdSlider.setVisible(iso_threshold_visible) attenuation_visible = rendering == ImageRendering.ATTENUATED_MIP self.attenuationSlider.setVisible(attenuation_visible) self.attenuationLabel.setVisible(attenuation_visible) def _update_plane_parameter_visibility(self): """Hide plane rendering controls if they aren't needed.""" depiction = VolumeDepiction(self.layer.depiction) visible = depiction == VolumeDepiction.PLANE and self.ndisplay == 3 self.planeNormalButtons.setVisible(visible) self.planeNormalLabel.setVisible(visible) self.planeThicknessSlider.setVisible(visible) self.planeThicknessLabel.setVisible(visible) def _update_interpolation_combo(self): interp_names = [i.value for i in Interpolation.view_subset()] interp = ( self.layer.interpolation2d if self.ndisplay == 2 else self.layer.interpolation3d ) with qt_signals_blocked(self.interpComboBox): self.interpComboBox.clear() self.interpComboBox.addItems(interp_names) self.interpComboBox.setCurrentText(interp) def _on_ndisplay_changed(self): """Update widget visibility based on 2D and 3D visualization modes.""" self._update_interpolation_combo() self._update_plane_parameter_visibility() if self.ndisplay == 2: self.isoThresholdSlider.hide() self.isoThresholdLabel.hide() self.attenuationSlider.hide() self.attenuationLabel.hide() self.renderComboBox.hide() self.renderLabel.hide() self.depictionComboBox.hide() self.depictionLabel.hide() else: self.renderComboBox.show() self.renderLabel.show() self._update_rendering_parameter_visibility() self.depictionComboBox.show() self.depictionLabel.show() class PlaneNormalButtons(QWidget): """Qt buttons for controlling plane orientation. Attributes ---------- xButton : qtpy.QtWidgets.QPushButton Button which orients a plane normal along the x axis. yButton : qtpy.QtWidgets.QPushButton Button which orients a plane normal along the y axis. zButton : qtpy.QtWidgets.QPushButton Button which orients a plane normal along the z axis. obliqueButton : qtpy.QtWidgets.QPushButton Button which orients a plane normal along the camera view direction. """ def __init__(self, parent=None) -> None: super().__init__(parent=parent) self.setLayout(QHBoxLayout()) self.layout().setSpacing(2) self.layout().setContentsMargins(0, 0, 0, 0) self.xButton = QPushButton('x') self.yButton = QPushButton('y') self.zButton = QPushButton('z') self.obliqueButton = QPushButton(trans._('oblique')) self.layout().addWidget(self.xButton) self.layout().addWidget(self.yButton) self.layout().addWidget(self.zButton) self.layout().addWidget(self.obliqueButton) napari-0.5.0a1/napari/_qt/layer_controls/qt_image_controls_base.py000066400000000000000000000262051437041365600253770ustar00rootroot00000000000000from __future__ import annotations from typing import TYPE_CHECKING import numpy as np from qtpy.QtCore import Qt from qtpy.QtGui import QImage, QPixmap from qtpy.QtWidgets import QHBoxLayout, QLabel, QPushButton, QWidget from superqt import QDoubleRangeSlider from napari._qt.layer_controls.qt_colormap_combobox import QtColormapComboBox from napari._qt.layer_controls.qt_layer_controls_base import QtLayerControls from napari._qt.utils import qt_signals_blocked from napari._qt.widgets._slider_compat import QDoubleSlider from napari._qt.widgets.qt_range_slider_popup import QRangeSliderPopup from napari.utils._dtype import normalize_dtype from napari.utils.colormaps import AVAILABLE_COLORMAPS from napari.utils.events.event_utils import connect_no_arg, connect_setattr from napari.utils.translations import trans if TYPE_CHECKING: from napari.layers import Image class _QDoubleRangeSlider(QDoubleRangeSlider): def mousePressEvent(self, event): """Update the slider, or, on right-click, pop-up an expanded slider. The expanded slider provides finer control, directly editable values, and the ability to change the available range of the sliders. Parameters ---------- event : napari.utils.event.Event The napari event that triggered this method. """ if event.button() == Qt.MouseButton.RightButton: self.parent().show_clim_popupup() else: super().mousePressEvent(event) class QtBaseImageControls(QtLayerControls): """Superclass for classes requiring colormaps, contrast & gamma sliders. This class is never directly instantiated anywhere. It is subclassed by QtImageControls and QtSurfaceControls. Parameters ---------- layer : napari.layers.Layer An instance of a napari layer. Attributes ---------- clim_popup : napari._qt.qt_range_slider_popup.QRangeSliderPopup Popup widget launching the contrast range slider. colorbarLabel : qtpy.QtWidgets.QLabel Label text of colorbar widget. colormapComboBox : qtpy.QtWidgets.QComboBox Dropdown widget for selecting the layer colormap. contrastLimitsSlider : superqt.QRangeSlider Contrast range slider widget. gammaSlider : qtpy.QtWidgets.QSlider Gamma adjustment slider widget. layer : napari.layers.Layer An instance of a napari layer. """ def __init__(self, layer: Image) -> None: super().__init__(layer) self.layer.events.colormap.connect(self._on_colormap_change) self.layer.events.gamma.connect(self._on_gamma_change) self.layer.events.contrast_limits.connect( self._on_contrast_limits_change ) self.layer.events.contrast_limits_range.connect( self._on_contrast_limits_range_change ) comboBox = QtColormapComboBox(self) comboBox.setObjectName("colormapComboBox") comboBox._allitems = set(self.layer.colormaps) for name, cm in AVAILABLE_COLORMAPS.items(): if name in self.layer.colormaps: comboBox.addItem(cm._display_name, name) comboBox.currentTextChanged.connect(self.changeColor) self.colormapComboBox = comboBox # Create contrast_limits slider self.contrastLimitsSlider = _QDoubleRangeSlider( Qt.Orientation.Horizontal, self ) decimals = range_to_decimals( self.layer.contrast_limits_range, self.layer.dtype ) self.contrastLimitsSlider.setRange(*self.layer.contrast_limits_range) self.contrastLimitsSlider.setSingleStep(10**-decimals) self.contrastLimitsSlider.setValue(self.layer.contrast_limits) self.contrastLimitsSlider.setToolTip( trans._('Right click for detailed slider popup.') ) self.clim_popup = None connect_setattr( self.contrastLimitsSlider.valueChanged, self.layer, "contrast_limits", ) connect_setattr( self.contrastLimitsSlider.rangeChanged, self.layer, 'contrast_limits_range', ) self.autoScaleBar = AutoScaleButtons(layer, self) # gamma slider sld = QDoubleSlider(Qt.Orientation.Horizontal, parent=self) sld.setMinimum(0.2) sld.setMaximum(2) sld.setSingleStep(0.02) sld.setValue(self.layer.gamma) connect_setattr(sld.valueChanged, self.layer, 'gamma') self.gammaSlider = sld self.colorbarLabel = QLabel(parent=self) self.colorbarLabel.setObjectName('colorbar') self.colorbarLabel.setToolTip(trans._('Colorbar')) self._on_colormap_change() def changeColor(self, text): """Change colormap on the layer model. Parameters ---------- text : str Colormap name. """ self.layer.colormap = self.colormapComboBox.currentData() def _on_contrast_limits_change(self): """Receive layer model contrast limits change event and update slider.""" with qt_signals_blocked(self.contrastLimitsSlider): self.contrastLimitsSlider.setValue(self.layer.contrast_limits) if self.clim_popup: with qt_signals_blocked(self.clim_popup.slider): self.clim_popup.slider.setValue(self.layer.contrast_limits) def _on_contrast_limits_range_change(self): """Receive layer model contrast limits change event and update slider.""" with qt_signals_blocked(self.contrastLimitsSlider): decimals = range_to_decimals( self.layer.contrast_limits_range, self.layer.dtype ) self.contrastLimitsSlider.setRange( *self.layer.contrast_limits_range ) self.contrastLimitsSlider.setSingleStep(10**-decimals) if self.clim_popup: with qt_signals_blocked(self.clim_popup.slider): self.clim_popup.slider.setRange( *self.layer.contrast_limits_range ) def _on_colormap_change(self): """Receive layer model colormap change event and update dropdown menu.""" name = self.layer.colormap.name if name not in self.colormapComboBox._allitems: if cm := AVAILABLE_COLORMAPS.get(name): self.colormapComboBox._allitems.add(name) self.colormapComboBox.addItem(cm._display_name, name) if name != self.colormapComboBox.currentData(): index = self.colormapComboBox.findData(name) self.colormapComboBox.setCurrentIndex(index) # Note that QImage expects the image width followed by height cbar = self.layer.colormap.colorbar image = QImage( cbar, cbar.shape[1], cbar.shape[0], QImage.Format_RGBA8888, ) self.colorbarLabel.setPixmap(QPixmap.fromImage(image)) def _on_gamma_change(self): """Receive the layer model gamma change event and update the slider.""" with qt_signals_blocked(self.gammaSlider): self.gammaSlider.setValue(self.layer.gamma) def closeEvent(self, event): self.deleteLater() self.layer.events.disconnect(self) super().closeEvent(event) def show_clim_popupup(self): self.clim_popup = QContrastLimitsPopup(self.layer, self) self.clim_popup.setParent(self) self.clim_popup.move_to('top', min_length=650) self.clim_popup.show() class AutoScaleButtons(QWidget): def __init__(self, layer: Image, parent=None) -> None: super().__init__(parent=parent) self.setLayout(QHBoxLayout()) self.layout().setSpacing(2) self.layout().setContentsMargins(0, 0, 0, 0) once_btn = QPushButton(trans._('once')) once_btn.setFocusPolicy(Qt.FocusPolicy.NoFocus) auto_btn = QPushButton(trans._('continuous')) auto_btn.setCheckable(True) auto_btn.setFocusPolicy(Qt.FocusPolicy.NoFocus) once_btn.clicked.connect(lambda: auto_btn.setChecked(False)) connect_no_arg(once_btn.clicked, layer, "reset_contrast_limits") connect_setattr(auto_btn.toggled, layer, "_keep_auto_contrast") connect_no_arg(auto_btn.clicked, layer, "reset_contrast_limits") self.layout().addWidget(once_btn) self.layout().addWidget(auto_btn) # just for testing self._once_btn = once_btn self._auto_btn = auto_btn class QContrastLimitsPopup(QRangeSliderPopup): def __init__(self, layer: Image, parent=None) -> None: super().__init__(parent) decimals = range_to_decimals(layer.contrast_limits_range, layer.dtype) self.slider.setRange(*layer.contrast_limits_range) self.slider.setDecimals(decimals) self.slider.setSingleStep(10**-decimals) self.slider.setValue(layer.contrast_limits) connect_setattr(self.slider.valueChanged, layer, "contrast_limits") connect_setattr( self.slider.rangeChanged, layer, "contrast_limits_range" ) def reset(): layer.reset_contrast_limits() layer.contrast_limits_range = layer.contrast_limits reset_btn = QPushButton("reset") reset_btn.setObjectName("reset_clims_button") reset_btn.setToolTip(trans._("autoscale contrast to data range")) reset_btn.setFixedWidth(45) reset_btn.clicked.connect(reset) self._layout.addWidget( reset_btn, alignment=Qt.AlignmentFlag.AlignBottom ) # the "full range" button doesn't do anything if it's not an # unsigned integer type (it's unclear what range should be set) # so we don't show create it at all. if np.issubdtype(normalize_dtype(layer.dtype), np.integer): range_btn = QPushButton("full range") range_btn.setObjectName("full_clim_range_button") range_btn.setToolTip( trans._("set contrast range to full bit-depth") ) range_btn.setFixedWidth(75) range_btn.clicked.connect(layer.reset_contrast_limits_range) self._layout.addWidget( range_btn, alignment=Qt.AlignmentFlag.AlignBottom ) def range_to_decimals(range_, dtype): """Convert a range to decimals of precision. Parameters ---------- range_ : tuple Slider range, min and then max values. dtype : np.dtype Data type of the layer. Integers layers are given integer. step sizes. Returns ------- int Decimals of precision. """ if hasattr(dtype, 'numpy_dtype'): # retrieve the corresponding numpy.dtype from a tensorstore.dtype dtype = dtype.numpy_dtype if np.issubdtype(dtype, np.integer): return 0 else: # scale precision with the log of the data range order of magnitude # eg. 0 - 1 (0 order of mag) -> 3 decimal places # 0 - 10 (1 order of mag) -> 2 decimals # 0 - 100 (2 orders of mag) -> 1 decimal # ≥ 3 orders of mag -> no decimals # no more than 64 decimals d_range = np.subtract(*range_[::-1]) return min(64, max(int(3 - np.log10(d_range)), 0)) napari-0.5.0a1/napari/_qt/layer_controls/qt_labels_controls.py000066400000000000000000000471551437041365600245740ustar00rootroot00000000000000from typing import TYPE_CHECKING import numpy as np from qtpy.QtCore import Qt from qtpy.QtGui import QColor, QPainter from qtpy.QtWidgets import ( QButtonGroup, QCheckBox, QComboBox, QHBoxLayout, QLabel, QSpinBox, QWidget, ) from superqt import QLargeIntSpinBox from napari._qt.layer_controls.qt_layer_controls_base import QtLayerControls from napari._qt.utils import set_widgets_enabled_with_opacity from napari._qt.widgets._slider_compat import QSlider from napari._qt.widgets.qt_mode_buttons import ( QtModePushButton, QtModeRadioButton, ) from napari.layers.labels._labels_constants import ( LABEL_COLOR_MODE_TRANSLATIONS, LabelsRendering, Mode, ) from napari.layers.labels._labels_utils import get_dtype from napari.utils._dtype import get_dtype_limits from napari.utils.action_manager import action_manager from napari.utils.events import disconnect_events from napari.utils.translations import trans if TYPE_CHECKING: import napari.layers INT32_MAX = 2**31 - 1 class QtLabelsControls(QtLayerControls): """Qt view and controls for the napari Labels layer. Parameters ---------- layer : napari.layers.Labels An instance of a napari Labels layer. Attributes ---------- button_group : qtpy.QtWidgets.QButtonGroup Button group of labels layer modes: PAN_ZOOM, PICKER, PAINT, ERASE, or FILL. colormapUpdate : qtpy.QtWidgets.QPushButton Button to update colormap of label layer. contigCheckBox : qtpy.QtWidgets.QCheckBox Checkbox to control if label layer is contiguous. fill_button : qtpy.QtWidgets.QtModeRadioButton Button to select FILL mode on Labels layer. layer : napari.layers.Labels An instance of a napari Labels layer. ndimSpinBox : qtpy.QtWidgets.QSpinBox Spinbox to control the number of editable dimensions of label layer. paint_button : qtpy.QtWidgets.QtModeRadioButton Button to select PAINT mode on Labels layer. panzoom_button : qtpy.QtWidgets.QtModeRadioButton Button to select PAN_ZOOM mode on Labels layer. pick_button : qtpy.QtWidgets.QtModeRadioButton Button to select PICKER mode on Labels layer. erase_button : qtpy.QtWidgets.QtModeRadioButton Button to select ERASE mode on Labels layer. selectionSpinBox : superqt.QLargeIntSpinBox Widget to select a specific label by its index. N.B. cannot represent labels > 2**53. Raises ------ ValueError Raise error if label mode is not PAN_ZOOM, PICKER, PAINT, ERASE, or FILL. """ layer: 'napari.layers.Labels' def __init__(self, layer) -> None: super().__init__(layer) self.layer.events.mode.connect(self._on_mode_change) self.layer.events.rendering.connect(self._on_rendering_change) self.layer.events.selected_label.connect( self._on_selected_label_change ) self.layer.events.brush_size.connect(self._on_brush_size_change) self.layer.events.contiguous.connect(self._on_contiguous_change) self.layer.events.n_edit_dimensions.connect( self._on_n_edit_dimensions_change ) self.layer.events.contour.connect(self._on_contour_change) self.layer.events.editable.connect(self._on_editable_or_visible_change) self.layer.events.visible.connect(self._on_editable_or_visible_change) self.layer.events.preserve_labels.connect( self._on_preserve_labels_change ) self.layer.events.color_mode.connect(self._on_color_mode_change) # selection spinbox self.selectionSpinBox = QLargeIntSpinBox() dtype_lims = get_dtype_limits(get_dtype(layer)) self.selectionSpinBox.setRange(*dtype_lims) self.selectionSpinBox.setKeyboardTracking(False) self.selectionSpinBox.valueChanged.connect(self.changeSelection) self.selectionSpinBox.setAlignment(Qt.AlignmentFlag.AlignCenter) self._on_selected_label_change() sld = QSlider(Qt.Orientation.Horizontal) sld.setFocusPolicy(Qt.FocusPolicy.NoFocus) sld.setMinimum(1) sld.setMaximum(40) sld.setSingleStep(1) sld.valueChanged.connect(self.changeSize) self.brushSizeSlider = sld self._on_brush_size_change() contig_cb = QCheckBox() contig_cb.setToolTip(trans._('contiguous editing')) contig_cb.stateChanged.connect(self.change_contig) self.contigCheckBox = contig_cb self._on_contiguous_change() ndim_sb = QSpinBox() self.ndimSpinBox = ndim_sb ndim_sb.setToolTip(trans._('number of dimensions for label editing')) ndim_sb.valueChanged.connect(self.change_n_edit_dim) ndim_sb.setMinimum(2) ndim_sb.setMaximum(self.layer.ndim) ndim_sb.setSingleStep(1) ndim_sb.setAlignment(Qt.AlignmentFlag.AlignCenter) self._on_n_edit_dimensions_change() self.contourSpinBox = QLargeIntSpinBox() self.contourSpinBox.setRange(*dtype_lims) self.contourSpinBox.setToolTip(trans._('display contours of labels')) self.contourSpinBox.valueChanged.connect(self.change_contour) self.contourSpinBox.setKeyboardTracking(False) self.contourSpinBox.setAlignment(Qt.AlignmentFlag.AlignCenter) self._on_contour_change() preserve_labels_cb = QCheckBox() preserve_labels_cb.setToolTip( trans._('preserve existing labels while painting') ) preserve_labels_cb.stateChanged.connect(self.change_preserve_labels) self.preserveLabelsCheckBox = preserve_labels_cb self._on_preserve_labels_change() selectedColorCheckbox = QCheckBox() selectedColorCheckbox.setToolTip( trans._("Display only selected label") ) selectedColorCheckbox.stateChanged.connect(self.toggle_selected_mode) self.selectedColorCheckbox = selectedColorCheckbox # shuffle colormap button self.colormapUpdate = QtModePushButton( layer, 'shuffle', slot=self.changeColor, tooltip=trans._('shuffle colors'), ) self.panzoom_button = QtModeRadioButton( layer, 'zoom', Mode.PAN_ZOOM, checked=True, ) action_manager.bind_button( 'napari:activate_label_pan_zoom_mode', self.panzoom_button ) self.pick_button = QtModeRadioButton(layer, 'picker', Mode.PICK) action_manager.bind_button( 'napari:activate_label_picker_mode', self.pick_button ) self.paint_button = QtModeRadioButton(layer, 'paint', Mode.PAINT) action_manager.bind_button( 'napari:activate_paint_mode', self.paint_button ) self.fill_button = QtModeRadioButton( layer, 'fill', Mode.FILL, ) action_manager.bind_button( 'napari:activate_fill_mode', self.fill_button, ) self.erase_button = QtModeRadioButton( layer, 'erase', Mode.ERASE, ) action_manager.bind_button( 'napari:activate_label_erase_mode', self.erase_button, ) # don't bind with action manager as this would remove "Toggle with {shortcut}" self._EDIT_BUTTONS = ( self.paint_button, self.pick_button, self.fill_button, self.erase_button, ) self.button_group = QButtonGroup(self) self.button_group.addButton(self.panzoom_button) self.button_group.addButton(self.paint_button) self.button_group.addButton(self.pick_button) self.button_group.addButton(self.fill_button) self.button_group.addButton(self.erase_button) self._on_editable_or_visible_change() button_row = QHBoxLayout() button_row.addStretch(1) button_row.addWidget(self.colormapUpdate) button_row.addWidget(self.erase_button) button_row.addWidget(self.paint_button) button_row.addWidget(self.fill_button) button_row.addWidget(self.pick_button) button_row.addWidget(self.panzoom_button) button_row.setSpacing(4) button_row.setContentsMargins(0, 0, 0, 5) renderComboBox = QComboBox(self) rendering_options = [i.value for i in LabelsRendering] renderComboBox.addItems(rendering_options) index = renderComboBox.findText( self.layer.rendering, Qt.MatchFlag.MatchFixedString ) renderComboBox.setCurrentIndex(index) renderComboBox.currentTextChanged.connect(self.changeRendering) self.renderComboBox = renderComboBox self.renderLabel = QLabel(trans._('rendering:')) self._on_ndisplay_changed() color_mode_comboBox = QComboBox(self) for index, (data, text) in enumerate( LABEL_COLOR_MODE_TRANSLATIONS.items() ): data = data.value color_mode_comboBox.addItem(text, data) if self.layer.color_mode == data: color_mode_comboBox.setCurrentIndex(index) color_mode_comboBox.activated.connect(self.change_color_mode) self.colorModeComboBox = color_mode_comboBox self._on_color_mode_change() color_layout = QHBoxLayout() self.colorBox = QtColorBox(layer) color_layout.addWidget(self.colorBox) color_layout.addWidget(self.selectionSpinBox) self.layout().addRow(button_row) self.layout().addRow(trans._('label:'), color_layout) self.layout().addRow(self.opacityLabel, self.opacitySlider) self.layout().addRow(trans._('brush size:'), self.brushSizeSlider) self.layout().addRow(trans._('blending:'), self.blendComboBox) self.layout().addRow(self.renderLabel, self.renderComboBox) self.layout().addRow(trans._('color mode:'), self.colorModeComboBox) self.layout().addRow(trans._('contour:'), self.contourSpinBox) self.layout().addRow(trans._('n edit dim:'), self.ndimSpinBox) self.layout().addRow(trans._('contiguous:'), self.contigCheckBox) self.layout().addRow( trans._('preserve\nlabels:'), self.preserveLabelsCheckBox ) self.layout().addRow( trans._('show\nselected:'), self.selectedColorCheckbox ) def _on_mode_change(self, event): """Receive layer model mode change event and update checkbox ticks. Parameters ---------- event : napari.utils.event.Event The napari event that triggered this method. Raises ------ ValueError Raise error if event.mode is not PAN_ZOOM, PICK, PAINT, ERASE, or FILL """ mode = event.mode if mode == Mode.PAN_ZOOM: self.panzoom_button.setChecked(True) elif mode == Mode.PICK: self.pick_button.setChecked(True) elif mode == Mode.PAINT: self.paint_button.setChecked(True) elif mode == Mode.FILL: self.fill_button.setChecked(True) elif mode == Mode.ERASE: self.erase_button.setChecked(True) elif mode != Mode.TRANSFORM: raise ValueError(trans._("Mode not recognized")) def changeRendering(self, text): """Change rendering mode for image display. Parameters ---------- text : str Rendering mode used by vispy. Selects a preset rendering mode in vispy that determines how volume is displayed: * translucent: voxel colors are blended along the view ray until the result is opaque. * iso_categorical: isosurface for categorical data (e.g., labels). Cast a ray until a value greater than zero is encountered. At that location, lighning calculations are performed to give the visual appearance of a surface. """ self.layer.rendering = text def changeColor(self): """Change colormap of the label layer.""" self.layer.new_colormap() def changeSelection(self, value): """Change currently selected label. Parameters ---------- value : int Index of label to select. """ self.layer.selected_label = value self.selectionSpinBox.clearFocus() self.setFocus() def toggle_selected_mode(self, state): self.layer.show_selected_label = state == Qt.CheckState.Checked def changeSize(self, value): """Change paint brush size. Parameters ---------- value : float Size of the paint brush. """ self.layer.brush_size = value def change_contig(self, state): """Toggle contiguous state of label layer. Parameters ---------- state : QCheckBox Checkbox indicating if labels are contiguous. """ self.layer.contiguous = state == Qt.CheckState.Checked def change_n_edit_dim(self, value): """Change the number of editable dimensions of label layer. Parameters ---------- value : int The number of editable dimensions to set. """ self.layer.n_edit_dimensions = value self.ndimSpinBox.clearFocus() self.setFocus() def change_contour(self, value): """Change contour thickness. Parameters ---------- value : int Thickness of contour. """ self.layer.contour = value self.contourSpinBox.clearFocus() self.setFocus() def change_preserve_labels(self, state): """Toggle preserve_labels state of label layer. Parameters ---------- state : QCheckBox Checkbox indicating if overwriting label is enabled. """ self.layer.preserve_labels = state == Qt.CheckState.Checked def change_color_mode(self): """Change color mode of label layer""" self.layer.color_mode = self.colorModeComboBox.currentData() def _on_contour_change(self): """Receive layer model contour value change event and update spinbox.""" with self.layer.events.contour.blocker(): value = self.layer.contour self.contourSpinBox.setValue(value) def _on_selected_label_change(self): """Receive layer model label selection change event and update spinbox.""" with self.layer.events.selected_label.blocker(): value = self.layer.selected_label self.selectionSpinBox.setValue(value) def _on_brush_size_change(self): """Receive layer model brush size change event and update the slider.""" with self.layer.events.brush_size.blocker(): value = self.layer.brush_size value = np.maximum(1, int(value)) if value > self.brushSizeSlider.maximum(): self.brushSizeSlider.setMaximum(int(value)) self.brushSizeSlider.setValue(value) def _on_n_edit_dimensions_change(self): """Receive layer model n-dim mode change event and update the checkbox.""" with self.layer.events.n_edit_dimensions.blocker(): value = self.layer.n_edit_dimensions self.ndimSpinBox.setValue(int(value)) def _on_contiguous_change(self): """Receive layer model contiguous change event and update the checkbox.""" with self.layer.events.contiguous.blocker(): self.contigCheckBox.setChecked(self.layer.contiguous) def _on_preserve_labels_change(self): """Receive layer model preserve_labels event and update the checkbox.""" with self.layer.events.preserve_labels.blocker(): self.preserveLabelsCheckBox.setChecked(self.layer.preserve_labels) def _on_color_mode_change(self): """Receive layer model color.""" with self.layer.events.color_mode.blocker(): self.colorModeComboBox.setCurrentIndex( self.colorModeComboBox.findData(self.layer.color_mode) ) def _on_editable_or_visible_change(self): """Receive layer model editable/visible change event & enable/disable buttons.""" set_widgets_enabled_with_opacity( self, self._EDIT_BUTTONS, self.layer.editable and self.layer.visible, ) def _on_rendering_change(self): """Receive layer model rendering change event and update dropdown menu.""" with self.layer.events.rendering.blocker(): index = self.renderComboBox.findText( self.layer.rendering, Qt.MatchFlag.MatchFixedString ) self.renderComboBox.setCurrentIndex(index) def _on_ndisplay_changed(self): render_visible = self.ndisplay == 3 self.renderComboBox.setVisible(render_visible) self.renderLabel.setVisible(render_visible) self._on_editable_or_visible_change() def deleteLater(self): disconnect_events(self.layer.events, self.colorBox) super().deleteLater() class QtColorBox(QWidget): """A widget that shows a square with the current label color. Parameters ---------- layer : napari.layers.Layer An instance of a napari layer. """ def __init__(self, layer) -> None: super().__init__() self.layer = layer self.layer.events.selected_label.connect( self._on_selected_label_change ) self.layer.events.opacity.connect(self._on_opacity_change) self.layer.events.colormap.connect(self._on_colormap_change) self.setAttribute(Qt.WidgetAttribute.WA_DeleteOnClose) self._height = 24 self.setFixedWidth(self._height) self.setFixedHeight(self._height) self.setToolTip(trans._('Selected label color')) self.color = None def _on_selected_label_change(self): """Receive layer model label selection change event & update colorbox.""" self.update() def _on_opacity_change(self): """Receive layer model label selection change event & update colorbox.""" self.update() def _on_colormap_change(self): """Receive label colormap change event & update colorbox.""" self.update() def paintEvent(self, event): """Paint the colorbox. If no color, display a checkerboard pattern. Parameters ---------- event : qtpy.QtCore.QEvent Event from the Qt context. """ painter = QPainter(self) if self.layer._selected_color is None: self.color = None for i in range(self._height // 4): for j in range(self._height // 4): if (i % 2 == 0 and j % 2 == 0) or ( i % 2 == 1 and j % 2 == 1 ): painter.setPen(QColor(230, 230, 230)) painter.setBrush(QColor(230, 230, 230)) else: painter.setPen(QColor(25, 25, 25)) painter.setBrush(QColor(25, 25, 25)) painter.drawRect(i * 4, j * 4, 5, 5) else: color = np.multiply(self.layer._selected_color, self.layer.opacity) color = np.round(255 * color).astype(int) painter.setPen(QColor(*list(color))) painter.setBrush(QColor(*list(color))) painter.drawRect(0, 0, self._height, self._height) self.color = tuple(color) def deleteLater(self): disconnect_events(self.layer.events, self) super().deleteLater() def closeEvent(self, event): """Disconnect events when widget is closing.""" disconnect_events(self.layer.events, self) super().closeEvent(event) napari-0.5.0a1/napari/_qt/layer_controls/qt_layer_controls_base.py000066400000000000000000000132341437041365600254270ustar00rootroot00000000000000from qtpy.QtCore import Qt from qtpy.QtWidgets import QComboBox, QFormLayout, QFrame, QLabel from napari._qt.widgets._slider_compat import QDoubleSlider from napari.layers.base._base_constants import BLENDING_TRANSLATIONS, Blending from napari.layers.base.base import Layer from napari.utils.events import disconnect_events from napari.utils.translations import trans # opaque and minimum blending do not support changing alpha (opacity) NO_OPACITY_BLENDING_MODES = {str(Blending.MINIMUM), str(Blending.OPAQUE)} class LayerFormLayout(QFormLayout): """Reusable form layout for subwidgets in each QtLayerControls class""" def __init__(self, QWidget=None) -> None: super().__init__(QWidget) self.setContentsMargins(0, 0, 0, 0) self.setSpacing(4) self.setFieldGrowthPolicy(QFormLayout.AllNonFixedFieldsGrow) class QtLayerControls(QFrame): """Superclass for all the other LayerControl classes. This class is never directly instantiated anywhere. Parameters ---------- layer : napari.layers.Layer An instance of a napari layer. Attributes ---------- blendComboBox : qtpy.QtWidgets.QComboBox Dropdown widget to select blending mode of layer. layer : napari.layers.Layer An instance of a napari layer. opacitySlider : qtpy.QtWidgets.QSlider Slider controlling opacity of the layer. opacityLabel : qtpy.QtWidgets.QLabel Label for the opacity slider widget. """ def __init__(self, layer: Layer) -> None: super().__init__() self._ndisplay: int = 2 self.layer = layer self.layer.events.blending.connect(self._on_blending_change) self.layer.events.opacity.connect(self._on_opacity_change) self.setObjectName('layer') self.setMouseTracking(True) self.setLayout(LayerFormLayout(self)) sld = QDoubleSlider(Qt.Orientation.Horizontal, parent=self) sld.setFocusPolicy(Qt.FocusPolicy.NoFocus) sld.setMinimum(0) sld.setMaximum(1) sld.setSingleStep(0.01) sld.valueChanged.connect(self.changeOpacity) self.opacitySlider = sld self.opacityLabel = QLabel(trans._('opacity:')) self._on_opacity_change() blend_comboBox = QComboBox(self) for index, (data, text) in enumerate(BLENDING_TRANSLATIONS.items()): data = data.value blend_comboBox.addItem(text, data) if data == self.layer.blending: blend_comboBox.setCurrentIndex(index) blend_comboBox.currentTextChanged.connect(self.changeBlending) self.blendComboBox = blend_comboBox # opaque and minimum blending do not support changing alpha self.opacitySlider.setEnabled( self.layer.blending not in NO_OPACITY_BLENDING_MODES ) self.opacityLabel.setEnabled( self.layer.blending not in NO_OPACITY_BLENDING_MODES ) def changeOpacity(self, value): """Change opacity value on the layer model. Parameters ---------- value : float Opacity value for shapes. Input range 0 - 100 (transparent to fully opaque). """ with self.layer.events.blocker(self._on_opacity_change): self.layer.opacity = value def changeBlending(self, text): """Change blending mode on the layer model. Parameters ---------- text : str Name of blending mode, eg: 'translucent', 'additive', 'opaque'. """ self.layer.blending = self.blendComboBox.currentData() # opaque and minimum blending do not support changing alpha self.opacitySlider.setEnabled( self.layer.blending not in NO_OPACITY_BLENDING_MODES ) self.opacityLabel.setEnabled( self.layer.blending not in NO_OPACITY_BLENDING_MODES ) blending_tooltip = '' if self.layer.blending == str(Blending.MINIMUM): blending_tooltip = trans._( '`minimum` blending mode works best with inverted colormaps with a white background.', ) self.blendComboBox.setToolTip(blending_tooltip) self.layer.help = blending_tooltip def _on_opacity_change(self): """Receive layer model opacity change event and update opacity slider.""" with self.layer.events.opacity.blocker(): self.opacitySlider.setValue(self.layer.opacity) def _on_blending_change(self): """Receive layer model blending mode change event and update slider.""" with self.layer.events.blending.blocker(): self.blendComboBox.setCurrentIndex( self.blendComboBox.findData(self.layer.blending) ) @property def ndisplay(self) -> int: """The number of dimensions displayed in the canvas.""" return self._ndisplay @ndisplay.setter def ndisplay(self, ndisplay: int) -> None: self._ndisplay = ndisplay self._on_ndisplay_changed() def _on_ndisplay_changed(self) -> None: """Respond to a change to the number of dimensions displayed in the viewer. This is needed because some layer controls may have options that are specific to 2D or 3D visualization only. """ pass def deleteLater(self): disconnect_events(self.layer.events, self) super().deleteLater() def close(self): """Disconnect events when widget is closing.""" disconnect_events(self.layer.events, self) for child in self.children(): close_method = getattr(child, 'close', None) if close_method is not None: close_method() return super().close() napari-0.5.0a1/napari/_qt/layer_controls/qt_layer_controls_container.py000066400000000000000000000123711437041365600265000ustar00rootroot00000000000000from qtpy.QtWidgets import QFrame, QStackedWidget from napari._qt.layer_controls.qt_image_controls import QtImageControls from napari._qt.layer_controls.qt_labels_controls import QtLabelsControls from napari._qt.layer_controls.qt_points_controls import QtPointsControls from napari._qt.layer_controls.qt_shapes_controls import QtShapesControls from napari._qt.layer_controls.qt_surface_controls import QtSurfaceControls from napari._qt.layer_controls.qt_tracks_controls import QtTracksControls from napari._qt.layer_controls.qt_vectors_controls import QtVectorsControls from napari.layers import ( Image, Labels, Points, Shapes, Surface, Tracks, Vectors, ) from napari.utils import config from napari.utils.translations import trans layer_to_controls = { Labels: QtLabelsControls, Image: QtImageControls, Points: QtPointsControls, Shapes: QtShapesControls, Surface: QtSurfaceControls, Vectors: QtVectorsControls, Tracks: QtTracksControls, } if config.async_loading: from napari.layers.image.experimental.octree_image import _OctreeImageBase # The user visible layer controls for OctreeImage layers are identical # to the regular image layer controls, for now. layer_to_controls[_OctreeImageBase] = QtImageControls def create_qt_layer_controls(layer): """ Create a qt controls widget for a layer based on its layer type. In case of a subclass, the type higher in the layer's method resolution order will be used. Parameters ---------- layer : napari.layers.Layer Layer that needs its controls widget created. Returns ------- controls : napari.layers.base.QtLayerControls Qt controls widget """ candidates = [] for layer_type in layer_to_controls: if isinstance(layer, layer_type): candidates.append(layer_type) if not candidates: raise TypeError( trans._( 'Could not find QtControls for layer of type {type_}', deferred=True, type_=type(layer), ) ) layer_cls = layer.__class__ # Sort the list of candidates by 'lineage' candidates.sort(key=lambda layer_type: layer_cls.mro().index(layer_type)) controls = layer_to_controls[candidates[0]] return controls(layer) class QtLayerControlsContainer(QStackedWidget): """Container widget for QtLayerControl widgets. Parameters ---------- viewer : napari.components.ViewerModel Napari viewer containing the rendered scene, layers, and controls. Attributes ---------- empty_widget : qtpy.QtWidgets.QFrame Empty placeholder frame for when no layer is selected. viewer : napari.components.ViewerModel Napari viewer containing the rendered scene, layers, and controls. widgets : dict Dictionary of key value pairs matching layer with its widget controls. widgets[layer] = controls """ def __init__(self, viewer) -> None: super().__init__() self.setProperty("emphasized", True) self.viewer = viewer self.setMouseTracking(True) self.empty_widget = QFrame() self.empty_widget.setObjectName("empty_controls_widget") self.widgets = {} self.addWidget(self.empty_widget) self.setCurrentWidget(self.empty_widget) self.viewer.layers.events.inserted.connect(self._add) self.viewer.layers.events.removed.connect(self._remove) viewer.layers.selection.events.active.connect(self._display) viewer.dims.events.ndisplay.connect(self._on_ndisplay_changed) def _on_ndisplay_changed(self, event): """Responds to a change in the dimensionality displayed in the canvas. Parameters ---------- event : Event Event with the new dimensionality value at `event.value`. """ for widget in self.widgets.values(): if widget is not self.empty_widget: widget.ndisplay = event.value def _display(self, event): """Change the displayed controls to be those of the target layer. Parameters ---------- event : Event Event with the target layer at `event.value`. """ layer = event.value if layer is None: self.setCurrentWidget(self.empty_widget) else: controls = self.widgets[layer] self.setCurrentWidget(controls) def _add(self, event): """Add the controls target layer to the list of control widgets. Parameters ---------- event : Event Event with the target layer at `event.value`. """ layer = event.value controls = create_qt_layer_controls(layer) controls.ndisplay = 3 self.addWidget(controls) self.widgets[layer] = controls def _remove(self, event): """Remove the controls target layer from the list of control widgets. Parameters ---------- event : Event Event with the target layer at `event.value`. """ layer = event.value controls = self.widgets[layer] self.removeWidget(controls) controls.hide() controls.deleteLater() controls = None del self.widgets[layer] napari-0.5.0a1/napari/_qt/layer_controls/qt_points_controls.py000066400000000000000000000315261437041365600246410ustar00rootroot00000000000000from typing import TYPE_CHECKING import numpy as np from qtpy.QtCore import Qt, Slot from qtpy.QtWidgets import QButtonGroup, QCheckBox, QComboBox, QHBoxLayout from napari._qt.layer_controls.qt_layer_controls_base import QtLayerControls from napari._qt.utils import ( qt_signals_blocked, set_widgets_enabled_with_opacity, ) from napari._qt.widgets._slider_compat import QSlider from napari._qt.widgets.qt_color_swatch import QColorSwatchEdit from napari._qt.widgets.qt_mode_buttons import ( QtModePushButton, QtModeRadioButton, ) from napari.layers.points._points_constants import ( SYMBOL_TRANSLATION, SYMBOL_TRANSLATION_INVERTED, Mode, ) from napari.utils.action_manager import action_manager from napari.utils.events import disconnect_events from napari.utils.translations import trans if TYPE_CHECKING: import napari.layers class QtPointsControls(QtLayerControls): """Qt view and controls for the napari Points layer. Parameters ---------- layer : napari.layers.Points An instance of a napari Points layer. Attributes ---------- addition_button : qtpy.QtWidgets.QtModeRadioButton Button to add points to layer. button_group : qtpy.QtWidgets.QButtonGroup Button group of points layer modes (ADD, PAN_ZOOM, SELECT). delete_button : qtpy.QtWidgets.QtModePushButton Button to delete points from layer. edgeColorEdit : QColorSwatchEdit Widget to select display color for shape edges. faceColorEdit : QColorSwatchEdit Widget to select display color for shape faces. layer : napari.layers.Points An instance of a napari Points layer. outOfSliceCheckBox : qtpy.QtWidgets.QCheckBox Checkbox to indicate whether to render out of slice. panzoom_button : qtpy.QtWidgets.QtModeRadioButton Button for pan/zoom mode. select_button : qtpy.QtWidgets.QtModeRadioButton Button to select points from layer. sizeSlider : qtpy.QtWidgets.QSlider Slider controlling size of points. symbolComboBox : qtpy.QtWidgets.QComboBox Drop down list of symbol options for points markers. Raises ------ ValueError Raise error if points mode is not recognized. Points mode must be one of: ADD, PAN_ZOOM, or SELECT. """ layer: 'napari.layers.Points' def __init__(self, layer) -> None: super().__init__(layer) self.layer.events.mode.connect(self._on_mode_change) self.layer.events.out_of_slice_display.connect( self._on_out_of_slice_display_change ) self.layer.events.symbol.connect(self._on_symbol_change) self.layer.events.size.connect(self._on_size_change) self.layer.events.current_edge_color.connect( self._on_current_edge_color_change ) self.layer._edge.events.current_color.connect( self._on_current_edge_color_change ) self.layer.events.current_face_color.connect( self._on_current_face_color_change ) self.layer._face.events.current_color.connect( self._on_current_face_color_change ) self.layer.events.editable.connect(self._on_editable_or_visible_change) self.layer.events.visible.connect(self._on_editable_or_visible_change) self.layer.text.events.visible.connect(self._on_text_visibility_change) sld = QSlider(Qt.Orientation.Horizontal) sld.setToolTip( trans._( "Change the size of currently selected points and any added afterwards." ) ) sld.setFocusPolicy(Qt.FocusPolicy.NoFocus) sld.setMinimum(1) if self.layer.size.size: max_value = max(100, int(np.max(self.layer.size)) + 1) else: max_value = 100 sld.setMaximum(max_value) sld.setSingleStep(1) value = self.layer.current_size sld.setValue(int(value)) sld.valueChanged.connect(self.changeSize) self.sizeSlider = sld self.faceColorEdit = QColorSwatchEdit( initial_color=self.layer.current_face_color, tooltip=trans._('click to set current face color'), ) self.edgeColorEdit = QColorSwatchEdit( initial_color=self.layer.current_edge_color, tooltip=trans._('click to set current edge color'), ) self.faceColorEdit.color_changed.connect(self.changeFaceColor) self.edgeColorEdit.color_changed.connect(self.changeEdgeColor) sym_cb = QComboBox() sym_cb.setToolTip( trans._( "Change the symbol of currently selected points and any added afterwards." ) ) current_index = 0 for index, (symbol_string, text) in enumerate( SYMBOL_TRANSLATION.items() ): symbol_string = symbol_string.value sym_cb.addItem(text, symbol_string) if symbol_string == self.layer.current_symbol: current_index = index sym_cb.setCurrentIndex(current_index) sym_cb.currentTextChanged.connect(self.changeSymbol) self.symbolComboBox = sym_cb self.outOfSliceCheckBox = QCheckBox() self.outOfSliceCheckBox.setToolTip(trans._('Out of slice display')) self.outOfSliceCheckBox.setChecked(self.layer.out_of_slice_display) self.outOfSliceCheckBox.stateChanged.connect(self.change_out_of_slice) self.select_button = QtModeRadioButton( layer, 'select_points', Mode.SELECT, ) action_manager.bind_button( 'napari:activate_points_select_mode', self.select_button ) self.addition_button = QtModeRadioButton(layer, 'add_points', Mode.ADD) action_manager.bind_button( 'napari:activate_points_add_mode', self.addition_button ) self.panzoom_button = QtModeRadioButton( layer, 'pan_zoom', Mode.PAN_ZOOM, checked=True, ) action_manager.bind_button( 'napari:activate_points_pan_zoom_mode', self.panzoom_button ) self.delete_button = QtModePushButton( layer, 'delete_shape', ) action_manager.bind_button( 'napari:delete_selected_points', self.delete_button ) self.textDispCheckBox = QCheckBox() self.textDispCheckBox.setToolTip(trans._('toggle text visibility')) self.textDispCheckBox.setChecked(self.layer.text.visible) self.textDispCheckBox.stateChanged.connect(self.change_text_visibility) self._EDIT_BUTTONS = ( self.select_button, self.addition_button, self.delete_button, ) self.button_group = QButtonGroup(self) self.button_group.addButton(self.select_button) self.button_group.addButton(self.addition_button) self.button_group.addButton(self.panzoom_button) self._on_editable_or_visible_change() button_row = QHBoxLayout() button_row.addStretch(1) button_row.addWidget(self.delete_button) button_row.addWidget(self.addition_button) button_row.addWidget(self.select_button) button_row.addWidget(self.panzoom_button) button_row.setContentsMargins(0, 0, 0, 5) button_row.setSpacing(4) self.layout().addRow(button_row) self.layout().addRow(self.opacityLabel, self.opacitySlider) self.layout().addRow(trans._('point size:'), self.sizeSlider) self.layout().addRow(trans._('blending:'), self.blendComboBox) self.layout().addRow(trans._('symbol:'), self.symbolComboBox) self.layout().addRow(trans._('face color:'), self.faceColorEdit) self.layout().addRow(trans._('edge color:'), self.edgeColorEdit) self.layout().addRow(trans._('display text:'), self.textDispCheckBox) self.layout().addRow(trans._('out of slice:'), self.outOfSliceCheckBox) def _on_mode_change(self, event): """Update ticks in checkbox widgets when points layer mode is changed. Available modes for points layer are: * ADD * SELECT * PAN_ZOOM Parameters ---------- event : napari.utils.event.Event The napari event that triggered this method. Raises ------ ValueError Raise error if event.mode is not ADD, PAN_ZOOM, or SELECT. """ mode = event.mode if mode == Mode.ADD: self.addition_button.setChecked(True) elif mode == Mode.SELECT: self.select_button.setChecked(True) elif mode == Mode.PAN_ZOOM: self.panzoom_button.setChecked(True) elif mode != Mode.TRANSFORM: raise ValueError(trans._("Mode not recognized {mode}", mode=mode)) def changeSymbol(self, text): """Change marker symbol of the points on the layer model. Parameters ---------- text : int Index of current marker symbol of points, eg: '+', '.', etc. """ self.layer.current_symbol = SYMBOL_TRANSLATION_INVERTED[text] def changeSize(self, value): """Change size of points on the layer model. Parameters ---------- value : float Size of points. """ self.layer.current_size = value def change_out_of_slice(self, state): """Toggleout of slice display of points layer. Parameters ---------- state : QCheckBox Checkbox indicating whether to render out of slice. """ # needs cast to bool for Qt6 self.layer.out_of_slice_display = bool(state) def change_text_visibility(self, state): """Toggle the visibility of the text. Parameters ---------- state : QCheckBox Checkbox indicating if text is visible. """ # needs cast to bool for Qt6 self.layer.text.visible = bool(state) def _on_text_visibility_change(self): """Receive layer model text visibiltiy change change event and update checkbox.""" with self.layer.text.events.visible.blocker(): self.textDispCheckBox.setChecked(self.layer.text.visible) def _on_out_of_slice_display_change(self): """Receive layer model out_of_slice_display change event and update checkbox.""" with self.layer.events.out_of_slice_display.blocker(): self.outOfSliceCheckBox.setChecked(self.layer.out_of_slice_display) def _on_symbol_change(self): """Receive marker symbol change event and update the dropdown menu.""" with self.layer.events.symbol.blocker(): self.symbolComboBox.setCurrentIndex( self.symbolComboBox.findData(self.layer.current_symbol.value) ) def _on_size_change(self): """Receive layer model size change event and update point size slider.""" with self.layer.events.size.blocker(): value = self.layer.current_size min_val = min(value) if isinstance(value, list) else value max_val = max(value) if isinstance(value, list) else value if min_val < self.sizeSlider.minimum(): self.sizeSlider.setMinimum(max(1, int(min_val - 1))) if max_val > self.sizeSlider.maximum(): self.sizeSlider.setMaximum(int(max_val + 1)) try: self.sizeSlider.setValue(int(value)) except TypeError: pass @Slot(np.ndarray) def changeFaceColor(self, color: np.ndarray): """Update face color of layer model from color picker user input.""" with self.layer.events.current_face_color.blocker(): self.layer.current_face_color = color @Slot(np.ndarray) def changeEdgeColor(self, color: np.ndarray): """Update edge color of layer model from color picker user input.""" with self.layer.events.current_edge_color.blocker(): self.layer.current_edge_color = color def _on_current_face_color_change(self): """Receive layer.current_face_color() change event and update view.""" with qt_signals_blocked(self.faceColorEdit): self.faceColorEdit.setColor(self.layer.current_face_color) def _on_current_edge_color_change(self): """Receive layer.current_edge_color() change event and update view.""" with qt_signals_blocked(self.edgeColorEdit): self.edgeColorEdit.setColor(self.layer.current_edge_color) def _on_editable_or_visible_change(self): """Receive layer model editable/visible change event & enable/disable buttons.""" set_widgets_enabled_with_opacity( self, self._EDIT_BUTTONS, self.layer.editable and self.layer.visible, ) def close(self): """Disconnect events when widget is closing.""" disconnect_events(self.layer.text.events, self) super().close() napari-0.5.0a1/napari/_qt/layer_controls/qt_shapes_controls.py000066400000000000000000000372561437041365600246160ustar00rootroot00000000000000from collections.abc import Iterable from typing import TYPE_CHECKING import numpy as np from qtpy.QtCore import Qt from qtpy.QtWidgets import QButtonGroup, QCheckBox, QGridLayout from napari._qt.layer_controls.qt_layer_controls_base import QtLayerControls from napari._qt.utils import ( qt_signals_blocked, set_widgets_enabled_with_opacity, ) from napari._qt.widgets._slider_compat import QSlider from napari._qt.widgets.qt_color_swatch import QColorSwatchEdit from napari._qt.widgets.qt_mode_buttons import ( QtModePushButton, QtModeRadioButton, ) from napari.layers.shapes._shapes_constants import Mode from napari.utils.action_manager import action_manager from napari.utils.events import disconnect_events from napari.utils.interactions import Shortcut from napari.utils.translations import trans if TYPE_CHECKING: import napari.layers class QtShapesControls(QtLayerControls): """Qt view and controls for the napari Shapes layer. Parameters ---------- layer : napari.layers.Shapes An instance of a napari Shapes layer. Attributes ---------- button_group : qtpy.QtWidgets.QButtonGroup Button group for shapes layer modes (SELECT, DIRECT, PAN_ZOOM, ADD_RECTANGLE, ADD_ELLIPSE, ADD_LINE, ADD_PATH, ADD_POLYGON, VERTEX_INSERT, VERTEX_REMOVE). delete_button : qtpy.QtWidgets.QtModePushButton Button to delete selected shapes direct_button : qtpy.QtWidgets.QtModeRadioButton Button to select individual vertices in shapes. edgeColorEdit : QColorSwatchEdit Widget allowing user to set edge color of points. ellipse_button : qtpy.QtWidgets.QtModeRadioButton Button to add ellipses to shapes layer. faceColorEdit : QColorSwatchEdit Widget allowing user to set face color of points. layer : napari.layers.Shapes An instance of a napari Shapes layer. line_button : qtpy.QtWidgets.QtModeRadioButton Button to add lines to shapes layer. move_back_button : qtpy.QtWidgets.QtModePushButton Button to move selected shape(s) to the back. move_front_button : qtpy.QtWidgets.QtModePushButton Button to move shape(s) to the front. panzoom_button : qtpy.QtWidgets.QtModeRadioButton Button to pan/zoom shapes layer. path_button : qtpy.QtWidgets.QtModeRadioButton Button to add paths to shapes layer. polygon_button : qtpy.QtWidgets.QtModeRadioButton Button to add polygons to shapes layer. rectangle_button : qtpy.QtWidgets.QtModeRadioButton Button to add rectangles to shapes layer. select_button : qtpy.QtWidgets.QtModeRadioButton Button to select shapes. vertex_insert_button : qtpy.QtWidgets.QtModeRadioButton Button to insert vertex into shape. vertex_remove_button : qtpy.QtWidgets.QtModeRadioButton Button to remove vertex from shapes. widthSlider : qtpy.QtWidgets.QSlider Slider controlling line edge width of shapes. Raises ------ ValueError Raise error if shapes mode is not recognized. """ layer: 'napari.layers.Shapes' def __init__(self, layer) -> None: super().__init__(layer) self.layer.events.mode.connect(self._on_mode_change) self.layer.events.edge_width.connect(self._on_edge_width_change) self.layer.events.current_edge_color.connect( self._on_current_edge_color_change ) self.layer.events.current_face_color.connect( self._on_current_face_color_change ) self.layer.events.editable.connect(self._on_editable_or_visible_change) self.layer.events.visible.connect(self._on_editable_or_visible_change) self.layer.text.events.visible.connect(self._on_text_visibility_change) sld = QSlider(Qt.Orientation.Horizontal) sld.setFocusPolicy(Qt.FocusPolicy.NoFocus) sld.setMinimum(0) sld.setMaximum(40) sld.setSingleStep(1) value = self.layer.current_edge_width if isinstance(value, Iterable): if isinstance(value, list): value = np.asarray(value) value = value.mean() sld.setValue(int(value)) sld.valueChanged.connect(self.changeWidth) self.widthSlider = sld def _radio_button( parent, btn_name, mode, action_name, extra_tooltip_text='', **kwargs, ): """ Convenience local function to create a RadioButton and bind it to an action at the same time. Parameters ---------- parent : Any Parent of the generated QtModeRadioButton btn_name : str name fo the button mode : Enum Value Associated to current button action_name : str Action triggered when button pressed extra_tooltip_text : str Text you want added after the automatic tooltip set by the action manager **kwargs: Passed to QtModeRadioButton Returns ------- button: QtModeRadioButton button bound (or that will be bound to) to action `action_name` Notes ----- When shortcuts are modifed/added/removed via the action manager, the tooltip will be updated to reflect the new shortcut. """ action_name = f'napari:{action_name}' btn = QtModeRadioButton(parent, btn_name, mode, **kwargs) action_manager.bind_button( action_name, btn, extra_tooltip_text='', ) return btn self.select_button = _radio_button( layer, 'select', Mode.SELECT, "activate_select_mode" ) self.direct_button = _radio_button( layer, 'direct', Mode.DIRECT, "activate_direct_mode" ) self.panzoom_button = _radio_button( layer, 'zoom', Mode.PAN_ZOOM, "activate_shape_pan_zoom_mode", extra_tooltip_text=trans._('(or hold Space)'), checked=True, ) self.rectangle_button = _radio_button( layer, 'rectangle', Mode.ADD_RECTANGLE, "activate_add_rectangle_mode", ) self.ellipse_button = _radio_button( layer, 'ellipse', Mode.ADD_ELLIPSE, "activate_add_ellipse_mode", ) self.line_button = _radio_button( layer, 'line', Mode.ADD_LINE, "activate_add_line_mode" ) self.path_button = _radio_button( layer, 'path', Mode.ADD_PATH, "activate_add_path_mode" ) self.polygon_button = _radio_button( layer, 'polygon', Mode.ADD_POLYGON, "activate_add_polygon_mode", ) self.vertex_insert_button = _radio_button( layer, 'vertex_insert', Mode.VERTEX_INSERT, "activate_vertex_insert_mode", ) self.vertex_remove_button = _radio_button( layer, 'vertex_remove', Mode.VERTEX_REMOVE, "activate_vertex_remove_mode", ) self.move_front_button = QtModePushButton( layer, 'move_front', slot=self.layer.move_to_front, tooltip=trans._('Move to front'), ) action_manager.bind_button( 'napari:move_shapes_selection_to_front', self.move_front_button ) self.move_back_button = QtModePushButton( layer, 'move_back', slot=self.layer.move_to_back, tooltip=trans._('Move to back'), ) action_manager.bind_button( 'napari:move_shapes_selection_to_back', self.move_back_button ) self.delete_button = QtModePushButton( layer, 'delete_shape', slot=self.layer.remove_selected, tooltip=trans._( "Delete selected shapes ({shortcut})", shortcut=Shortcut('Backspace').platform, ), ) self._EDIT_BUTTONS = ( self.select_button, self.direct_button, self.rectangle_button, self.ellipse_button, self.line_button, self.path_button, self.polygon_button, self.vertex_remove_button, self.vertex_insert_button, self.delete_button, self.move_back_button, self.move_front_button, ) self.button_group = QButtonGroup(self) self.button_group.addButton(self.select_button) self.button_group.addButton(self.direct_button) self.button_group.addButton(self.panzoom_button) self.button_group.addButton(self.rectangle_button) self.button_group.addButton(self.ellipse_button) self.button_group.addButton(self.line_button) self.button_group.addButton(self.path_button) self.button_group.addButton(self.polygon_button) self.button_group.addButton(self.vertex_insert_button) self.button_group.addButton(self.vertex_remove_button) self._on_editable_or_visible_change() button_grid = QGridLayout() button_grid.addWidget(self.vertex_remove_button, 0, 2) button_grid.addWidget(self.vertex_insert_button, 0, 3) button_grid.addWidget(self.delete_button, 0, 4) button_grid.addWidget(self.direct_button, 0, 5) button_grid.addWidget(self.select_button, 0, 6) button_grid.addWidget(self.panzoom_button, 0, 7) button_grid.addWidget(self.move_back_button, 1, 1) button_grid.addWidget(self.move_front_button, 1, 2) button_grid.addWidget(self.ellipse_button, 1, 3) button_grid.addWidget(self.rectangle_button, 1, 4) button_grid.addWidget(self.polygon_button, 1, 5) button_grid.addWidget(self.line_button, 1, 6) button_grid.addWidget(self.path_button, 1, 7) button_grid.setContentsMargins(5, 0, 0, 5) button_grid.setColumnStretch(0, 1) button_grid.setSpacing(4) self.faceColorEdit = QColorSwatchEdit( initial_color=self.layer.current_face_color, tooltip=trans._('click to set current face color'), ) self._on_current_face_color_change() self.edgeColorEdit = QColorSwatchEdit( initial_color=self.layer.current_edge_color, tooltip=trans._('click to set current edge color'), ) self._on_current_edge_color_change() self.faceColorEdit.color_changed.connect(self.changeFaceColor) self.edgeColorEdit.color_changed.connect(self.changeEdgeColor) text_disp_cb = QCheckBox() text_disp_cb.setToolTip(trans._('toggle text visibility')) text_disp_cb.setChecked(self.layer.text.visible) text_disp_cb.stateChanged.connect(self.change_text_visibility) self.textDispCheckBox = text_disp_cb self.layout().addRow(button_grid) self.layout().addRow(self.opacityLabel, self.opacitySlider) self.layout().addRow(trans._('edge width:'), self.widthSlider) self.layout().addRow(trans._('blending:'), self.blendComboBox) self.layout().addRow(trans._('face color:'), self.faceColorEdit) self.layout().addRow(trans._('edge color:'), self.edgeColorEdit) self.layout().addRow(trans._('display text:'), self.textDispCheckBox) def _on_mode_change(self, event): """Update ticks in checkbox widgets when shapes layer mode changed. Available modes for shapes layer are: * SELECT * DIRECT * PAN_ZOOM * ADD_RECTANGLE * ADD_ELLIPSE * ADD_LINE * ADD_PATH * ADD_POLYGON * VERTEX_INSERT * VERTEX_REMOVE Parameters ---------- event : napari.utils.event.Event The napari event that triggered this method. Raises ------ ValueError Raise error if event.mode is not ADD, PAN_ZOOM, or SELECT. """ mode_buttons = { Mode.SELECT: self.select_button, Mode.DIRECT: self.direct_button, Mode.PAN_ZOOM: self.panzoom_button, Mode.ADD_RECTANGLE: self.rectangle_button, Mode.ADD_ELLIPSE: self.ellipse_button, Mode.ADD_LINE: self.line_button, Mode.ADD_PATH: self.path_button, Mode.ADD_POLYGON: self.polygon_button, Mode.VERTEX_INSERT: self.vertex_insert_button, Mode.VERTEX_REMOVE: self.vertex_remove_button, } if event.mode in mode_buttons: mode_buttons[event.mode].setChecked(True) elif event.mode != Mode.TRANSFORM: raise ValueError( trans._("Mode '{mode}'not recognized", mode=event.mode) ) def changeFaceColor(self, color: np.ndarray): """Change face color of shapes. Parameters ---------- color : np.ndarray Face color for shapes, color name or hex string. Eg: 'white', 'red', 'blue', '#00ff00', etc. """ with self.layer.events.current_face_color.blocker(): self.layer.current_face_color = color def changeEdgeColor(self, color: np.ndarray): """Change edge color of shapes. Parameters ---------- color : np.ndarray Edge color for shapes, color name or hex string. Eg: 'white', 'red', 'blue', '#00ff00', etc. """ with self.layer.events.current_edge_color.blocker(): self.layer.current_edge_color = color def changeWidth(self, value): """Change edge line width of shapes on the layer model. Parameters ---------- value : float Line width of shapes. """ self.layer.current_edge_width = float(value) def change_text_visibility(self, state): """Toggle the visibility of the text. Parameters ---------- state : QCheckBox Checkbox indicating if text is visible. """ self.layer.text.visible = state == Qt.CheckState.Checked def _on_text_visibility_change(self): """Receive layer model text visibiltiy change change event and update checkbox.""" with self.layer.text.events.visible.blocker(): self.textDispCheckBox.setChecked(self.layer.text.visible) def _on_edge_width_change(self): """Receive layer model edge line width change event and update slider.""" with self.layer.events.edge_width.blocker(): value = self.layer.current_edge_width value = np.clip(int(value), 0, 40) self.widthSlider.setValue(value) def _on_current_edge_color_change(self): """Receive layer model edge color change event and update color swatch.""" with qt_signals_blocked(self.edgeColorEdit): self.edgeColorEdit.setColor(self.layer.current_edge_color) def _on_current_face_color_change(self): """Receive layer model face color change event and update color swatch.""" with qt_signals_blocked(self.faceColorEdit): self.faceColorEdit.setColor(self.layer.current_face_color) def _on_editable_or_visible_change(self): """Receive layer model editable/visible change event & enable/disable buttons.""" set_widgets_enabled_with_opacity( self, self._EDIT_BUTTONS, self.layer.editable and self.layer.visible, ) def close(self): """Disconnect events when widget is closing.""" disconnect_events(self.layer.text.events, self) super().close() napari-0.5.0a1/napari/_qt/layer_controls/qt_surface_controls.py000066400000000000000000000042671437041365600247570ustar00rootroot00000000000000from typing import TYPE_CHECKING from qtpy.QtWidgets import QComboBox, QHBoxLayout from napari._qt.layer_controls.qt_image_controls_base import ( QtBaseImageControls, ) from napari.layers.surface._surface_constants import SHADING_TRANSLATION from napari.utils.translations import trans if TYPE_CHECKING: import napari.layers class QtSurfaceControls(QtBaseImageControls): """Qt view and controls for the napari Surface layer. Parameters ---------- layer : napari.layers.Surface An instance of a napari Surface layer. Attributes ---------- layer : napari.layers.Surface An instance of a napari Surface layer. """ layer: 'napari.layers.Surface' def __init__(self, layer) -> None: super().__init__(layer) colormap_layout = QHBoxLayout() colormap_layout.addWidget(self.colorbarLabel) colormap_layout.addWidget(self.colormapComboBox) colormap_layout.addStretch(1) shading_comboBox = QComboBox(self) for display_name, shading in SHADING_TRANSLATION.items(): shading_comboBox.addItem(display_name, shading) index = shading_comboBox.findData( SHADING_TRANSLATION[self.layer.shading] ) shading_comboBox.setCurrentIndex(index) shading_comboBox.currentTextChanged.connect(self.changeShading) self.shadingComboBox = shading_comboBox self.layout().addRow(self.opacityLabel, self.opacitySlider) self.layout().addRow( trans._('contrast limits:'), self.contrastLimitsSlider ) self.layout().addRow(trans._('auto-contrast:'), self.autoScaleBar) self.layout().addRow(trans._('gamma:'), self.gammaSlider) self.layout().addRow(trans._('colormap:'), colormap_layout) self.layout().addRow(trans._('blending:'), self.blendComboBox) self.layout().addRow(trans._('shading:'), self.shadingComboBox) def changeShading(self, text): """Change shading value on the surface layer. Parameters ---------- text : str Name of shading mode, eg: 'flat', 'smooth', 'none'. """ self.layer.shading = self.shadingComboBox.currentData() napari-0.5.0a1/napari/_qt/layer_controls/qt_tracks_controls.py000066400000000000000000000167571437041365600246250ustar00rootroot00000000000000from typing import TYPE_CHECKING from qtpy.QtCore import Qt from qtpy.QtWidgets import QCheckBox, QComboBox, QSlider from napari._qt.layer_controls.qt_layer_controls_base import QtLayerControls from napari._qt.utils import qt_signals_blocked from napari.utils.colormaps import AVAILABLE_COLORMAPS from napari.utils.translations import trans if TYPE_CHECKING: import napari.layers class QtTracksControls(QtLayerControls): """Qt view and controls for the Tracks layer. Parameters ---------- layer : napari.layers.Tracks An instance of a Tracks layer. Attributes ---------- layer : layers.Tracks An instance of a Tracks layer. """ layer: 'napari.layers.Tracks' def __init__(self, layer) -> None: super().__init__(layer) # NOTE(arl): there are no events fired for changing checkboxes self.layer.events.tail_width.connect(self._on_tail_width_change) self.layer.events.tail_length.connect(self._on_tail_length_change) self.layer.events.head_length.connect(self._on_head_length_change) self.layer.events.properties.connect(self._on_properties_change) self.layer.events.colormap.connect(self._on_colormap_change) self.layer.events.color_by.connect(self._on_color_by_change) # combo box for track coloring, we can get these from the properties # keys self.color_by_combobox = QComboBox() self.color_by_combobox.addItems(self.layer.properties_to_color_by) self.colormap_combobox = QComboBox() for name, colormap in AVAILABLE_COLORMAPS.items(): display_name = colormap._display_name self.colormap_combobox.addItem(display_name, name) # slider for track head length self.head_length_slider = QSlider(Qt.Orientation.Horizontal) self.head_length_slider.setFocusPolicy(Qt.FocusPolicy.NoFocus) self.head_length_slider.setMinimum(0) self.head_length_slider.setMaximum(self.layer._max_length) self.head_length_slider.setSingleStep(1) # slider for track tail length self.tail_length_slider = QSlider(Qt.Orientation.Horizontal) self.tail_length_slider.setFocusPolicy(Qt.FocusPolicy.NoFocus) self.tail_length_slider.setMinimum(1) self.tail_length_slider.setMaximum(self.layer._max_length) self.tail_length_slider.setSingleStep(1) # slider for track edge width self.tail_width_slider = QSlider(Qt.Orientation.Horizontal) self.tail_width_slider.setFocusPolicy(Qt.FocusPolicy.NoFocus) self.tail_width_slider.setMinimum(1) self.tail_width_slider.setMaximum(int(2 * self.layer._max_width)) self.tail_width_slider.setSingleStep(1) # checkboxes for display self.id_checkbox = QCheckBox() self.tail_checkbox = QCheckBox() self.tail_checkbox.setChecked(True) self.graph_checkbox = QCheckBox() self.graph_checkbox.setChecked(True) self.tail_width_slider.valueChanged.connect(self.change_tail_width) self.tail_length_slider.valueChanged.connect(self.change_tail_length) self.head_length_slider.valueChanged.connect(self.change_head_length) self.tail_checkbox.stateChanged.connect(self.change_display_tail) self.id_checkbox.stateChanged.connect(self.change_display_id) self.graph_checkbox.stateChanged.connect(self.change_display_graph) self.color_by_combobox.currentTextChanged.connect(self.change_color_by) self.colormap_combobox.currentTextChanged.connect(self.change_colormap) self.layout().addRow(trans._('color by:'), self.color_by_combobox) self.layout().addRow(trans._('colormap:'), self.colormap_combobox) self.layout().addRow(trans._('blending:'), self.blendComboBox) self.layout().addRow(self.opacityLabel, self.opacitySlider) self.layout().addRow(trans._('tail width:'), self.tail_width_slider) self.layout().addRow(trans._('tail length:'), self.tail_length_slider) self.layout().addRow(trans._('head length:'), self.head_length_slider) self.layout().addRow(trans._('tail:'), self.tail_checkbox) self.layout().addRow(trans._('show ID:'), self.id_checkbox) self.layout().addRow(trans._('graph:'), self.graph_checkbox) self._on_tail_length_change() self._on_tail_width_change() self._on_colormap_change() self._on_color_by_change() def _on_tail_width_change(self): """Receive layer model track line width change event and update slider.""" with self.layer.events.tail_width.blocker(): value = int(2 * self.layer.tail_width) self.tail_width_slider.setValue(value) def _on_tail_length_change(self): """Receive layer model track line width change event and update slider.""" with self.layer.events.tail_length.blocker(): value = self.layer.tail_length self.tail_length_slider.setValue(value) def _on_head_length_change(self): """Receive layer model track line width change event and update slider.""" with self.layer.events.head_length.blocker(): value = self.layer.head_length self.head_length_slider.setValue(value) def _on_properties_change(self): """Change the properties that can be used to color the tracks.""" with self.layer.events.properties.blocker(): with qt_signals_blocked(self.color_by_combobox): self.color_by_combobox.clear() self.color_by_combobox.addItems(self.layer.properties_to_color_by) def _on_colormap_change(self): """Receive layer model colormap change event and update combobox.""" with self.layer.events.colormap.blocker(): self.colormap_combobox.setCurrentIndex( self.colormap_combobox.findData(self.layer.colormap) ) def _on_color_by_change(self): """Receive layer model color_by change event and update combobox.""" with self.layer.events.color_by.blocker(): color_by = self.layer.color_by idx = self.color_by_combobox.findText( color_by, Qt.MatchFlag.MatchFixedString ) self.color_by_combobox.setCurrentIndex(idx) def change_tail_width(self, value): """Change track line width of shapes on the layer model. Parameters ---------- value : float Line width of track tails. """ self.layer.tail_width = float(value) / 2.0 def change_tail_length(self, value): """Change edge line backward length of shapes on the layer model. Parameters ---------- value : int Line length of track tails. """ self.layer.tail_length = value def change_head_length(self, value): """Change edge line forward length of shapes on the layer model. Parameters ---------- value : int Line length of track tails. """ self.layer.head_length = value def change_display_tail(self, state): self.layer.display_tail = self.tail_checkbox.isChecked() def change_display_id(self, state): self.layer.display_id = self.id_checkbox.isChecked() def change_display_graph(self, state): self.layer.display_graph = self.graph_checkbox.isChecked() def change_color_by(self, value: str): self.layer.color_by = value def change_colormap(self, colormap: str): self.layer.colormap = self.colormap_combobox.currentData() napari-0.5.0a1/napari/_qt/layer_controls/qt_vectors_controls.py000066400000000000000000000254341437041365600250130ustar00rootroot00000000000000from typing import TYPE_CHECKING import numpy as np from qtpy.QtCore import Qt from qtpy.QtWidgets import QCheckBox, QComboBox, QDoubleSpinBox, QLabel from napari._qt.layer_controls.qt_layer_controls_base import QtLayerControls from napari._qt.utils import qt_signals_blocked from napari._qt.widgets.qt_color_swatch import QColorSwatchEdit from napari.layers.utils._color_manager_constants import ColorMode from napari.utils.translations import trans if TYPE_CHECKING: import napari.layers class QtVectorsControls(QtLayerControls): """Qt view and controls for the napari Vectors layer. Parameters ---------- layer : napari.layers.Vectors An instance of a napari Vectors layer. Attributes ---------- edge_color_label : qtpy.QtWidgets.QLabel Label for edgeColorSwatch edgeColorEdit : QColorSwatchEdit Widget to select display color for vectors. color_mode_comboBox : qtpy.QtWidgets.QComboBox Dropdown widget to select edge_color_mode for the vectors. color_prop_box : qtpy.QtWidgets.QComboBox Dropdown widget to select _edge_color_property for the vectors. edge_prop_label : qtpy.QtWidgets.QLabel Label for color_prop_box layer : napari.layers.Vectors An instance of a napari Vectors layer. outOfSliceCheckBox : qtpy.QtWidgets.QCheckBox Checkbox to indicate whether to render out of slice. lengthSpinBox : qtpy.QtWidgets.QDoubleSpinBox Spin box widget controlling line length of vectors. Multiplicative factor on projections for length of all vectors. widthSpinBox : qtpy.QtWidgets.QDoubleSpinBox Spin box widget controlling edge line width of vectors. """ layer: 'napari.layers.Vectors' def __init__(self, layer) -> None: super().__init__(layer) # dropdown to select the property for mapping edge_color color_properties = self._get_property_values() self.color_prop_box = QComboBox(self) self.color_prop_box.currentTextChanged.connect( self.change_edge_color_property ) self.color_prop_box.addItems(color_properties) self.edge_prop_label = QLabel(trans._('edge property:')) # vector direct color mode adjustment and widget self.edgeColorEdit = QColorSwatchEdit( initial_color=self.layer.edge_color, tooltip=trans._( 'click to set current edge color', ), ) self.edgeColorEdit.color_changed.connect(self.change_edge_color_direct) self.edge_color_label = QLabel(trans._('edge color:')) self._on_edge_color_change() # dropdown to select the edge color mode self.color_mode_comboBox = QComboBox(self) color_modes = [e.value for e in ColorMode] self.color_mode_comboBox.addItems(color_modes) self.color_mode_comboBox.currentTextChanged.connect( self.change_edge_color_mode ) self._on_edge_color_mode_change() # line width in pixels self.widthSpinBox = QDoubleSpinBox() self.widthSpinBox.setKeyboardTracking(False) self.widthSpinBox.setSingleStep(0.1) self.widthSpinBox.setMinimum(0.1) self.widthSpinBox.setMaximum(np.inf) self.widthSpinBox.setValue(self.layer.edge_width) self.widthSpinBox.valueChanged.connect(self.change_width) # line length self.lengthSpinBox = QDoubleSpinBox() self.lengthSpinBox.setKeyboardTracking(False) self.lengthSpinBox.setSingleStep(0.1) self.lengthSpinBox.setValue(self.layer.length) self.lengthSpinBox.setMinimum(0.1) self.lengthSpinBox.setMaximum(np.inf) self.lengthSpinBox.valueChanged.connect(self.change_length) out_of_slice_cb = QCheckBox() out_of_slice_cb.setToolTip(trans._('Out of slice display')) out_of_slice_cb.setChecked(self.layer.out_of_slice_display) out_of_slice_cb.stateChanged.connect(self.change_out_of_slice) self.outOfSliceCheckBox = out_of_slice_cb self.layout().addRow(self.opacityLabel, self.opacitySlider) self.layout().addRow(trans._('width:'), self.widthSpinBox) self.layout().addRow(trans._('length:'), self.lengthSpinBox) self.layout().addRow(trans._('blending:'), self.blendComboBox) self.layout().addRow( trans._('edge color mode:'), self.color_mode_comboBox ) self.layout().addRow(self.edge_color_label, self.edgeColorEdit) self.layout().addRow(self.edge_prop_label, self.color_prop_box) self.layout().addRow(trans._('out of slice:'), self.outOfSliceCheckBox) self.layer.events.edge_width.connect(self._on_edge_width_change) self.layer.events.length.connect(self._on_length_change) self.layer.events.out_of_slice_display.connect( self._on_out_of_slice_display_change ) self.layer.events.edge_color_mode.connect( self._on_edge_color_mode_change ) self.layer.events.edge_color.connect(self._on_edge_color_change) def change_edge_color_property(self, property: str): """Change edge_color_property of vectors on the layer model. This property is the property the edge color is mapped to. Parameters ---------- property : str property to map the edge color to """ mode = self.layer.edge_color_mode try: self.layer.edge_color = property self.layer.edge_color_mode = mode except TypeError: # if the selected property is the wrong type for the current color mode # the color mode will be changed to the appropriate type, so we must update self._on_edge_color_mode_change() raise def change_edge_color_mode(self, mode: str): """Change edge color mode of vectors on the layer model. Parameters ---------- mode : str Edge color for vectors. Must be: 'direct', 'cycle', or 'colormap' """ old_mode = self.layer.edge_color_mode with self.layer.events.edge_color_mode.blocker(): try: self.layer.edge_color_mode = mode self._update_edge_color_gui(mode) except ValueError: # if the color mode was invalid, revert to the old mode self.layer.edge_color_mode = old_mode raise def change_edge_color_direct(self, color: np.ndarray): """Change edge color of vectors on the layer model. Parameters ---------- color : np.ndarray Edge color for vectors, in an RGBA array """ self.layer.edge_color = color def change_width(self, value): """Change edge line width of vectors on the layer model. Parameters ---------- value : float Line width of vectors. """ self.layer.edge_width = value self.widthSpinBox.clearFocus() self.setFocus() def change_length(self, value): """Change length of vectors on the layer model. Multiplicative factor on projections for length of all vectors. Parameters ---------- value : float Length of vectors. """ self.layer.length = value self.lengthSpinBox.clearFocus() self.setFocus() def change_out_of_slice(self, state): """Toggle out of slice display of vectors layer. Parameters ---------- state : QCheckBox Checkbox to indicate whether to render out of slice. """ self.layer.out_of_slice_display = state == Qt.CheckState.Checked def _update_edge_color_gui(self, mode: str): """Update the GUI element associated with edge_color. This is typically used when edge_color_mode changes Parameters ---------- mode : str The new edge_color mode the GUI needs to be updated for. Should be: 'direct', 'cycle', 'colormap' """ if mode in {'cycle', 'colormap'}: self.edgeColorEdit.setHidden(True) self.edge_color_label.setHidden(True) self.color_prop_box.setHidden(False) self.edge_prop_label.setHidden(False) elif mode == 'direct': self.edgeColorEdit.setHidden(False) self.edge_color_label.setHidden(False) self.color_prop_box.setHidden(True) self.edge_prop_label.setHidden(True) def _get_property_values(self): """Get the current property values from the Vectors layer Returns ------- property_values : np.ndarray array of all of the union of the property names (keys) in Vectors.properties and Vectors.property_choices """ property_choices = [*self.layer.property_choices] properties = [*self.layer.properties] property_values = np.union1d(property_choices, properties) return property_values def _on_length_change(self): """Change length of vectors.""" with self.layer.events.length.blocker(): self.lengthSpinBox.setValue(self.layer.length) def _on_out_of_slice_display_change(self, event): """Receive layer model out_of_slice_display change event and update checkbox.""" with self.layer.events.out_of_slice_display.blocker(): self.outOfSliceCheckBox.setChecked(self.layer.out_of_slice_display) def _on_edge_width_change(self): """Receive layer model width change event and update width spinbox.""" with self.layer.events.edge_width.blocker(): self.widthSpinBox.setValue(self.layer.edge_width) def _on_edge_color_mode_change(self): """Receive layer model edge color mode change event & update dropdown.""" with qt_signals_blocked(self.color_mode_comboBox): mode = self.layer._edge.color_mode index = self.color_mode_comboBox.findText( mode, Qt.MatchFixedString ) self.color_mode_comboBox.setCurrentIndex(index) self._update_edge_color_gui(mode) def _on_edge_color_change(self): """Receive layer model edge color change event & update dropdown.""" if ( self.layer._edge.color_mode == ColorMode.DIRECT and len(self.layer.data) > 0 ): with qt_signals_blocked(self.edgeColorEdit): self.edgeColorEdit.setColor(self.layer.edge_color[0]) elif self.layer._edge.color_mode in ( ColorMode.CYCLE, ColorMode.COLORMAP, ): with qt_signals_blocked(self.color_prop_box): prop = self.layer._edge.color_properties.name index = self.color_prop_box.findText(prop, Qt.MatchFixedString) self.color_prop_box.setCurrentIndex(index) napari-0.5.0a1/napari/_qt/menus/000077500000000000000000000000001437041365600164055ustar00rootroot00000000000000napari-0.5.0a1/napari/_qt/menus/__init__.py000066400000000000000000000004411437041365600205150ustar00rootroot00000000000000from napari._qt.menus.debug_menu import DebugMenu from napari._qt.menus.file_menu import FileMenu from napari._qt.menus.plugins_menu import PluginsMenu from napari._qt.menus.window_menu import WindowMenu __all__ = [ 'DebugMenu', 'FileMenu', 'PluginsMenu', 'WindowMenu', ] napari-0.5.0a1/napari/_qt/menus/_tests/000077500000000000000000000000001437041365600177065ustar00rootroot00000000000000napari-0.5.0a1/napari/_qt/menus/_tests/__init__.py000066400000000000000000000000001437041365600220050ustar00rootroot00000000000000napari-0.5.0a1/napari/_qt/menus/_tests/test_file_menu.py000066400000000000000000000107711437041365600232700ustar00rootroot00000000000000from unittest import mock import pytest import qtpy from npe2 import DynamicPlugin from npe2.manifest.contributions import SampleDataURI from qtpy.QtWidgets import QMenu from napari.utils.action_manager import action_manager def test_sample_data_triggers_reader_dialog( make_napari_viewer, tmp_plugin: DynamicPlugin ): """Sample data pops reader dialog if multiple compatible readers""" # make two tmp readers that take tif files tmp2 = tmp_plugin.spawn(register=True) @tmp_plugin.contribute.reader(filename_patterns=['*.tif']) def _(path): ... @tmp2.contribute.reader(filename_patterns=['*.tif']) def _(path): ... # make a sample data reader for tif file my_sample = SampleDataURI( key='tmp-sample', display_name='Temp Sample', uri='some-path/some-file.tif', ) tmp_plugin.manifest.contributions.sample_data = [my_sample] viewer = make_napari_viewer() sample_action = viewer.window.file_menu.open_sample_menu.actions()[0] with mock.patch( 'napari._qt.menus.file_menu.handle_gui_reading' ) as mock_read: sample_action.trigger() # assert that handle gui reading was called mock_read.assert_called_once() def test_plugin_display_name_use_for_multiple_samples( make_napari_viewer, builtins ): """For plugin with more than two sample datasets, should use plugin_display for building the menu""" viewer = make_napari_viewer() # builtins provides more than one sample, so the submenu should use the `display_name` from manifest plugin_action_menu = viewer.window.file_menu.open_sample_menu.actions()[ 0 ].menu() assert plugin_action_menu.title() == 'napari builtins' # Now ensure that the actions are still correct # trigger the action, opening the first sample: `Astronaut` assert len(viewer.layers) == 0 plugin_action_menu.actions()[0].trigger() assert len(viewer.layers) == 1 assert viewer.layers[0].name == 'astronaut' def test_show_shortcuts_actions(make_napari_viewer): viewer = make_napari_viewer() assert viewer.window.file_menu._pref_dialog is None action_manager.trigger("napari:show_shortcuts") assert viewer.window.file_menu._pref_dialog is not None assert ( viewer.window.file_menu._pref_dialog._list.currentItem().text() == "Shortcuts" ) viewer.window.file_menu._pref_dialog.close() def get_open_with_plugin_action(viewer, action_text): def _get_menu(act): # this function may be removed when PyQt6 will release next version # (after 6.3.1 - if we do not want to support this test on older PyQt6) # https://www.riverbankcomputing.com/pipermail/pyqt/2022-July/044817.html # because both PyQt6 and PySide6 will have working manu method of action return ( QMenu.menuInAction(act) if getattr(qtpy, 'PYQT6', False) else act.menu() ) actions = viewer.window.file_menu.actions() for action1 in actions: if action1.text() == 'Open with Plugin': for action2 in _get_menu(action1).actions(): if action2.text() == action_text: return action2, action1 raise ValueError( f'Could not find action "{action_text}"' ) # pragma: no cover @pytest.mark.parametrize( "menu_str,dialog_method,dialog_return,filename_call,stack", [ ( 'Open File(s)...', 'getOpenFileNames', (['my-file.tif'], ''), ['my-file.tif'], False, ), ( 'Open Files as Stack...', 'getOpenFileNames', (['my-file.tif'], ''), ['my-file.tif'], True, ), ( 'Open Folder...', 'getExistingDirectory', 'my-dir/', ['my-dir/'], False, ), ], ) def test_open_with_plugin( make_napari_viewer, menu_str, dialog_method, dialog_return, filename_call, stack, ): viewer = make_napari_viewer() action, _a = get_open_with_plugin_action(viewer, menu_str) with mock.patch( 'napari._qt.qt_viewer.QFileDialog' ) as mock_file, mock.patch( 'napari._qt.qt_viewer.QtViewer._qt_open' ) as mock_read: mock_file_instance = mock_file.return_value getattr(mock_file_instance, dialog_method).return_value = dialog_return action.trigger() mock_read.assert_called_once_with( filename_call, stack=stack, choose_plugin=True ) napari-0.5.0a1/napari/_qt/menus/_tests/test_plugins_menu.py000066400000000000000000000022011437041365600240170ustar00rootroot00000000000000from npe2 import DynamicPlugin from qtpy.QtWidgets import QWidget class DummyWidget(QWidget): pass def test_plugin_display_name_use_for_multiple_widgets( make_napari_viewer, tmp_plugin: DynamicPlugin ): """For plugin with more than two widgets, should use plugin_display for building the menu""" @tmp_plugin.contribute.widget(display_name='Widget 1') def widget1(): return DummyWidget() @tmp_plugin.contribute.widget(display_name='Widget 2') def widget2(): return DummyWidget() assert tmp_plugin.display_name == 'Temp Plugin' viewer = make_napari_viewer() # the submenu should use the `display_name` from manifest plugin_action_menu = viewer.window.plugins_menu.actions()[3].menu() assert plugin_action_menu.title() == tmp_plugin.display_name # Now ensure that the actions are still correct # trigger the action, opening the first widget: `Widget 1` assert len(viewer.window._dock_widgets) == 0 plugin_action_menu.actions()[0].trigger() assert len(viewer.window._dock_widgets) == 1 assert list(viewer.window._dock_widgets.data)[0] == 'Widget 1 (tmp_plugin)' napari-0.5.0a1/napari/_qt/menus/_tests/test_util.py000066400000000000000000000022761437041365600223030ustar00rootroot00000000000000from unittest.mock import MagicMock from qtpy.QtWidgets import QMenu from napari._qt.menus._util import populate_menu def test_populate_menu_create(qtbot): """Test the populate_menu function.""" mock = MagicMock() menu = QMenu() populate_menu(menu, [{"text": "test", "slot": mock}]) assert len(menu.actions()) == 1 assert menu.actions()[0].text() == "test" assert menu.actions()[0].isCheckable() is False with qtbot.waitSignal(menu.actions()[0].triggered): menu.actions()[0].trigger() mock.assert_called_once() def test_populate_menu_create_checkable(qtbot): """Test the populate_menu function with checkable actions.""" mock = MagicMock() menu = QMenu() populate_menu(menu, [{"text": "test", "slot": mock, "checkable": True}]) assert len(menu.actions()) == 1 assert menu.actions()[0].text() == "test" assert menu.actions()[0].isCheckable() is True with qtbot.waitSignal(menu.actions()[0].triggered): menu.actions()[0].trigger() mock.assert_called_once_with(True) mock.reset_mock() with qtbot.waitSignal(menu.actions()[0].triggered): menu.actions()[0].trigger() mock.assert_called_once_with(False) napari-0.5.0a1/napari/_qt/menus/_util.py000066400000000000000000000105251437041365600200760ustar00rootroot00000000000000import contextlib from typing import TYPE_CHECKING, Callable, List, Union from qtpy.QtWidgets import QAction, QMenu if TYPE_CHECKING: from typing_extensions import TypedDict from napari.utils.events import EventEmitter try: from qtpy.QtCore import SignalInstance except ImportError: from qtpy.QtCore import pyqtBoundSignal as SignalInstance class ActionDict(TypedDict): text: str # these are optional slot: Callable shortcut: str statusTip: str menuRole: QAction.MenuRole checkable: bool checked: bool check_on: Union[EventEmitter, SignalInstance] class MenuDict(TypedDict): menu: str # these are optional items: List[ActionDict] # note: TypedDict still doesn't have the concept of "optional keys" # so we add in generic `dict` for type checking. # see PEP655: https://peps.python.org/pep-0655/ MenuItem = Union[MenuDict, ActionDict, dict] def populate_menu(menu: QMenu, actions: List['MenuItem']): """Populate a QMenu from a declarative list of QAction dicts. Parameters ---------- menu : QMenu the menu to populate actions : list of dict A list of dicts with one or more of the following keys **Required: One of "text" or "menu" MUST be present in the dict** text: str the name of the QAction to add menu: str if present, creates a submenu instead. "menu" keys may also provide an "items" key to populate the menu. **Optional:** slot: callable a callback to call when the action is triggered shortcut: str a keyboard shortcut to trigger the actoin statusTip: str used for setStatusTip menuRole: QAction.MenuRole used for setMenuRole checkable: bool used for setCheckable checked: bool used for setChecked (only if `checkable` is provided and True) check_on: EventEmitter If provided, and `checkable` is True, this EventEmitter will be connected to action.setChecked: `dct['check_on'].connect(lambda e: action.setChecked(e.value))` """ for ax in actions: if not ax: menu.addSeparator() continue if not ax.get("when", True): continue if 'menu' in ax: sub = ax['menu'] if isinstance(sub, QMenu): menu.addMenu(sub) sub.setParent(menu) else: sub = menu.addMenu(sub) populate_menu(sub, ax.get("items", [])) continue action: QAction = menu.addAction(ax['text']) if 'slot' in ax: if ax.get("checkable"): action.toggled.connect(ax['slot']) else: action.triggered.connect(ax['slot']) action.setShortcut(ax.get('shortcut', '')) action.setStatusTip(ax.get('statusTip', '')) if 'menuRole' in ax: action.setMenuRole(ax['menuRole']) if ax.get("checkable"): action.setCheckable(True) action.setChecked(ax.get("checked", False)) if 'check_on' in ax: emitter = ax['check_on'] @emitter.connect def _setchecked(e, action=action): action.setChecked(e.value if hasattr(e, 'value') else e) action.setData(ax) class NapariMenu(QMenu): """ Base napari menu class that provides action handling and clean up on close. """ _INSTANCES: List['NapariMenu'] = [] def __init__(self, *args, **kwargs) -> None: super().__init__(*args, **kwargs) self._INSTANCES.append(self) def _destroy(self): """Clean up action data to avoid widget leaks.""" for ax in self.actions(): ax.setData(None) with contextlib.suppress(AttributeError): ax._destroy() if self in self._INSTANCES: self._INSTANCES.remove(self) def update(self, event=None): """Update action enabled/disabled state based on action data.""" for ax in self.actions(): if data := ax.data(): enabled_func = data.get('enabled', lambda event: True) ax.setEnabled(bool(enabled_func(event))) napari-0.5.0a1/napari/_qt/menus/debug_menu.py000066400000000000000000000067471437041365600211070ustar00rootroot00000000000000"""Debug menu. The debug menu is for developer-focused functionality that we want to be easy-to-use and discoverable, but which is not for the average user. """ from typing import TYPE_CHECKING from qtpy.QtCore import QTimer from qtpy.QtWidgets import QFileDialog from napari._qt.menus._util import NapariMenu, populate_menu from napari.utils import perf from napari.utils.history import get_save_history, update_save_history from napari.utils.translations import trans if TYPE_CHECKING: from napari._qt.qt_main_window import Window class DebugMenu(NapariMenu): def __init__(self, window: 'Window') -> None: self._win = window super().__init__(trans._('&Debug'), window._qt_window) self._perf_menu = NapariMenu(trans._("Performance Trace"), self) ACTIONS = [ { 'menu': self._perf_menu, 'items': [ { 'text': trans._('Start Recording...'), 'slot': self._start_trace_dialog, 'shortcut': 'Alt+T', 'statusTip': trans._('Start recording a trace file'), }, { 'text': trans._('Stop Recording...'), 'slot': self._stop_trace, 'shortcut': 'Shift+Alt+T', 'statusTip': trans._('Stop recording a trace file'), }, ], } ] populate_menu(self, ACTIONS) self._set_recording(False) if perf.perf_config: path = perf.perf_config.trace_file_on_start if path is not None: # Config option "trace_file_on_start" means immediately # start tracing to that file. This is very useful if you # want to create a trace every time you start napari, # without having to start it from the debug menu. self._start_trace(path) def _start_trace_dialog(self): """Open Save As dialog to start recording a trace file.""" viewer = self._win._qt_viewer dlg = QFileDialog() hist = get_save_history() dlg.setHistory(hist) filename, _ = dlg.getSaveFileName( parent=viewer, caption=trans._('Record performance trace file'), directory=hist[0], filter=trans._("Trace Files (*.json)"), ) if filename: if not filename.endswith(".json"): filename += ".json" # Schedule this to avoid bogus "MetaCall" event for the entire # time the file dialog was up. QTimer.singleShot(0, lambda: self._start_trace(filename)) update_save_history(filename) def _start_trace(self, path: str): perf.timers.start_trace_file(path) self._set_recording(True) def _stop_trace(self): """Stop recording a trace file.""" perf.timers.stop_trace_file() self._set_recording(False) def _set_recording(self, recording: bool): """Toggle which are enabled/disabled. Parameters ---------- recording : bool Are we currently recording a trace file. """ for action in self._perf_menu.actions(): if trans._('Start Recording') in action.text(): action.setEnabled(not recording) elif trans._('Stop Recording') in action.text(): action.setEnabled(recording) napari-0.5.0a1/napari/_qt/menus/file_menu.py000066400000000000000000000231521437041365600207250ustar00rootroot00000000000000from itertools import chain from typing import TYPE_CHECKING from qtpy.QtCore import QSize from qtpy.QtWidgets import QAction, QMenu from napari._qt.dialogs.preferences_dialog import PreferencesDialog from napari._qt.dialogs.qt_reader_dialog import handle_gui_reading from napari._qt.dialogs.screenshot_dialog import ScreenshotDialog from napari._qt.menus._util import NapariMenu, populate_menu from napari.components._viewer_key_bindings import register_viewer_action from napari.errors.reader_errors import MultipleReaderError from napari.settings import get_settings from napari.utils.history import get_save_history, update_save_history from napari.utils.misc import running_as_bundled_app from napari.utils.translations import trans if TYPE_CHECKING: from napari import Viewer from napari._qt.qt_main_window import Window class FileMenu(NapariMenu): def __init__(self, window: 'Window') -> None: self._win = window super().__init__(trans._('&File'), window._qt_window) self.open_sample_menu = NapariMenu(trans._('Open Sample'), self) ACTIONS = [ { 'text': trans._('Open File(s)...'), 'slot': window._qt_viewer._open_files_dialog, 'shortcut': 'Ctrl+O', }, { 'text': trans._('Open Files as Stack...'), 'slot': window._qt_viewer._open_files_dialog_as_stack_dialog, 'shortcut': 'Ctrl+Alt+O', }, { 'text': trans._('Open Folder...'), 'slot': window._qt_viewer._open_folder_dialog, 'shortcut': 'Ctrl+Shift+O', }, { 'menu': trans._('Open with Plugin'), 'items': [ { 'text': 'Open File(s)...', 'slot': self._open_files_w_plugin, }, { 'text': 'Open Files as Stack...', 'slot': self._open_files_as_stack_w_plugin, }, { 'text': 'Open Folder...', 'slot': self._open_folder_w_plugin, }, ], }, {'menu': self.open_sample_menu}, {}, { 'text': trans._('Preferences'), 'slot': self._open_preferences, 'shortcut': 'Ctrl+Shift+P', 'statusTip': trans._('Open preferences dialog'), 'menuRole': QAction.PreferencesRole, }, {}, { 'text': trans._('Save Selected Layer(s)...'), 'slot': lambda: window._qt_viewer._save_layers_dialog( selected=True ), 'shortcut': 'Ctrl+S', 'enabled': self._layer_count, }, { 'text': trans._('Save All Layers...'), 'slot': lambda: window._qt_viewer._save_layers_dialog( selected=False ), 'shortcut': 'Ctrl+Shift+S', 'enabled': self._layer_count, }, { 'text': trans._('Save Screenshot...'), 'slot': window._qt_viewer._screenshot_dialog, 'shortcut': 'Alt+S', 'statusTip': trans._( 'Save screenshot of current display, default .png' ), }, { 'text': trans._('Save Screenshot with Viewer...'), 'slot': self._screenshot_dialog, 'shortcut': 'Alt+Shift+S', 'statusTip': trans._( 'Save screenshot of current display with the viewer, default .png' ), }, { 'text': trans._('Copy Screenshot to Clipboard'), 'slot': window._qt_viewer.clipboard, 'shortcut': 'Alt+C', 'statusTip': trans._( 'Copy screenshot of current display to the clipboard' ), }, { 'text': trans._('Copy Screenshot with Viewer to Clipboard'), 'slot': window.clipboard, 'shortcut': 'Alt+Shift+C', 'statusTip': trans._( 'Copy screenshot of current display with the viewer to the clipboard' ), }, {}, { 'text': trans._('Close Window'), 'slot': self._close_window, 'shortcut': 'Ctrl+W', }, { 'when': running_as_bundled_app(), 'text': trans._('Restart'), 'slot': window._qt_window.restart, }, # OS X will rename this to Quit and put it in the app menu. # This quits the entire QApplication and closes all windows. { 'text': trans._('Exit'), 'slot': self._close_app, 'shortcut': 'Ctrl+Q', 'menuRole': QAction.QuitRole, }, ] populate_menu(self, ACTIONS) self._pref_dialog = None from napari.plugins import plugin_manager plugin_manager.discover_sample_data() plugin_manager.events.disabled.connect(self._rebuild_samples_menu) plugin_manager.events.registered.connect(self._rebuild_samples_menu) plugin_manager.events.unregistered.connect(self._rebuild_samples_menu) self._rebuild_samples_menu() self.update() def _close_app(self): self._win._qt_window.close(quit_app=True, confirm_need=True) def _close_window(self): self._win._qt_window.close(quit_app=False, confirm_need=True) def _layer_count(self, event=None): return len(self._win._qt_viewer.viewer.layers) def _screenshot_dialog(self): """Save screenshot of current display with viewer, default .png""" hist = get_save_history() dial = ScreenshotDialog( self._win.screenshot, self._win._qt_viewer, hist[0], hist ) if dial.exec_(): update_save_history(dial.selectedFiles()[0]) def _open_preferences(self): """Edit preferences from the menubar.""" if self._pref_dialog is None: win = PreferencesDialog(parent=self._win._qt_window) self._pref_dialog = win app_pref = get_settings().application if app_pref.preferences_size: win.resize(*app_pref.preferences_size) @win.resized.connect def _save_size(sz: QSize): app_pref.preferences_size = (sz.width(), sz.height()) win.finished.connect(self._clean_pref_dialog) win.show() else: self._pref_dialog.raise_() def _clean_pref_dialog(self): self._pref_dialog = None def _rebuild_samples_menu(self): from napari.plugins import _npe2, menu_item_template, plugin_manager self.open_sample_menu.clear() for plugin_name, samples in chain( _npe2.sample_iterator(), plugin_manager._sample_data.items() ): multiprovider = len(samples) > 1 if multiprovider: # use display_name for the menu item if npe2 from npe2 import plugin_manager as pm try: plugin_display_name = pm.get_manifest( plugin_name ).display_name except KeyError: plugin_display_name = plugin_name menu = self.open_sample_menu.addMenu( QMenu(title=plugin_display_name, parent=self) ).menu() else: menu = self.open_sample_menu for samp_name, samp_dict in samples.items(): display_name = samp_dict['display_name'].replace("&", "&&") if multiprovider: action = QAction(display_name, parent=self) else: full_name = menu_item_template.format( plugin_name, display_name ) action = QAction(full_name, parent=self) def _add_sample(*_, plg=plugin_name, smp=samp_name): try: self._win._qt_viewer.viewer.open_sample(plg, smp) except MultipleReaderError as e: handle_gui_reading( e.paths, self._win._qt_viewer, plugin_name=plg, stack=False, ) menu.addAction(action) action.triggered.connect(_add_sample) def _open_files_w_plugin(self): """Helper method for forcing plugin choice""" self._win._qt_viewer._open_files_dialog(choose_plugin=True) def _open_files_as_stack_w_plugin(self): """Helper method for forcing plugin choice""" self._win._qt_viewer._open_files_dialog_as_stack_dialog( choose_plugin=True ) def _open_folder_w_plugin(self): """Helper method for forcing plugin choice""" self._win._qt_viewer._open_folder_dialog(choose_plugin=True) @register_viewer_action(trans._("Show all key bindings")) def show_shortcuts(viewer: 'Viewer'): viewer.window.file_menu._open_preferences() pref_list = viewer.window.file_menu._pref_dialog._list for i in range(pref_list.count()): if pref_list.item(i).text() == "Shortcuts": pref_list.setCurrentRow(i) napari-0.5.0a1/napari/_qt/menus/plugins_menu.py000066400000000000000000000110071437041365600214630ustar00rootroot00000000000000from itertools import chain from typing import TYPE_CHECKING, Sequence from qtpy.QtWidgets import QAction from napari._qt.dialogs.qt_plugin_dialog import QtPluginDialog from napari._qt.dialogs.qt_plugin_report import QtPluginErrReporter from napari._qt.menus._util import NapariMenu from napari.plugins import _npe2 from napari.utils.translations import trans if TYPE_CHECKING: from napari._qt.qt_main_window import Window class PluginsMenu(NapariMenu): def __init__(self, window: 'Window') -> None: self._win = window super().__init__(trans._('&Plugins'), window._qt_window) from napari.plugins import plugin_manager _npe2.index_npe1_adapters() plugin_manager.discover_widgets() plugin_manager.events.disabled.connect( self._remove_unregistered_widget ) plugin_manager.events.registered.connect(self._add_registered_widget) plugin_manager.events.unregistered.connect( self._remove_unregistered_widget ) self._build() def _build(self, event=None): self.clear() action = self.addAction(trans._("Install/Uninstall Plugins...")) action.triggered.connect(self._show_plugin_install_dialog) action = self.addAction(trans._("Plugin Errors...")) action.setStatusTip( trans._( 'Review stack traces for plugin exceptions and notify developers' ) ) action.triggered.connect(self._show_plugin_err_reporter) self.addSeparator() # Add a menu item (QAction) for each available plugin widget self._add_registered_widget(call_all=True) def _remove_unregistered_widget(self, event): for action in self.actions(): if event.value in action.text(): self.removeAction(action) self._win._remove_dock_widget(event=event) def _add_registered_widget(self, event=None, call_all=False): from napari.plugins import plugin_manager # eg ('dock', ('my_plugin', {'My widget': MyWidget})) for hook_type, (plugin_name, widgets) in chain( _npe2.widget_iterator(), plugin_manager.iter_widgets() ): if call_all or event.value == plugin_name: self._add_plugin_actions(hook_type, plugin_name, widgets) def _add_plugin_actions( self, hook_type: str, plugin_name: str, widgets: Sequence[str] ): from napari.plugins import menu_item_template multiprovider = len(widgets) > 1 if multiprovider: # use display_name if npe2 plugin from npe2 import plugin_manager as pm try: plugin_display_name = pm.get_manifest(plugin_name).display_name except KeyError: plugin_display_name = plugin_name menu = NapariMenu(plugin_display_name, self) self.addMenu(menu) else: menu = self for wdg_name in widgets: key = (plugin_name, wdg_name) if multiprovider: action = QAction(wdg_name.replace("&", "&&"), parent=self) else: full_name = menu_item_template.format(*key) action = QAction(full_name.replace("&", "&&"), parent=self) def _add_toggle_widget(*, key=key, hook_type=hook_type): full_name = menu_item_template.format(*key) if full_name in self._win._dock_widgets.keys(): dock_widget = self._win._dock_widgets[full_name] if dock_widget.isVisible(): dock_widget.hide() else: dock_widget.show() return if hook_type == 'dock': self._win.add_plugin_dock_widget(*key) else: self._win._add_plugin_function_widget(*key) action.setCheckable(True) # check that this wasn't added to the menu already actions = [a.text() for a in menu.actions()] if action.text() not in actions: menu.addAction(action) action.triggered.connect(_add_toggle_widget) def _show_plugin_install_dialog(self): """Show dialog that allows users to sort the call order of plugins.""" QtPluginDialog(self._win._qt_window).exec_() def _show_plugin_err_reporter(self): """Show dialog that allows users to review and report plugin errors.""" QtPluginErrReporter(parent=self._win._qt_window).exec_() napari-0.5.0a1/napari/_qt/menus/window_menu.py000066400000000000000000000006331437041365600213140ustar00rootroot00000000000000from typing import TYPE_CHECKING from napari._qt.menus._util import NapariMenu, populate_menu from napari.utils.translations import trans if TYPE_CHECKING: from napari._qt.qt_main_window import Window class WindowMenu(NapariMenu): def __init__(self, window: 'Window') -> None: super().__init__(trans._('&Window'), window._qt_window) ACTIONS = [] populate_menu(self, ACTIONS) napari-0.5.0a1/napari/_qt/perf/000077500000000000000000000000001437041365600162125ustar00rootroot00000000000000napari-0.5.0a1/napari/_qt/perf/__init__.py000066400000000000000000000000001437041365600203110ustar00rootroot00000000000000napari-0.5.0a1/napari/_qt/perf/_tests/000077500000000000000000000000001437041365600175135ustar00rootroot00000000000000napari-0.5.0a1/napari/_qt/perf/_tests/__init__.py000066400000000000000000000000001437041365600216120ustar00rootroot00000000000000napari-0.5.0a1/napari/_qt/perf/_tests/test_perf.py000066400000000000000000000051321437041365600220610ustar00rootroot00000000000000import dataclasses import json import os import subprocess import sys from pathlib import Path from unittest.mock import MagicMock from napari._qt.perf import qt_performance from napari._tests.utils import skip_local_popups, skip_on_win_ci # NOTE: # for some reason, running this test fails in a subprocess with a segfault # if you don't show the viewer... PERFMON_SCRIPT = """ import napari from qtpy.QtCore import QTimer v = napari.view_points() QTimer.singleShot(100, napari._qt.qt_event_loop.quit_app) napari.run() """ CONFIG = { "trace_qt_events": True, "trace_file_on_start": '', "trace_callables": ["chunk_loader"], "callable_lists": { "chunk_loader": [ "napari.components.experimental.chunk._loader.ChunkLoader.load_request", "napari.components.experimental.chunk._loader.ChunkLoader._on_done", ] }, } @skip_on_win_ci @skip_local_popups def test_trace_on_start(tmp_path: Path): """Make sure napari can write a perfmon trace file.""" trace_path = tmp_path / "trace.json" config_path = tmp_path / "perfmon.json" CONFIG['trace_file_on_start'] = str(trace_path) config_path.write_text(json.dumps(CONFIG)) env = os.environ.copy() env.update({'NAPARI_PERFMON': str(config_path), 'NAPARI_CONFIG': ''}) subprocess.run([sys.executable, '-c', PERFMON_SCRIPT], env=env, check=True) # Make sure file exists and is not empty. assert trace_path.exists(), "Trace file not written" assert trace_path.stat().st_size > 0, "Trace file is empty" # Assert every event contains every important field. with open(trace_path) as infile: data = json.load(infile) assert len(data) > 0 for event in data: for field in ['pid', 'tid', 'name', 'ph', 'ts', 'args']: assert field in event def test_qt_performance(qtbot, monkeypatch): widget = qt_performance.QtPerformance() widget.timer.stop() qtbot.addWidget(widget) mock = MagicMock() data = [ ("test1", MockTimer(1, 1)), ("test2", MockTimer(20, 120)), ("test1", MockTimer(70, 90)), ("test2", MockTimer(50, 220)), ] mock.timers.items = MagicMock(return_value=data) monkeypatch.setattr(qt_performance.perf, 'timers', mock) assert widget.log.toPlainText() == "" widget.update() assert widget.log.toPlainText() == ' 120ms test2\n 220ms test2\n' widget._change_thresh("150") assert widget.log.toPlainText() == "" widget.update() assert widget.log.toPlainText() == ' 220ms test2\n' @dataclasses.dataclass class MockTimer: average: float max: float napari-0.5.0a1/napari/_qt/perf/qt_event_tracing.py000066400000000000000000000065471437041365600221340ustar00rootroot00000000000000"""A special QApplication for perfmon that traces events. This file defines QApplicationWithTracing which we use when perfmon is enabled to time Qt Events. When using perfmon there is a debug menu "Start Tracing" command as well as a dockable QtPerformance widget. """ from qtpy.QtCore import QEvent from qtpy.QtWidgets import QApplication, QWidget from napari.utils import perf from napari.utils.translations import trans class QApplicationWithTracing(QApplication): """Extend QApplication to trace Qt Events. This QApplication wraps a perf_timer around the normal notify(). Notes ----- Qt Event handling is nested. A call to notify() can trigger other calls to notify() prior to the first one finishing, even several levels deep. The hierarchy of timers is displayed correctly in the chrome://tracing GUI. Seeing the structure of the event handling hierarchy can be very informative even apart from the actual timing numbers, which is why we call it "tracing" instead of just "timing". """ def notify(self, receiver, event): """Trace events while we handle them.""" timer_name = _get_event_label(receiver, event) # Time the event while we handle it. with perf.perf_timer(timer_name, "qt_event"): return QApplication.notify(self, receiver, event) class EventTypes: """Convert event type to a string name. Create event type to string mapping once on startup. We want human-readable event names for our timers. PySide2 does this for you but PyQt5 does not: # PySide2 str(QEvent.KeyPress) -> 'PySide2.QtCore.QEvent.Type.KeyPress' # PyQt5 str(QEvent.KeyPress) -> '6' We use this class for PyQt5 and PySide2 to be consistent. """ def __init__(self) -> None: """Create mapping for all known event types.""" self.string_name = {} for name in vars(QEvent): attribute = getattr(QEvent, name) if type(attribute) == QEvent.Type: self.string_name[attribute] = name def as_string(self, event: QEvent.Type) -> str: """Return the string name for this event. event : QEvent.Type Return string for this event type. """ try: return self.string_name[event] except KeyError: return trans._("UnknownEvent:{event}", event=event) EVENT_TYPES = EventTypes() def _get_event_label(receiver: QWidget, event: QEvent) -> str: """Return a label for this event. Parameters ---------- receiver : QWidget The receiver of the event. event : QEvent The event name. Returns ------- str Label to display for the event. Notes ----- If no object we return . If there's an object we return :. Combining the two names with a colon is our own made-up format. The name will show up in chrome://tracing and our QtPerformance widget. """ event_str = EVENT_TYPES.as_string(event.type()) try: # There may or may not be a receiver object name. object_name = receiver.objectName() except AttributeError: # Ignore "missing objectName attribute" during shutdown. object_name = None if object_name: return f"{event_str}:{object_name}" # There was no object (pretty common). return event_str napari-0.5.0a1/napari/_qt/perf/qt_performance.py000066400000000000000000000135271437041365600216010ustar00rootroot00000000000000"""QtPerformance widget to show performance information. """ import time from qtpy.QtCore import Qt, QTimer from qtpy.QtGui import QTextCursor from qtpy.QtWidgets import ( QComboBox, QHBoxLayout, QLabel, QProgressBar, QSizePolicy, QSpacerItem, QTextEdit, QVBoxLayout, QWidget, ) from napari.utils import perf from napari.utils.translations import trans class TextLog(QTextEdit): """Text window we can write "log" messages to. TODO: need to limit length, erase oldest messages? """ def append(self, name: str, time_ms: float) -> None: """Add one line of text for this timer. Parameters ---------- name : str Timer name. time_ms : float Duration of the timer in milliseconds. """ self.moveCursor(QTextCursor.MoveOperation.End) self.setTextColor(Qt.GlobalColor.red) self.insertPlainText( trans._("{time_ms:5.0f}ms {name}\n", time_ms=time_ms, name=name) ) class QtPerformance(QWidget): """Dockable widget to show performance info. Notes ----- 1) The progress bar doesn't show "progress", we use it as a bar graph to show the average duration of recent "UpdateRequest" events. This is actually not the total draw time, but it's generally the biggest part of each frame. 2) We log any event whose duration is longer than the threshold. 3) We show uptime so you can tell if this window is being updated at all. Attributes ---------- start_time : float Time is seconds when widget was created. bar : QProgressBar The progress bar we use as your draw time indicator. thresh_ms : float Log events whose duration is longer then this. timer_label : QLabel We write the current "uptime" into this label. timer : QTimer To update our window every UPDATE_MS. """ # We log events slower than some threshold (in milliseconds). THRESH_DEFAULT = 100 THRESH_OPTIONS = [ "1", "5", "10", "15", "20", "30", "40", "50", "100", "200", ] # Update at 250ms / 4Hz for now. The more we update more alive our # display will look, but the more we will slow things down. UPDATE_MS = 250 def __init__(self) -> None: """Create our windgets.""" super().__init__() layout = QVBoxLayout() # We log slow events to this window. self.log = TextLog() # For our "uptime" timer. self.start_time = time.time() # Label for our progress bar. bar_label = QLabel(trans._("Draw Time:")) layout.addWidget(bar_label) # Progress bar is not used for "progress", it's just a bar graph to show # the "draw time", the duration of the "UpdateRequest" event. bar = QProgressBar() bar.setRange(0, 100) bar.setValue(50) bar.setFormat("%vms") layout.addWidget(bar) self.bar = bar # We let the user set the "slow event" threshold. self.thresh_ms = self.THRESH_DEFAULT self.thresh_combo = QComboBox() self.thresh_combo.addItems(self.THRESH_OPTIONS) self.thresh_combo.currentTextChanged.connect(self._change_thresh) self.thresh_combo.setCurrentText(str(self.thresh_ms)) combo_layout = QHBoxLayout() combo_layout.addWidget(QLabel(trans._("Show Events Slower Than:"))) combo_layout.addWidget(self.thresh_combo) combo_layout.addWidget(QLabel(trans._("milliseconds"))) combo_layout.addItem( QSpacerItem(40, 20, QSizePolicy.Expanding, QSizePolicy.Minimum) ) layout.addLayout(combo_layout) layout.addWidget(self.log) # Uptime label. To indicate if the widget is getting updated. label = QLabel('') layout.addWidget(label) self.timer_label = label self.setLayout(layout) # Update us with a timer. self.timer = QTimer(self) self.timer.timeout.connect(self.update) self.timer.setInterval(self.UPDATE_MS) self.timer.start() def _change_thresh(self, text): """Threshold combo box change.""" self.thresh_ms = float(text) self.log.clear() # start fresh with this new threshold def _get_timer_info(self): """Get the information from the timers that we want to display.""" average = None long_events = [] # We don't update any GUI/widgets while iterating over the timers. # Updating widgets can create immediate Qt Events which would modify the # timers out from under us! for name, timer in perf.timers.timers.items(): # The Qt Event "UpdateRequest" is the main "draw" event, so # that's what we use for our progress bar. if name == "UpdateRequest": average = timer.average # Log any "long" events to the text window. if timer.max >= self.thresh_ms: long_events.append((name, timer.max)) return average, long_events def update(self): """Update our label and progress bar and log any new slow events.""" # Update our timer label. elapsed = time.time() - self.start_time self.timer_label.setText( trans._("Uptime: {elapsed:.2f}", elapsed=elapsed) ) average, long_events = self._get_timer_info() # Now safe to update the GUI: progress bar first. if average is not None: self.bar.setValue(int(average)) # And log any new slow events. for name, time_ms in long_events: self.log.append(name, time_ms) # Clear all the timers since we've displayed them. They will immediately # start accumulating numbers for the next update. perf.timers.clear() napari-0.5.0a1/napari/_qt/qt_event_filters.py000066400000000000000000000014071437041365600212070ustar00rootroot00000000000000"""Qt event filters providing custom handling of events.""" import html from qtpy.QtCore import QEvent, QObject from qtpy.QtWidgets import QWidget from napari._qt.utils import qt_might_be_rich_text class QtToolTipEventFilter(QObject): """ An event filter that converts all plain-text widget tooltips to rich-text tooltips. """ def eventFilter(self, qobject: QObject, event: QEvent) -> bool: if event.type() == QEvent.ToolTipChange and isinstance( qobject, QWidget ): tooltip = qobject.toolTip() if tooltip and not qt_might_be_rich_text(tooltip): qobject.setToolTip(f'{html.escape(tooltip)}') return True return super().eventFilter(qobject, event) napari-0.5.0a1/napari/_qt/qt_event_loop.py000066400000000000000000000345451437041365600205210ustar00rootroot00000000000000from __future__ import annotations import os import sys from contextlib import contextmanager from typing import TYPE_CHECKING from warnings import warn from qtpy import PYQT5 from qtpy.QtCore import QDir, Qt from qtpy.QtGui import QIcon from qtpy.QtWidgets import QApplication from napari import Viewer, __version__ from napari._qt.dialogs.qt_notification import NapariQtNotification from napari._qt.qt_event_filters import QtToolTipEventFilter from napari._qt.qthreading import ( register_threadworker_processors, wait_for_workers_to_quit, ) from napari._qt.utils import _maybe_allow_interrupt from napari.resources._icons import _theme_path from napari.settings import get_settings from napari.utils import config, perf from napari.utils.notifications import ( notification_manager, show_console_notification, ) from napari.utils.perf import perf_config from napari.utils.theme import _themes from napari.utils.translations import trans if TYPE_CHECKING: from IPython import InteractiveShell NAPARI_ICON_PATH = os.path.join( os.path.dirname(__file__), '..', 'resources', 'logo.png' ) NAPARI_APP_ID = f'napari.napari.viewer.{__version__}' def set_app_id(app_id): if os.name == "nt" and app_id and not getattr(sys, 'frozen', False): import ctypes ctypes.windll.shell32.SetCurrentProcessExplicitAppUserModelID(app_id) _defaults = { 'app_name': 'napari', 'app_version': __version__, 'icon': NAPARI_ICON_PATH, 'org_name': 'napari', 'org_domain': 'napari.org', 'app_id': NAPARI_APP_ID, } # store reference to QApplication to prevent garbage collection _app_ref = None _IPYTHON_WAS_HERE_FIRST = "IPython" in sys.modules def get_app( *, app_name: str = None, app_version: str = None, icon: str = None, org_name: str = None, org_domain: str = None, app_id: str = None, ipy_interactive: bool = None, ) -> QApplication: """Get or create the Qt QApplication. There is only one global QApplication instance, which can be retrieved by calling get_app again, (or by using QApplication.instance()) Parameters ---------- app_name : str, optional Set app name (if creating for the first time), by default 'napari' app_version : str, optional Set app version (if creating for the first time), by default __version__ icon : str, optional Set app icon (if creating for the first time), by default NAPARI_ICON_PATH org_name : str, optional Set organization name (if creating for the first time), by default 'napari' org_domain : str, optional Set organization domain (if creating for the first time), by default 'napari.org' app_id : str, optional Set organization domain (if creating for the first time). Will be passed to set_app_id (which may also be called independently), by default NAPARI_APP_ID ipy_interactive : bool, optional Use the IPython Qt event loop ('%gui qt' magic) if running in an interactive IPython terminal. Returns ------- QApplication [description] Notes ----- Substitutes QApplicationWithTracing when the NAPARI_PERFMON env variable is set. """ # napari defaults are all-or nothing. If any of the keywords are used # then they are all used. set_values = {k for k, v in locals().items() if v} kwargs = locals() if set_values else _defaults global _app_ref app = QApplication.instance() if app: set_values.discard("ipy_interactive") if set_values: warn( trans._( "QApplication already existed, these arguments to to 'get_app' were ignored: {args}", deferred=True, args=set_values, ) ) if perf_config and perf_config.trace_qt_events: warn( trans._( "Using NAPARI_PERFMON with an already-running QtApp (--gui qt?) is not supported.", deferred=True, ) ) else: # automatically determine monitor DPI. # Note: this MUST be set before the QApplication is instantiated if PYQT5: QApplication.setAttribute( Qt.ApplicationAttribute.AA_EnableHighDpiScaling ) QApplication.setAttribute( Qt.ApplicationAttribute.AA_UseHighDpiPixmaps ) argv = sys.argv.copy() if sys.platform == "darwin" and not argv[0].endswith("napari"): # Make sure the app name in the Application menu is `napari` # which is taken from the basename of sys.argv[0]; we use # a copy so the original value is still available at sys.argv argv[0] = "napari" if perf_config and perf_config.trace_qt_events: from napari._qt.perf.qt_event_tracing import ( QApplicationWithTracing, ) app = QApplicationWithTracing(argv) else: app = QApplication(argv) # if this is the first time the Qt app is being instantiated, we set # the name and metadata app.setApplicationName(kwargs.get('app_name')) app.setApplicationVersion(kwargs.get('app_version')) app.setOrganizationName(kwargs.get('org_name')) app.setOrganizationDomain(kwargs.get('org_domain')) set_app_id(kwargs.get('app_id')) # Intercept tooltip events in order to convert all text to rich text # to allow for text wrapping of tooltips app.installEventFilter(QtToolTipEventFilter()) if app.windowIcon().isNull(): app.setWindowIcon(QIcon(kwargs.get('icon'))) if ipy_interactive is None: ipy_interactive = get_settings().application.ipy_interactive if _IPYTHON_WAS_HERE_FIRST: _try_enable_ipython_gui('qt' if ipy_interactive else None) if not _ipython_has_eventloop(): notification_manager.notification_ready.connect( NapariQtNotification.show_notification ) notification_manager.notification_ready.connect( show_console_notification ) if perf_config and not perf_config.patched: # Will patch based on config file. perf_config.patch_callables() if not _app_ref: # running get_app for the first time # see docstring of `wait_for_workers_to_quit` for caveats on killing # workers at shutdown. app.aboutToQuit.connect(wait_for_workers_to_quit) # Setup search paths for currently installed themes. for name in _themes: QDir.addSearchPath(f'theme_{name}', str(_theme_path(name))) # When a new theme is added, at it to the search path. @_themes.events.changed.connect @_themes.events.added.connect def _(event): name = event.key QDir.addSearchPath(f'theme_{name}', str(_theme_path(name))) register_threadworker_processors() _app_ref = app # prevent garbage collection # Add the dispatcher attribute to the application to be able to dispatch # notifications coming from threads return app def quit_app(): """Close all windows and quit the QApplication if napari started it.""" for v in list(Viewer._instances): v.close() QApplication.closeAllWindows() # if we started the application then the app will be named 'napari'. if ( QApplication.applicationName() == 'napari' and not _ipython_has_eventloop() ): QApplication.quit() # otherwise, something else created the QApp before us (such as # %gui qt IPython magic). If we quit the app in this case, then # *later* attempts to instantiate a napari viewer won't work until # the event loop is restarted with app.exec_(). So rather than # quit just close all the windows (and clear our app icon). else: QApplication.setWindowIcon(QIcon()) if perf.USE_PERFMON: # Write trace file before exit, if we were writing one. # Is there a better place to make sure this is done on exit? perf.timers.stop_trace_file() if config.monitor: # Stop the monitor service if we were using it from napari.components.experimental.monitor import monitor monitor.stop() if config.async_loading: # Shutdown the chunkloader from napari.components.experimental.chunk import chunk_loader chunk_loader.shutdown() @contextmanager def gui_qt(*, startup_logo=False, gui_exceptions=False, force=False): """Start a Qt event loop in which to run the application. NOTE: This context manager is deprecated!. Prefer using :func:`napari.run`. Parameters ---------- startup_logo : bool, optional Show a splash screen with the napari logo during startup. gui_exceptions : bool, optional Whether to show uncaught exceptions in the GUI, by default they will be shown in the console that launched the event loop. force : bool, optional Force the application event_loop to start, even if there are no top level widgets to show. Notes ----- This context manager is not needed if running napari within an interactive IPython session. In this case, use the ``%gui qt`` magic command, or start IPython with the Qt GUI event loop enabled by default by using ``ipython --gui=qt``. """ warn( trans._( "\nThe 'gui_qt()' context manager is deprecated.\nIf you are running napari from a script, please use 'napari.run()' as follows:\n\n import napari\n\n viewer = napari.Viewer() # no prior setup needed\n # other code using the viewer...\n napari.run()\n\nIn IPython or Jupyter, 'napari.run()' is not necessary. napari will automatically\nstart an interactive event loop for you: \n\n import napari\n viewer = napari.Viewer() # that's it!\n", deferred=True, ), FutureWarning, ) app = get_app() splash = None if startup_logo and app.applicationName() == 'napari': from napari._qt.widgets.qt_splash_screen import NapariSplashScreen splash = NapariSplashScreen() splash.close() try: yield app except Exception: # noqa: BLE001 notification_manager.receive_error(*sys.exc_info()) run(force=force, gui_exceptions=gui_exceptions, _func_name='gui_qt') def _ipython_has_eventloop() -> bool: """Return True if IPython %gui qt is active. Using this is better than checking ``QApp.thread().loopLevel() > 0``, because IPython starts and stops the event loop continuously to accept code at the prompt. So it will likely "appear" like there is no event loop running, but we still don't need to start one. """ ipy_module = sys.modules.get("IPython") if not ipy_module: return False shell: InteractiveShell = ipy_module.get_ipython() # type: ignore if not shell: return False return shell.active_eventloop == 'qt' def _pycharm_has_eventloop(app: QApplication) -> bool: """Return true if running in PyCharm and eventloop is active. Explicit checking is necessary because PyCharm runs a custom interactive shell which overrides `InteractiveShell.enable_gui()`, breaking some superclass behaviour. """ in_pycharm = 'PYCHARM_HOSTED' in os.environ in_event_loop = getattr(app, '_in_event_loop', False) return in_pycharm and in_event_loop def _try_enable_ipython_gui(gui='qt'): """Start %gui qt the eventloop.""" ipy_module = sys.modules.get("IPython") if not ipy_module: return shell: InteractiveShell = ipy_module.get_ipython() # type: ignore if not shell: return if shell.active_eventloop != gui: shell.enable_gui(gui) def run( *, force=False, gui_exceptions=False, max_loop_level=1, _func_name='run' ): """Start the Qt Event Loop Parameters ---------- force : bool, optional Force the application event_loop to start, even if there are no top level widgets to show. gui_exceptions : bool, optional Whether to show uncaught exceptions in the GUI. By default they will be shown in the console that launched the event loop. max_loop_level : int, optional The maximum allowable "loop level" for the execution thread. Every time `QApplication.exec_()` is called, Qt enters the event loop, increments app.thread().loopLevel(), and waits until exit() is called. This function will prevent calling `exec_()` if the application already has at least ``max_loop_level`` event loops running. By default, 1. _func_name : str, optional name of calling function, by default 'run'. This is only here to provide functions like `gui_qt` a way to inject their name into the warning message. Raises ------ RuntimeError (To avoid confusion) if no widgets would be shown upon starting the event loop. """ if _ipython_has_eventloop(): # If %gui qt is active, we don't need to block again. return app = QApplication.instance() if _pycharm_has_eventloop(app): # explicit check for PyCharm pydev console return if not app: raise RuntimeError( trans._( 'No Qt app has been created. One can be created by calling `get_app()` or `qtpy.QtWidgets.QApplication([])`', deferred=True, ) ) if not app.topLevelWidgets() and not force: warn( trans._( "Refusing to run a QApplication with no topLevelWidgets. To run the app anyway, use `{_func_name}(force=True)`", deferred=True, _func_name=_func_name, ) ) return if app.thread().loopLevel() >= max_loop_level: loops = app.thread().loopLevel() warn( trans._n( "A QApplication is already running with 1 event loop. To enter *another* event loop, use `{_func_name}(max_loop_level={max_loop_level})`", "A QApplication is already running with {n} event loops. To enter *another* event loop, use `{_func_name}(max_loop_level={max_loop_level})`", n=loops, deferred=True, _func_name=_func_name, max_loop_level=loops + 1, ) ) return with notification_manager, _maybe_allow_interrupt(app): app.exec_() napari-0.5.0a1/napari/_qt/qt_main_window.py000066400000000000000000001521621437041365600206560ustar00rootroot00000000000000""" Custom Qt widgets that serve as native objects that the public-facing elements wrap. """ import contextlib import inspect import os import sys import time import warnings from typing import ( TYPE_CHECKING, Any, ClassVar, Dict, List, Optional, Sequence, Tuple, Union, cast, ) from weakref import WeakValueDictionary from qtpy.QtCore import ( QEvent, QEventLoop, QPoint, QProcess, QRect, QSize, Qt, Slot, ) from qtpy.QtGui import QIcon from qtpy.QtWidgets import ( QApplication, QDialog, QDockWidget, QHBoxLayout, QMainWindow, QMenu, QShortcut, QToolTip, QWidget, ) from superqt.utils import QSignalThrottler from napari._app_model.constants import MenuId from napari._qt import menus from napari._qt._qapp_model import build_qmodel_menu from napari._qt._qapp_model.qactions import init_qactions from napari._qt.dialogs.confirm_close_dialog import ConfirmCloseDialog from napari._qt.dialogs.qt_activity_dialog import QtActivityDialog from napari._qt.dialogs.qt_notification import NapariQtNotification from napari._qt.qt_event_loop import NAPARI_ICON_PATH, get_app, quit_app from napari._qt.qt_resources import get_stylesheet from napari._qt.qt_viewer import QtViewer from napari._qt.utils import QImg2array, qbytearray_to_str, str_to_qbytearray from napari._qt.widgets.qt_viewer_dock_widget import ( _SHORTCUT_DEPRECATION_STRING, QtViewerDockWidget, ) from napari._qt.widgets.qt_viewer_status_bar import ViewerStatusBar from napari.plugins import menu_item_template as plugin_menu_item_template from napari.plugins import plugin_manager from napari.settings import get_settings from napari.utils import perf from napari.utils._proxies import PublicOnlyProxy from napari.utils.io import imsave from napari.utils.misc import ( in_ipython, in_jupyter, in_python_repl, running_as_bundled_app, ) from napari.utils.notifications import Notification from napari.utils.theme import _themes, get_system_theme from napari.utils.translations import trans _sentinel = object() if TYPE_CHECKING: from magicgui.widgets import Widget from qtpy.QtGui import QImage from napari.viewer import Viewer class _QtMainWindow(QMainWindow): # This was added so that someone can patch # `napari._qt.qt_main_window._QtMainWindow._window_icon` # to their desired window icon _window_icon = NAPARI_ICON_PATH # To track window instances and facilitate getting the "active" viewer... # We use this instead of QApplication.activeWindow for compatibility with # IPython usage. When you activate IPython, it will appear that there are # *no* active windows, so we want to track the most recently active windows _instances: ClassVar[List['_QtMainWindow']] = [] # `window` is passed through on construction so it's available to a window # provider for dependency injection # See https://github.com/napari/napari/pull/4826 def __init__( self, viewer: 'Viewer', window: 'Window', parent=None ) -> None: super().__init__(parent) self._ev = None self._window = window self._qt_viewer = QtViewer(viewer, show_welcome_screen=True) self._quit_app = False self.setWindowIcon(QIcon(self._window_icon)) self.setAttribute(Qt.WidgetAttribute.WA_DeleteOnClose) self.setUnifiedTitleAndToolBarOnMac(True) center = QWidget(self) center.setLayout(QHBoxLayout()) center.layout().addWidget(self._qt_viewer) center.layout().setContentsMargins(4, 0, 4, 0) self.setCentralWidget(center) self.setWindowTitle(self._qt_viewer.viewer.title) self._maximized_flag = False self._fullscreen_flag = False self._normal_geometry = QRect() self._window_size = None self._window_pos = None self._old_size = None self._positions = [] self._is_close_dialog = {False: True, True: True} # this ia sa workaround for #5335 issue. The dict is used to not # collide shortcuts for close and close all windows act_dlg = QtActivityDialog(self._qt_viewer._welcome_widget) self._qt_viewer._welcome_widget.resized.connect( act_dlg.move_to_bottom_right ) act_dlg.hide() self._activity_dialog = act_dlg self.setStatusBar(ViewerStatusBar(self)) settings = get_settings() # TODO: # settings.plugins.defaults.call_order = plugin_manager.call_order() # set the values in plugins to match the ones saved in settings if settings.plugins.call_order is not None: plugin_manager.set_call_order(settings.plugins.call_order) _QtMainWindow._instances.append(self) # since we initialize canvas before window, # we need to manually connect them again. handle = self.windowHandle() if handle is not None: handle.screenChanged.connect( self._qt_viewer.canvas._backend.screen_changed ) # this is the line that initializes any Qt-based app-model Actions that # were defined somewhere in the `_qt` module and imported in init_qactions init_qactions() self.status_throttler = QSignalThrottler(parent=self) self.status_throttler.setTimeout(50) self._throttle_cursor_to_status_connection(viewer) def _throttle_cursor_to_status_connection(self, viewer: 'Viewer'): # In the GUI we expect lots of changes to the cursor position, so # replace the direct connection with a throttled one. with contextlib.suppress(IndexError): viewer.cursor.events.position.disconnect( viewer._update_status_bar_from_cursor ) viewer.cursor.events.position.connect(self.status_throttler.throttle) self.status_throttler.triggered.connect( viewer._update_status_bar_from_cursor ) def statusBar(self) -> 'ViewerStatusBar': return super().statusBar() @classmethod def current(cls) -> Optional['_QtMainWindow']: return cls._instances[-1] if cls._instances else None @classmethod def current_viewer(cls): window = cls.current() return window._qt_viewer.viewer if window else None def event(self, e: QEvent) -> bool: if ( e.type() == QEvent.Type.ToolTip and self._qt_viewer.viewer.tooltip.visible ): # globalPos is for Qt5 e.globalPosition().toPoint() is for QT6 # https://doc-snapshots.qt.io/qt6-dev/qmouseevent-obsolete.html#globalPos pnt = ( e.globalPosition().toPoint() if hasattr(e, "globalPosition") else e.globalPos() ) QToolTip.showText(pnt, self._qt_viewer.viewer.tooltip.text, self) if e.type() == QEvent.Type.Close: # when we close the MainWindow, remove it from the instances list with contextlib.suppress(ValueError): _QtMainWindow._instances.remove(self) if e.type() in {QEvent.Type.WindowActivate, QEvent.Type.ZOrderChange}: # upon activation or raise_, put window at the end of _instances with contextlib.suppress(ValueError): inst = _QtMainWindow._instances inst.append(inst.pop(inst.index(self))) return super().event(e) def isFullScreen(self): # Needed to prevent errors when going to fullscreen mode on Windows # Use a flag attribute to determine if the window is in full screen mode # See https://bugreports.qt.io/browse/QTBUG-41309 # Based on https://github.com/spyder-ide/spyder/pull/7720 return self._fullscreen_flag def showNormal(self): # Needed to prevent errors when going to fullscreen mode on Windows. Here we: # * Set fullscreen flag # * Remove `Qt.FramelessWindowHint` and `Qt.WindowStaysOnTopHint` window flags if needed # * Set geometry to previously stored normal geometry or default empty QRect # Always call super `showNormal` to set Qt window state # See https://bugreports.qt.io/browse/QTBUG-41309 # Based on https://github.com/spyder-ide/spyder/pull/7720 self._fullscreen_flag = False if os.name == 'nt': self.setWindowFlags( self.windowFlags() ^ (Qt.FramelessWindowHint | Qt.WindowStaysOnTopHint) ) self.setGeometry(self._normal_geometry) super().showNormal() def showFullScreen(self): # Needed to prevent errors when going to fullscreen mode on Windows. Here we: # * Set fullscreen flag # * Add `Qt.FramelessWindowHint` and `Qt.WindowStaysOnTopHint` window flags if needed # * Call super `showNormal` to update the normal screen geometry to apply it later if needed # * Save window normal geometry if needed # * Get screen geometry # * Set geometry window to use total screen geometry +1 in every direction if needed # If the workaround is not needed just call super `showFullScreen` # See https://bugreports.qt.io/browse/QTBUG-41309 # Based on https://github.com/spyder-ide/spyder/pull/7720 self._fullscreen_flag = True if os.name == 'nt': self.setWindowFlags( self.windowFlags() | Qt.FramelessWindowHint | Qt.WindowStaysOnTopHint ) super().showNormal() self._normal_geometry = self.normalGeometry() screen_rect = self.windowHandle().screen().geometry() self.setGeometry( screen_rect.left() - 1, screen_rect.top() - 1, screen_rect.width() + 2, screen_rect.height() + 2, ) else: super().showFullScreen() def _load_window_settings(self): """ Load window layout settings from configuration. """ settings = get_settings() window_position = settings.application.window_position # It's necessary to verify if the window/position value is valid with # the current screen. if not window_position: window_position = (self.x(), self.y()) else: width, height = window_position screen_geo = QApplication.primaryScreen().geometry() if screen_geo.width() < width or screen_geo.height() < height: window_position = (self.x(), self.y()) return ( settings.application.window_state, settings.application.window_size, window_position, settings.application.window_maximized, settings.application.window_fullscreen, ) def _get_window_settings(self): """Return current window settings. Symmetric to the 'set_window_settings' setter. """ window_fullscreen = self.isFullScreen() if window_fullscreen: window_maximized = self._maximized_flag else: window_maximized = self.isMaximized() window_state = qbytearray_to_str(self.saveState()) return ( window_state, self._window_size or (self.width(), self.height()), self._window_pos or (self.x(), self.y()), window_maximized, window_fullscreen, ) def _set_window_settings( self, window_state, window_size, window_position, window_maximized, window_fullscreen, ): """ Set window settings. Symmetric to the 'get_window_settings' accessor. """ self.setUpdatesEnabled(False) self.setWindowState(Qt.WindowState.WindowNoState) if window_position: window_position = QPoint(*window_position) self.move(window_position) if window_size: window_size = QSize(*window_size) self.resize(window_size) if window_state: self.restoreState(str_to_qbytearray(window_state)) # Toggling the console visibility is disabled when it is not # available, so ensure that it is hidden. if in_ipython() or in_jupyter() or in_python_repl(): self._qt_viewer.dockConsole.setVisible(False) if window_fullscreen: self._maximized_flag = window_maximized self.showFullScreen() elif window_maximized: self.setWindowState(Qt.WindowState.WindowMaximized) self.setUpdatesEnabled(True) def _save_current_window_settings(self): """Save the current geometry of the main window.""" ( window_state, window_size, window_position, window_maximized, window_fullscreen, ) = self._get_window_settings() settings = get_settings() if settings.application.save_window_geometry: settings.application.window_maximized = window_maximized settings.application.window_fullscreen = window_fullscreen settings.application.window_position = window_position settings.application.window_size = window_size settings.application.window_statusbar = ( not self.statusBar().isHidden() ) if settings.application.save_window_state: settings.application.window_state = window_state def close(self, quit_app=False, confirm_need=False): """Override to handle closing app or just the window.""" if hasattr(self.status_throttler, "_timer"): self.status_throttler._timer.stop() if not quit_app and not self._qt_viewer.viewer.layers: return super().close() confirm_need_local = confirm_need and self._is_close_dialog[quit_app] self._is_close_dialog[quit_app] = False # here we save information that we could request confirmation on close # So fi function `close` is called again, we don't ask again but just close if ( not confirm_need_local or not get_settings().application.confirm_close_window or ConfirmCloseDialog(self, quit_app).exec_() == QDialog.Accepted ): self._quit_app = quit_app self._is_close_dialog[quit_app] = True # here we inform that confirmation dialog is not open self._qt_viewer.dims.stop() return super().close() self._is_close_dialog[quit_app] = True # here we inform that confirmation dialog is not open def close_window(self): """Close active dialog or active window.""" parent = QApplication.focusWidget() while parent is not None: if isinstance(parent, QMainWindow): self.close() break if isinstance(parent, QDialog): parent.close() break try: parent = parent.parent() except AttributeError: parent = getattr(parent, "_parent", None) def show(self, block=False): super().show() self._qt_viewer.setFocus() if block: self._ev = QEventLoop() self._ev.exec() def changeEvent(self, event): """Handle window state changes.""" if event.type() == QEvent.Type.WindowStateChange: # TODO: handle maximization issue. When double clicking on the # title bar on Mac the resizeEvent is called an varying amount # of times which makes it hard to track the original size before # maximization. condition = ( self.isMaximized() if os.name == "nt" else self.isFullScreen() ) if condition and self._old_size is not None: if self._positions and len(self._positions) > 1: self._window_pos = self._positions[-2] self._window_size = ( self._old_size.width(), self._old_size.height(), ) else: self._old_size = None self._window_pos = None self._window_size = None self._positions = [] super().changeEvent(event) def resizeEvent(self, event): """Override to handle original size before maximizing.""" # the first resize event will have nonsense positions that we dont # want to store (and potentially restore) if event.oldSize().isValid(): self._old_size = event.oldSize() self._positions.append((self.x(), self.y())) if self._positions and len(self._positions) >= 2: self._window_pos = self._positions[-2] self._positions = self._positions[-2:] super().resizeEvent(event) def closeEvent(self, event): """This method will be called when the main window is closing. Regardless of whether cmd Q, cmd W, or the close button is used... """ if ( event.spontaneous() and get_settings().application.confirm_close_window and self._qt_viewer.viewer.layers and ConfirmCloseDialog(self, False).exec_() != QDialog.Accepted ): event.ignore() return if self._ev and self._ev.isRunning(): self._ev.quit() # Close any floating dockwidgets for dock in self.findChildren(QtViewerDockWidget): if isinstance(dock, QWidget) and dock.isFloating(): dock.setFloating(False) self._save_current_window_settings() # On some versions of Darwin, exiting while fullscreen seems to tickle # some bug deep in NSWindow. This forces the fullscreen keybinding # test to complete its draw cycle, then pop back out of fullscreen. if self.isFullScreen(): self.showNormal() for _ in range(5): time.sleep(0.1) QApplication.processEvents() self._qt_viewer.dims.stop() if self._quit_app: quit_app() event.accept() def restart(self): """Restart the napari application in a detached process.""" process = QProcess() process.setProgram(sys.executable) if not running_as_bundled_app(): process.setArguments(sys.argv) process.startDetached() self.close(quit_app=True) @staticmethod @Slot(Notification) def show_notification(notification: Notification): """Show notification coming from a thread.""" NapariQtNotification.show_notification(notification) class Window: """Application window that contains the menu bar and viewer. Parameters ---------- viewer : napari.components.ViewerModel Contained viewer widget. Attributes ---------- file_menu : qtpy.QtWidgets.QMenu File menu. help_menu : qtpy.QtWidgets.QMenu Help menu. main_menu : qtpy.QtWidgets.QMainWindow.menuBar Main menubar. view_menu : qtpy.QtWidgets.QMenu View menu. window_menu : qtpy.QtWidgets.QMenu Window menu. """ def __init__(self, viewer: 'Viewer', *, show: bool = True) -> None: # create QApplication if it doesn't already exist get_app() # Dictionary holding dock widgets self._dock_widgets: Dict[ str, QtViewerDockWidget ] = WeakValueDictionary() self._unnamed_dockwidget_count = 1 # Connect the Viewer and create the Main Window self._qt_window = _QtMainWindow(viewer, self) # connect theme events before collecting plugin-provided themes # to ensure icons from the plugins are generated correctly. _themes.events.added.connect(self._add_theme) _themes.events.removed.connect(self._remove_theme) # discover any themes provided by plugins plugin_manager.discover_themes() self._setup_existing_themes() self._add_menus() self._update_theme() get_settings().appearance.events.theme.connect(self._update_theme) self._add_viewer_dock_widget( self._qt_viewer.dockConsole, tabify=False, menu=self.window_menu ) self._add_viewer_dock_widget( self._qt_viewer.dockLayerControls, tabify=False, menu=self.window_menu, ) self._add_viewer_dock_widget( self._qt_viewer.dockLayerList, tabify=False, menu=self.window_menu ) if perf.USE_PERFMON: self._add_viewer_dock_widget( self._qt_viewer.dockPerformance, menu=self.window_menu ) viewer.events.help.connect(self._help_changed) viewer.events.title.connect(self._title_changed) viewer.events.theme.connect(self._update_theme) viewer.layers.events.connect(self.file_menu.update) viewer.events.status.connect(self._status_changed) if show: self.show() # Ensure the controls dock uses the minimum height self._qt_window.resizeDocks( [ self._qt_viewer.dockLayerControls, self._qt_viewer.dockLayerList, ], [self._qt_viewer.dockLayerControls.minimumHeight(), 10000], Qt.Orientation.Vertical, ) def _setup_existing_themes(self, connect: bool = True): """This function is only executed once at the startup of napari to connect events to themes that have not been connected yet. Parameters ---------- connect : bool Determines whether the `connect` or `disconnect` method should be used. """ for theme in _themes.values(): if connect: self._connect_theme(theme) else: self._disconnect_theme(theme) def _connect_theme(self, theme): # connect events to update theme. Here, we don't want to pass the event # since it won't have the right `value` attribute. theme.events.background.connect(self._update_theme_no_event) theme.events.foreground.connect(self._update_theme_no_event) theme.events.primary.connect(self._update_theme_no_event) theme.events.secondary.connect(self._update_theme_no_event) theme.events.highlight.connect(self._update_theme_no_event) theme.events.text.connect(self._update_theme_no_event) theme.events.warning.connect(self._update_theme_no_event) theme.events.current.connect(self._update_theme_no_event) theme.events.icon.connect(self._update_theme_no_event) theme.events.canvas.connect( lambda _: self._qt_viewer.canvas._set_theme_change( get_settings().appearance.theme ) ) # connect console-specific attributes only if QtConsole # is present. The `console` is called which might slow # things down a little. if self._qt_viewer._console: theme.events.console.connect(self._qt_viewer.console._update_theme) theme.events.syntax_style.connect( self._qt_viewer.console._update_theme ) def _disconnect_theme(self, theme): theme.events.background.disconnect(self._update_theme_no_event) theme.events.foreground.disconnect(self._update_theme_no_event) theme.events.primary.disconnect(self._update_theme_no_event) theme.events.secondary.disconnect(self._update_theme_no_event) theme.events.highlight.disconnect(self._update_theme_no_event) theme.events.text.disconnect(self._update_theme_no_event) theme.events.warning.disconnect(self._update_theme_no_event) theme.events.current.disconnect(self._update_theme_no_event) theme.events.icon.disconnect(self._update_theme_no_event) theme.events.canvas.disconnect( lambda _: self._qt_viewer.canvas._set_theme_change( get_settings().appearance.theme ) ) # disconnect console-specific attributes only if QtConsole # is present and they were previously connected if self._qt_viewer._console: theme.events.console.disconnect( self._qt_viewer.console._update_theme ) theme.events.syntax_style.disconnect( self._qt_viewer.console._update_theme ) def _add_theme(self, event): """Add new theme and connect events.""" theme = event.value self._connect_theme(theme) def _remove_theme(self, event): """Remove theme and disconnect events.""" theme = event.value self._disconnect_theme(theme) @property def qt_viewer(self): warnings.warn( trans._( 'Public access to Window.qt_viewer is deprecated and will be removed in\nv0.5.0. It is considered an "implementation detail" of the napari\napplication, not part of the napari viewer model. If your use case\nrequires access to qt_viewer, please open an issue to discuss.', deferred=True, ), category=FutureWarning, stacklevel=2, ) return self._qt_window._qt_viewer @property def _qt_viewer(self): # this is starting to be "vestigial"... this property could be removed return self._qt_window._qt_viewer @property def _status_bar(self): # TODO: remove from window return self._qt_window.statusBar() def _add_menus(self): """Add menubar to napari app.""" # TODO: move this to _QMainWindow... but then all of the Menu() # items will not have easy access to the methods on this Window obj. self.main_menu = self._qt_window.menuBar() # Menubar shortcuts are only active when the menubar is visible. # Therefore, we set a global shortcut not associated with the menubar # to toggle visibility, *but*, in order to not shadow the menubar # shortcut, we disable it, and only enable it when the menubar is # hidden. See this stackoverflow link for details: # https://stackoverflow.com/questions/50537642/how-to-keep-the-shortcuts-of-a-hidden-widget-in-pyqt5 self._main_menu_shortcut = QShortcut('Ctrl+M', self._qt_window) self._main_menu_shortcut.setEnabled(False) self._main_menu_shortcut.activated.connect( self._toggle_menubar_visible ) self.file_menu = menus.FileMenu(self) self.main_menu.addMenu(self.file_menu) self.view_menu = build_qmodel_menu( MenuId.MENUBAR_VIEW, title=trans._('&View'), parent=self._qt_window ) self.main_menu.addMenu(self.view_menu) self.plugins_menu = menus.PluginsMenu(self) self.main_menu.addMenu(self.plugins_menu) self.window_menu = menus.WindowMenu(self) self.main_menu.addMenu(self.window_menu) self.help_menu = build_qmodel_menu( MenuId.MENUBAR_HELP, title=trans._('&Help'), parent=self._qt_window ) self.main_menu.addMenu(self.help_menu) if perf.USE_PERFMON: self._debug_menu = menus.DebugMenu(self) self.main_menu.addMenu(self._debug_menu) def _toggle_menubar_visible(self): """Toggle visibility of app menubar. This function also disables or enables a global keyboard shortcut to show the menubar, since menubar shortcuts are only available while the menubar is visible. """ self.main_menu.setVisible(not self.main_menu.isVisible()) self._main_menu_shortcut.setEnabled(not self.main_menu.isVisible()) def _toggle_fullscreen(self): """Toggle fullscreen mode.""" if self._qt_window.isFullScreen(): self._qt_window.showNormal() else: self._qt_window.showFullScreen() def _toggle_play(self): """Toggle play.""" if self._qt_viewer.dims.is_playing: self._qt_viewer.dims.stop() else: axis = self._qt_viewer.viewer.dims.last_used or 0 self._qt_viewer.dims.play(axis) def add_plugin_dock_widget( self, plugin_name: str, widget_name: str = None, tabify: bool = False, ) -> Tuple[QtViewerDockWidget, Any]: """Add plugin dock widget if not already added. Parameters ---------- plugin_name : str Name of a plugin providing a widget widget_name : str, optional Name of a widget provided by `plugin_name`. If `None`, and the specified plugin provides only a single widget, that widget will be returned, otherwise a ValueError will be raised, by default None Returns ------- tuple A 2-tuple containing (the DockWidget instance, the plugin widget instance). """ from napari.plugins import _npe2 Widget = None dock_kwargs = {} if result := _npe2.get_widget_contribution(plugin_name, widget_name): Widget, widget_name = result if Widget is None: Widget, dock_kwargs = plugin_manager.get_widget( plugin_name, widget_name ) if not widget_name: # if widget_name wasn't provided, `get_widget` will have # ensured that there is a single widget available. widget_name = list(plugin_manager._dock_widgets[plugin_name])[0] full_name = plugin_menu_item_template.format(plugin_name, widget_name) if full_name in self._dock_widgets: dock_widget = self._dock_widgets[full_name] wdg = dock_widget.widget() if hasattr(wdg, '_magic_widget'): wdg = wdg._magic_widget return dock_widget, wdg wdg = _instantiate_dock_widget( Widget, cast('Viewer', self._qt_viewer.viewer) ) # Add dock widget dock_kwargs.pop('name', None) dock_widget = self.add_dock_widget( wdg, name=full_name, tabify=tabify, **dock_kwargs ) return dock_widget, wdg def _add_plugin_function_widget(self, plugin_name: str, widget_name: str): """Add plugin function widget if not already added. Parameters ---------- plugin_name : str Name of a plugin providing a widget widget_name : str, optional Name of a widget provided by `plugin_name`. If `None`, and the specified plugin provides only a single widget, that widget will be returned, otherwise a ValueError will be raised, by default None """ full_name = plugin_menu_item_template.format(plugin_name, widget_name) if full_name in self._dock_widgets: return func = plugin_manager._function_widgets[plugin_name][widget_name] # Add function widget return self.add_function_widget( func, name=full_name, area=None, allowed_areas=None ) def add_dock_widget( self, widget: Union[QWidget, 'Widget'], *, name: str = '', area: str = 'right', allowed_areas: Optional[Sequence[str]] = None, shortcut=_sentinel, add_vertical_stretch=True, tabify: bool = False, menu: Optional[QMenu] = None, ): """Convenience method to add a QDockWidget to the main window. If name is not provided a generic name will be addded to avoid `saveState` warnings on close. Parameters ---------- widget : QWidget `widget` will be added as QDockWidget's main widget. name : str, optional Name of dock widget to appear in window menu. area : str Side of the main window to which the new dock widget will be added. Must be in {'left', 'right', 'top', 'bottom'} allowed_areas : list[str], optional Areas, relative to main window, that the widget is allowed dock. Each item in list must be in {'left', 'right', 'top', 'bottom'} By default, all areas are allowed. shortcut : str, optional Keyboard shortcut to appear in dropdown menu. add_vertical_stretch : bool, optional Whether to add stretch to the bottom of vertical widgets (pushing widgets up towards the top of the allotted area, instead of letting them distribute across the vertical space). By default, True. .. deprecated:: 0.4.8 The shortcut parameter is deprecated since version 0.4.8, please use the action and shortcut manager APIs. The new action manager and shortcut API allow user configuration and localisation. tabify : bool Flag to tabify dockwidget or not. menu : QMenu, optional Menu bar to add toggle action to. If `None` nothing added to menu. Returns ------- dock_widget : QtViewerDockWidget `dock_widget` that can pass viewer events. """ if not name: with contextlib.suppress(AttributeError): name = widget.objectName() name = name or trans._( "Dock widget {number}", number=self._unnamed_dockwidget_count, ) self._unnamed_dockwidget_count += 1 if shortcut is not _sentinel: warnings.warn( _SHORTCUT_DEPRECATION_STRING.format(shortcut=shortcut), FutureWarning, stacklevel=2, ) dock_widget = QtViewerDockWidget( self._qt_viewer, widget, name=name, area=area, allowed_areas=allowed_areas, shortcut=shortcut, add_vertical_stretch=add_vertical_stretch, ) else: dock_widget = QtViewerDockWidget( self._qt_viewer, widget, name=name, area=area, allowed_areas=allowed_areas, add_vertical_stretch=add_vertical_stretch, ) self._add_viewer_dock_widget(dock_widget, tabify=tabify, menu=menu) if hasattr(widget, 'reset_choices'): # Keep the dropdown menus in the widget in sync with the layer model # if widget has a `reset_choices`, which is true for all magicgui # `CategoricalWidget`s layers_events = self._qt_viewer.viewer.layers.events layers_events.inserted.connect(widget.reset_choices) layers_events.removed.connect(widget.reset_choices) layers_events.reordered.connect(widget.reset_choices) # Add dock widget to dictionary self._dock_widgets[dock_widget.name] = dock_widget return dock_widget def _add_viewer_dock_widget( self, dock_widget: QtViewerDockWidget, tabify: bool = False, menu: Optional[QMenu] = None, ): """Add a QtViewerDockWidget to the main window If other widgets already present in area then will tabify. Parameters ---------- dock_widget : QtViewerDockWidget `dock_widget` will be added to the main window. tabify : bool Flag to tabify dockwidget or not. menu : QMenu, optional Menu bar to add toggle action to. If `None` nothing added to menu. """ # Find if any othe dock widgets are currently in area current_dws_in_area = [ dw for dw in self._qt_window.findChildren(QDockWidget) if self._qt_window.dockWidgetArea(dw) == dock_widget.qt_area ] self._qt_window.addDockWidget(dock_widget.qt_area, dock_widget) # If another dock widget present in area then tabify if current_dws_in_area: if tabify: self._qt_window.tabifyDockWidget( current_dws_in_area[-1], dock_widget ) dock_widget.show() dock_widget.raise_() elif dock_widget.area in ('right', 'left'): _wdg = current_dws_in_area + [dock_widget] # add sizes to push lower widgets up sizes = list(range(1, len(_wdg) * 4, 4)) self._qt_window.resizeDocks( _wdg, sizes, Qt.Orientation.Vertical ) if menu: action = dock_widget.toggleViewAction() action.setStatusTip(dock_widget.name) action.setText(dock_widget.name) import warnings with warnings.catch_warnings(): warnings.simplefilter("ignore", FutureWarning) # deprecating with 0.4.8, but let's try to keep compatibility. shortcut = dock_widget.shortcut if shortcut is not None: action.setShortcut(shortcut) menu.addAction(action) # see #3663, to fix #3624 more generally dock_widget.setFloating(False) def _remove_dock_widget(self, event=None): names = list(self._dock_widgets.keys()) for widget_name in names: if event.value in widget_name: # remove this widget widget = self._dock_widgets[widget_name] self.remove_dock_widget(widget) def remove_dock_widget(self, widget: QWidget, menu=None): """Removes specified dock widget. If a QDockWidget is not provided, the existing QDockWidgets will be searched for one whose inner widget (``.widget()``) is the provided ``widget``. Parameters ---------- widget : QWidget | str If widget == 'all', all docked widgets will be removed. """ if widget == 'all': for dw in list(self._dock_widgets.values()): self.remove_dock_widget(dw) return if not isinstance(widget, QDockWidget): dw: QDockWidget for dw in self._qt_window.findChildren(QDockWidget): if dw.widget() is widget: _dw: QDockWidget = dw break else: raise LookupError( trans._( "Could not find a dock widget containing: {widget}", deferred=True, widget=widget, ) ) else: _dw = widget if _dw.widget(): _dw.widget().setParent(None) self._qt_window.removeDockWidget(_dw) if menu is not None: menu.removeAction(_dw.toggleViewAction()) # Remove dock widget from dictionary self._dock_widgets.pop(_dw.name, None) # Deleting the dock widget means any references to it will no longer # work but it's not really useful anyway, since the inner widget has # been removed. and anyway: people should be using add_dock_widget # rather than directly using _add_viewer_dock_widget _dw.deleteLater() def add_function_widget( self, function, *, magic_kwargs=None, name: str = '', area=None, allowed_areas=None, shortcut=_sentinel, ): """Turn a function into a dock widget via magicgui. Parameters ---------- function : callable Function that you want to add. magic_kwargs : dict, optional Keyword arguments to :func:`magicgui.magicgui` that can be used to specify widget. name : str, optional Name of dock widget to appear in window menu. area : str, optional Side of the main window to which the new dock widget will be added. Must be in {'left', 'right', 'top', 'bottom'}. If not provided the default will be determined by the widget.layout, with 'vertical' layouts appearing on the right, otherwise on the bottom. allowed_areas : list[str], optional Areas, relative to main window, that the widget is allowed dock. Each item in list must be in {'left', 'right', 'top', 'bottom'} By default, only provided areas is allowed. shortcut : str, optional Keyboard shortcut to appear in dropdown menu. Returns ------- dock_widget : QtViewerDockWidget `dock_widget` that can pass viewer events. """ from magicgui import magicgui if magic_kwargs is None: magic_kwargs = { 'auto_call': False, 'call_button': "run", 'layout': 'vertical', } widget = magicgui(function, **magic_kwargs or {}) if area is None: area = 'right' if str(widget.layout) == 'vertical' else 'bottom' if allowed_areas is None: allowed_areas = [area] if shortcut is not _sentinel: return self.add_dock_widget( widget, name=name or function.__name__.replace('_', ' '), area=area, allowed_areas=allowed_areas, shortcut=shortcut, ) else: return self.add_dock_widget( widget, name=name or function.__name__.replace('_', ' '), area=area, allowed_areas=allowed_areas, ) def resize(self, width, height): """Resize the window. Parameters ---------- width : int Width in logical pixels. height : int Height in logical pixels. """ self._qt_window.resize(width, height) def set_geometry(self, left, top, width, height): """Set the geometry of the widget Parameters ---------- left : int X coordinate of the upper left border. top : int Y coordinate of the upper left border. width : int Width of the rectangle shape of the window. height : int Height of the rectangle shape of the window. """ self._qt_window.setGeometry(left, top, width, height) def geometry(self) -> Tuple[int, int, int, int]: """Get the geometry of the widget Returns ------- left : int X coordinate of the upper left border. top : int Y coordinate of the upper left border. width : int Width of the rectangle shape of the window. height : int Height of the rectangle shape of the window. """ rect = self._qt_window.geometry() return rect.left(), rect.top(), rect.width(), rect.height() def show(self, *, block=False): """Resize, show, and bring forward the window. Raises ------ RuntimeError If the viewer.window has already been closed and deleted. """ settings = get_settings() try: self._qt_window.show(block=block) except (AttributeError, RuntimeError) as e: raise RuntimeError( trans._( "This viewer has already been closed and deleted. Please create a new one.", deferred=True, ) ) from e if settings.application.first_time: settings.application.first_time = False try: self._qt_window.resize(self._qt_window.layout().sizeHint()) except (AttributeError, RuntimeError) as e: raise RuntimeError( trans._( "This viewer has already been closed and deleted. Please create a new one.", deferred=True, ) ) from e else: try: if settings.application.save_window_geometry: self._qt_window._set_window_settings( *self._qt_window._load_window_settings() ) except Exception as err: # noqa: BLE001 import warnings warnings.warn( trans._( "The window geometry settings could not be loaded due to the following error: {err}", deferred=True, err=err, ), category=RuntimeWarning, stacklevel=2, ) # Resize axis labels now that window is shown self._qt_viewer.dims._resize_axis_labels() # We want to bring the viewer to the front when # A) it is our own event loop OR we are running in jupyter # B) it is not the first time a QMainWindow is being created # `app_name` will be "napari" iff the application was instantiated in # get_app(). isActiveWindow() will be True if it is the second time a # _qt_window has been created. # See #721, #732, #735, #795, #1594 app_name = QApplication.instance().applicationName() if ( app_name == 'napari' or in_jupyter() ) and self._qt_window.isActiveWindow(): self.activate() def activate(self): """Make the viewer the currently active window.""" self._qt_window.raise_() # for macOS self._qt_window.activateWindow() # for Windows def _update_theme_no_event(self): self._update_theme() def _update_theme(self, event=None): """Update widget color theme.""" settings = get_settings() with contextlib.suppress(AttributeError, RuntimeError): value = event.value if event else settings.appearance.theme self._qt_viewer.viewer.theme = value if value == "system": # system isn't a theme, so get the name and set style sheet actual_theme_name = get_system_theme() self._qt_window.setStyleSheet( get_stylesheet(actual_theme_name) ) else: self._qt_window.setStyleSheet(get_stylesheet(value)) def _status_changed(self, event): """Update status bar. Parameters ---------- event : napari.utils.event.Event The napari event that triggered this method. """ if isinstance(event.value, str): self._status_bar.setStatusText(event.value) else: status_info = event.value self._status_bar.setStatusText( layer_base=status_info['layer_base'], source_type=status_info['source_type'], plugin=status_info['plugin'], coordinates=status_info['coordinates'], ) def _title_changed(self, event): """Update window title. Parameters ---------- event : napari.utils.event.Event The napari event that triggered this method. """ self._qt_window.setWindowTitle(event.value) def _help_changed(self, event): """Update help message on status bar. Parameters ---------- event : napari.utils.event.Event The napari event that triggered this method. """ self._status_bar.setHelpText(event.value) def _restart(self): """Restart the napari application.""" self._qt_window.restart() def _screenshot( self, size=None, scale=None, flash=True, canvas_only=False ) -> 'QImage': """Capture screenshot of the currently displayed viewer. Parameters ---------- flash : bool Flag to indicate whether flash animation should be shown after the screenshot was captured. size : tuple (int, int) Size (resolution) of the screenshot. By default, the currently displayed size. Only used if `canvas_only` is True. scale : float Scale factor used to increase resolution of canvas for the screenshot. By default, the currently displayed resolution. Only used if `canvas_only` is True. canvas_only : bool If True, screenshot shows only the image display canvas, and if False include the napari viewer frame in the screenshot, By default, True. Returns ------- img : QImage """ from napari._qt.utils import add_flash_animation if canvas_only: canvas = self._qt_viewer.canvas prev_size = canvas.size if size is not None: if len(size) != 2: raise ValueError( trans._( 'screenshot size must be 2 values, got {len_size}', len_size=len(size), ) ) # Scale the requested size to account for HiDPI size = tuple( int(dim / self._qt_window.devicePixelRatio()) for dim in size ) canvas.size = size[::-1] # invert x ad y for vispy if scale is not None: # multiply canvas dimensions by the scale factor to get new size canvas.size = tuple(int(dim * scale) for dim in canvas.size) try: img = self._qt_viewer.canvas.native.grabFramebuffer() if flash: add_flash_animation(self._qt_viewer._welcome_widget) finally: # make sure we always go back to the right canvas size if size is not None or scale is not None: canvas.size = prev_size else: img = self._qt_window.grab().toImage() if flash: add_flash_animation(self._qt_window) return img def screenshot( self, path=None, size=None, scale=None, flash=True, canvas_only=False ): """Take currently displayed viewer and convert to an image array. Parameters ---------- path : str Filename for saving screenshot image. size : tuple (int, int) Size (resolution) of the screenshot. By default, the currently displayed size. Only used if `canvas_only` is True. scale : float Scale factor used to increase resolution of canvas for the screenshot. By default, the currently displayed resolution. Only used if `canvas_only` is True. flash : bool Flag to indicate whether flash animation should be shown after the screenshot was captured. canvas_only : bool If True, screenshot shows only the image display canvas, and if False include the napari viewer frame in the screenshot, By default, True. Returns ------- image : array Numpy array of type ubyte and shape (h, w, 4). Index [0, 0] is the upper-left corner of the rendered region. """ img = QImg2array(self._screenshot(size, scale, flash, canvas_only)) if path is not None: imsave(path, img) # scikit-image imsave method return img def clipboard(self, flash=True, canvas_only=False): """Copy screenshot of current viewer to the clipboard. Parameters ---------- flash : bool Flag to indicate whether flash animation should be shown after the screenshot was captured. canvas_only : bool If True, screenshot shows only the image display canvas, and if False include the napari viewer frame in the screenshot, By default, True. """ img = self._screenshot(flash=flash, canvas_only=canvas_only) QApplication.clipboard().setImage(img) def _teardown(self): """Carry out various teardown tasks such as event disconnection.""" self._setup_existing_themes(False) _themes.events.added.disconnect(self._add_theme) _themes.events.removed.disconnect(self._remove_theme) self._qt_viewer.viewer.layers.events.disconnect(self.file_menu.update) for menu in self.file_menu._INSTANCES: with contextlib.suppress(RuntimeError): menu._destroy() def close(self): """Close the viewer window and cleanup sub-widgets.""" # Someone is closing us twice? Only try to delete self._qt_window # if we still have one. if hasattr(self, '_qt_window'): self._teardown() self._qt_viewer.close() self._qt_window.close() del self._qt_window def _instantiate_dock_widget(wdg_cls, viewer: 'Viewer'): # if the signature is looking a for a napari viewer, pass it. from napari.viewer import Viewer kwargs = {} try: sig = inspect.signature(wdg_cls.__init__) except ValueError: pass else: for param in sig.parameters.values(): if param.name == 'napari_viewer': kwargs['napari_viewer'] = PublicOnlyProxy(viewer) break if param.annotation in ('napari.viewer.Viewer', Viewer): kwargs[param.name] = PublicOnlyProxy(viewer) break # cannot look for param.kind == param.VAR_KEYWORD because # QWidget allows **kwargs but errs on unknown keyword arguments # instantiate the widget return wdg_cls(**kwargs) napari-0.5.0a1/napari/_qt/qt_resources/000077500000000000000000000000001437041365600177745ustar00rootroot00000000000000napari-0.5.0a1/napari/_qt/qt_resources/__init__.py000066400000000000000000000037451437041365600221160ustar00rootroot00000000000000from pathlib import Path from typing import List, Optional from napari._qt.qt_resources._svg import QColoredSVGIcon from napari.settings import get_settings __all__ = ['get_stylesheet', 'QColoredSVGIcon'] STYLE_PATH = (Path(__file__).parent / 'styles').resolve() STYLES = {x.stem: str(x) for x in STYLE_PATH.iterdir() if x.suffix == '.qss'} def get_stylesheet( theme_id: str = None, extra: Optional[List[str]] = None ) -> str: """Combine all qss files into single, possibly pre-themed, style string. Parameters ---------- theme : str, optional Theme to apply to the stylesheet. If no theme is provided, the returned stylesheet will still have ``{{ template_variables }}`` that need to be replaced using the :func:`napari.utils.theme.template` function prior to using the stylesheet. extra : list of str, optional Additional paths to QSS files to include in stylesheet, by default None Returns ------- css : str The combined stylesheet. """ stylesheet = '' for key in sorted(STYLES.keys()): file = STYLES[key] with open(file) as f: stylesheet += f.read() if extra: for file in extra: with open(file) as f: stylesheet += f.read() if theme_id: from napari.utils.theme import get_theme, template return template(stylesheet, **get_theme(theme_id, as_dict=True)) return stylesheet def get_current_stylesheet(extra: Optional[List[str]] = None) -> str: """ Return the current stylesheet base on settings. This is wrapper around :py:func:`get_stylesheet` that takes the current theme base on settings. Parameters ---------- extra : list of str, optional Additional paths to QSS files to include in stylesheet, by default None Returns ------- css : str The combined stylesheet. """ settings = get_settings() return get_stylesheet(settings.appearance.theme, extra) napari-0.5.0a1/napari/_qt/qt_resources/_svg.py000066400000000000000000000114611437041365600213070ustar00rootroot00000000000000""" A Class for generating QIcons from SVGs with arbitrary colors at runtime. """ from typing import Optional, Union from qtpy.QtCore import QByteArray, QPoint, QRect, QRectF, Qt from qtpy.QtGui import QIcon, QIconEngine, QImage, QPainter, QPixmap from qtpy.QtSvg import QSvgRenderer class QColoredSVGIcon(QIcon): """A QIcon class that specializes in colorizing SVG files. Parameters ---------- path_or_xml : str Raw SVG XML or a path to an existing svg file. (Will raise error on ``__init__`` if a non-existent file is provided.) color : str, optional A valid CSS color string, used to colorize the SVG. by default None. opacity : float, optional Fill opacity for the icon (0-1). By default 1 (opaque). Examples -------- >>> from napari._qt.qt_resources import QColoredSVGIcon >>> from qtpy.QtWidgets import QLabel # Create icon with specific color >>> label = QLabel() >>> icon = QColoredSVGIcon.from_resources('new_points') >>> label.setPixmap(icon.colored('#0934e2', opacity=0.7).pixmap(300, 300)) >>> label.show() # Create colored icon using theme >>> label = QLabel() >>> icon = QColoredSVGIcon.from_resources('new_points') >>> label.setPixmap(icon.colored(theme='light').pixmap(300, 300)) >>> label.show() """ def __init__( self, path_or_xml: str, color: Optional[str] = None, opacity: float = 1.0, ) -> None: from napari.resources import get_colorized_svg self._svg = path_or_xml colorized = get_colorized_svg(path_or_xml, color, opacity) super().__init__(SVGBufferIconEngine(colorized)) def colored( self, color: Optional[str] = None, opacity: float = 1.0, theme: Optional[str] = None, theme_key: str = 'icon', ) -> 'QColoredSVGIcon': """Return a new colorized QIcon instance. Parameters ---------- color : str, optional A valid CSS color string, used to colorize the SVG. If provided, will take precedence over ``theme``, by default None. opacity : float, optional Fill opacity for the icon (0-1). By default 1 (opaque). theme : str, optional Name of the theme to from which to get `theme_key` color. ``color`` argument takes precedence. theme_key : str, optional If using a theme, key in the theme dict to use, by default 'icon' Returns ------- QColoredSVGIcon A pre-colored QColoredSVGIcon (which may still be recolored) """ if not color and theme: from napari.utils.theme import get_theme color = getattr(get_theme(theme, False), theme_key).as_hex() return QColoredSVGIcon(self._svg, color, opacity) @staticmethod def from_resources( icon_name: str, ) -> 'QColoredSVGIcon': """Get an icon from napari SVG resources. Parameters ---------- icon_name : str The name of the icon svg to load (just the stem). Must be in the napari icons folder. Returns ------- QColoredSVGIcon A colorizeable QIcon """ from napari.resources import get_icon_path path = get_icon_path(icon_name) return QColoredSVGIcon(path) class SVGBufferIconEngine(QIconEngine): """A custom QIconEngine that can render an SVG buffer. An icon engine provides the rendering functions for a ``QIcon``. Each icon has a corresponding icon engine that is responsible for drawing the icon with a requested size, mode and state. While the built-in QIconEngine is capable of rendering SVG files, it's not able to receive the raw XML string from memory. This ``QIconEngine`` takes in SVG data as a raw xml string or bytes. see: https://doc.qt.io/qt-5/qiconengine.html """ def __init__(self, xml: Union[str, bytes]) -> None: if isinstance(xml, str): xml = xml.encode('utf-8') self.data = QByteArray(xml) super().__init__() def paint(self, painter: QPainter, rect, mode, state): """Paint the icon int ``rect`` using ``painter``.""" renderer = QSvgRenderer(self.data) renderer.render(painter, QRectF(rect)) def clone(self): """Required to subclass abstract QIconEngine.""" return SVGBufferIconEngine(self.data) def pixmap(self, size, mode, state): """Return the icon as a pixmap with requested size, mode, and state.""" img = QImage(size, QImage.Format_ARGB32) img.fill(Qt.transparent) pixmap = QPixmap.fromImage(img, Qt.NoFormatConversion) painter = QPainter(pixmap) self.paint(painter, QRect(QPoint(0, 0), size), mode, state) return pixmap napari-0.5.0a1/napari/_qt/qt_resources/_tests/000077500000000000000000000000001437041365600212755ustar00rootroot00000000000000napari-0.5.0a1/napari/_qt/qt_resources/_tests/test_icons.py000066400000000000000000000005701437041365600240230ustar00rootroot00000000000000import shutil from napari.resources._icons import ICON_PATH, ICONS from napari.utils.misc import dir_hash, paths_hash def test_icon_hash_equality(): if (_themes := ICON_PATH / '_themes').exists(): shutil.rmtree(_themes) dir_hash_result = dir_hash(ICON_PATH) paths_hash_result = paths_hash(ICONS.values()) assert dir_hash_result == paths_hash_result napari-0.5.0a1/napari/_qt/qt_resources/_tests/test_svg.py000066400000000000000000000012561437041365600235110ustar00rootroot00000000000000from qtpy.QtGui import QIcon from napari._qt.qt_resources import QColoredSVGIcon def test_colored_svg(qtbot): """Test that we can create a colored icon with certain color.""" icon = QColoredSVGIcon.from_resources('new_points') assert isinstance(icon, QIcon) assert isinstance(icon.colored('#0934e2', opacity=0.4), QColoredSVGIcon) assert icon.pixmap(250, 250) def test_colored_svg_from_theme(qtbot): """Test that we can create a colored icon using a theme name.""" icon = QColoredSVGIcon.from_resources('new_points') assert isinstance(icon, QIcon) assert isinstance(icon.colored(theme='light'), QColoredSVGIcon) assert icon.pixmap(250, 250) napari-0.5.0a1/napari/_qt/qt_resources/styles/000077500000000000000000000000001437041365600213175ustar00rootroot00000000000000napari-0.5.0a1/napari/_qt/qt_resources/styles/00_base.qss000066400000000000000000000325061437041365600232660ustar00rootroot00000000000000/* Styles in this file should only refer to built-in QtWidgets It will be imported first, and styles declared in other files may override these styles, but should only do so on custom subclasses, object names, or properties. might be possible to convert px to em by 1px = 0.0625em */ /* ----------------- QWidget ------------------ */ /* mappings between property and QPalette.ColorRole: these colors can be looked up dynamically in widgets using, e.g ``widget.palette().color(QPalette.Window)`` background -> QPalette.Window/QPalette.Background background-color -> QPalette.Window/QPalette.Background color -> QPalette.WindowText/QPalette.Foreground selection-color -> QPalette.HighlightedText selection-background-color -> QPalette.Highlight alternate-background-color -> QPalette.AlternateBase */ QWidget { background-color: {{ background }}; border: 0px; padding: 1px; margin: 0px; color: {{ text }}; selection-background-color: {{ secondary }}; selection-color: {{ text }}; } QWidget[emphasized="true"] { background-color: {{ foreground }}; } QWidget[emphasized="true"] > QFrame { background-color: {{ foreground }}; } /* ------------ QAbstractScrollArea ------------- */ /* QAbstractScrollArea is the superclass */ QTextEdit { background-color: {{ console }}; background-clip: padding; color: {{ text }}; selection-background-color: {{ foreground }}; padding: 4px 2px 4px 4px; } /* the area behind the scrollbar */ QTextEdit > QWidget { background-color: {{ console }}; } /* ----------------- QPushButton ------------------ */ QPushButton { background-color: {{ foreground }}; border-radius: 2px; padding: 4px; border: 0px; } QPushButton:hover { background-color: {{ primary }}; } QPushButton:pressed { background-color: {{ highlight }}; } QPushButton:checked { background-color: {{ highlight }}; } QPushButton:disabled { background-color: {{ opacity(foreground, 75) }}; border: 1px solid; border-color: {{ foreground }}; color: {{ opacity(text, 90) }}; } QWidget[emphasized="true"] QPushButton { background-color: {{ primary }}; } QWidget[emphasized="true"] QPushButton:disabled { background-color: {{ darken(foreground, 20) }}; } QWidget[emphasized="true"] QPushButton:hover { background-color: {{ highlight }}; } QWidget[emphasized="true"] QPushButton:pressed { background-color: {{ secondary }}; } QWidget[emphasized="true"] QPushButton:checked { background-color: {{ current }}; } /* ----------------- QComboBox ------------------ */ QComboBox { border-radius: 2px; background-color: {{ foreground }}; padding: 3px 10px 3px 8px; /* top right bottom left */ } QComboBox:disabled { background-color: {{ opacity(foreground, 75) }}; border: 1px solid; border-color: {{ foreground }}; color: {{ opacity(text, 90) }}; } QWidget[emphasized="true"] QComboBox { background-color: {{ primary }}; } QComboBox::drop-down { width: 26px; border-top-right-radius: 2px; border-bottom-right-radius: 2px; } QComboBox::down-arrow { image: url("theme_{{ id }}:/drop_down_50.svg"); width: 14px; height: 14px; } QComboBox::down-arrow:on { /* when the dropdown is open */ } QComboBox:on { border-radius: 0px; } QListView { /* controls the color of the open dropdown menu */ background-color: {{ foreground }}; color: {{ text }}; border-radius: 2px; } QListView:item:selected { background-color: {{ highlight }}; } QWidget[emphasized="true"] QComboBox { background-color: {{ primary }}; } /* ----------------- QLineEdit ------------------ */ QLineEdit { background-color: {{ darken(background, 15) }}; color: {{ text }}; min-height: 20px; padding: 2px; border-radius: 2px; } QWidget[emphasized="true"] QLineEdit { background-color: {{ background }}; } /* ----------------- QAbstractSpinBox ------------------ */ QAbstractSpinBox { background-color: {{ foreground }}; border: none; padding: 1px 10px; min-width: 70px; min-height: 18px; border-radius: 2px; } QLabeledSlider > QAbstractSpinBox { min-width: 10px; padding: 0px; } QWidget[emphasized="true"] QAbstractSpinBox { background-color: {{ primary }}; } QAbstractSpinBox::up-button, QAbstractSpinBox::down-button { subcontrol-origin: margin; width: 20px; height: 20px; } QAbstractSpinBox::up-button:hover, QAbstractSpinBox::down-button:hover { background-color: {{ primary }}; } QWidget[emphasized="true"] QAbstractSpinBox::up-button:hover, QWidget[emphasized="true"] QAbstractSpinBox::down-button:hover { background-color: {{ highlight }}; } QAbstractSpinBox::up-button:pressed, QAbstractSpinBox::down-button:pressed { background-color: {{ highlight }}; } QWidget[emphasized="true"] QAbstractSpinBox::up-button:pressed, QWidget[emphasized="true"] QAbstractSpinBox::down-button:pressed { background-color: {{ lighten(highlight, 15) }}; } QAbstractSpinBox::up-button { subcontrol-position: center right; right: 0px; border-top-right-radius: 2px; border-bottom-right-radius: 2px; } QAbstractSpinBox::down-button { subcontrol-position: center left; left: 0px; border-top-left-radius: 2px; border-bottom-left-radius: 2px; } QAbstractSpinBox::up-arrow, QAbstractSpinBox::down-arrow { width: 10px; height: 10px; } QAbstractSpinBox::up-arrow { image: url("theme_{{ id }}:/plus_50.svg"); } QAbstractSpinBox::down-arrow { image: url("theme_{{ id }}:/minus_50.svg"); } QLabeledRangeSlider > QAbstractSpinBox { min-width: 5px; } /* ----------------- QCheckBox ------------------ */ QCheckBox { spacing: 5px; color: {{ text }}; background-color: none; } QCheckBox::indicator { width: 16px; height: 16px; background-color: {{ foreground }}; border: 0px; padding: 1px; border-radius: 2px } QCheckBox::indicator:hover { background-color: {{ lighten(foreground, 5) }}; } QCheckBox::indicator:unchecked { image: none; } QCheckBox::indicator:checked { image: url("theme_{{ id }}:/check.svg"); } QCheckBox::indicator:indeterminate { image: url("theme_{{ id }}:/minus.svg"); padding: 2px; width: 14px; height: 14px; } QWidget[emphasized="true"] QCheckBox::indicator { background-color: {{ primary }}; border-color: {{ primary }}; } QWidget[emphasized="true"] QCheckBox::indicator:hover { background-color: {{ lighten(primary, 5) }}; } QWidget[emphasized="true"] QCheckBox::indicator:unchecked:hover { background-color: {{ lighten(primary, 5) }}; border-color: {{ lighten(primary, 5) }}; } /* ----------------- QRadioButton ------------------ */ QRadioButton { background: none; } QRadioButton::indicator{ height: 16px; width: 16px; border-radius: 8px; } QRadioButton::indicator::unchecked { background: {{ foreground }}; } QRadioButton::indicator:unchecked:hover { background: {{ lighten(foreground, 5) }}; } QRadioButton::indicator::checked { background: {{ highlight }}; } QRadioButton::indicator::checked { image: url("theme_{{ id }}:/circle.svg"); height: 6px; width: 6px; padding: 5px; } QWidget[emphasized="true"] > QRadioButton { background: {{ foreground }}; } QWidget[emphasized="true"] > QRadioButton::indicator::unchecked { background-color: {{ primary }}; } QWidget[emphasized="true"] > QRadioButton:disabled { background-color: {{ foreground }}; } QWidget[emphasized="true"] > QRadioButton::indicator:checked { background-color: {{ secondary }}; } QWidget[emphasized="true"] > QRadioButton::indicator:unchecked:hover { background: {{ lighten(primary, 5) }}; } /* ----------------- QSlider ------------------ */ QSlider { background-color: none; } QSlider::groove:horizontal { border: 0px; background-color: {{ foreground }}; height: 6px; border-radius: 2px; } QSlider::handle:horizontal { background-color: {{ highlight }}; border: 0px; width: 16px; margin-top: -5px; margin-bottom: -5px; border-radius: 8px; } QSlider::handle:hover { background-color: {{ secondary }}; } QSlider::sub-page:horizontal { background: {{ primary }}; border-radius: 2px; } QWidget[emphasized="true"] QSlider::groove:horizontal { background: {{ primary }}; } QWidget[emphasized="true"] QSlider::handle:horizontal { background: {{ secondary }}; } QWidget[emphasized="true"] QSlider::sub-page:horizontal { background: {{ highlight }}; } QWidget[emphasized="true"] QSlider::handle:hover { background-color: {{ lighten(secondary, 5) }}; } QRangeSlider { qproperty-barColor: {{ primary }}; } QWidget[emphasized="true"] QRangeSlider { qproperty-barColor: {{ highlight }}; } /* ----------------- QScrollBar ------------------ */ QScrollBar { border: none; border-radius: 2px; background: {{ foreground }}; } QWidget[emphasized="true"] QScrollBar { background: {{ primary }}; } QScrollBar:horizontal { min-height: 13px; max-height: 13px; margin: 0px 16px; } QScrollBar:vertical { max-width: 13px; margin: 16px 0px; } QScrollBar::handle { background: {{ highlight }}; border-radius: 2px; } QWidget[emphasized="true"] QScrollBar::handle { background: {{ secondary }}; } QScrollBar::handle:horizontal { min-width: 26px; } QScrollBar::handle:vertical { min-height: 26px; } QScrollBar::add-line, QScrollBar::sub-line { border: none; border-radius: 2px; background: {{ foreground }}; subcontrol-origin: margin; } QWidget[emphasized="true"] QScrollBar::add-line, QWidget[emphasized="true"] QScrollBar::sub-line { background: {{ primary }}; } QScrollBar::add-line:horizontal { width: 13px; subcontrol-position: right; } QScrollBar::sub-line:horizontal { width: 13px; subcontrol-position: left; } QScrollBar::add-line:vertical { height: 13px; subcontrol-position: bottom; } QScrollBar::sub-line:vertical { height: 13px; subcontrol-position: top; } QScrollBar::add-line:horizontal:pressed, QScrollBar::sub-line:horizontal:pressed { background: {{ highlight }}; } QWidget[emphasized="true"] QScrollBar::add-line:horizontal:pressed, QWidget[emphasized="true"] QScrollBar::sub-line:horizontal:pressed { background: {{ secondary }}; } QScrollBar:left-arrow:horizontal { image: url("theme_{{ id }}:/left_arrow.svg"); } QScrollBar::right-arrow:horizontal { image: url("theme_{{ id }}:/right_arrow.svg"); } QScrollBar:up-arrow:vertical { image: url("theme_{{ id }}:/up_arrow.svg"); } QScrollBar::down-arrow:vertical { image: url("theme_{{ id }}:/down_arrow.svg"); } QScrollBar::left-arrow, QScrollBar::right-arrow, QScrollBar::up-arrow, QScrollBar::down-arrow { min-height: 13px; min-width: 13px; max-height: 13px; max-width: 13px; padding: 1px 2px; margin: 0; border: 0; border-radius: 2px; background: {{ foreground }}; } QScrollBar::left-arrow:hover, QScrollBar::right-arrow:hover, QScrollBar::up-arrow:hover, QScrollBar::down-arrow:hover { background-color: {{ primary }}; } QScrollBar::left-arrow:pressed, QScrollBar::right-arrow:pressed, QScrollBar::up-arrow:pressed, QScrollBar::down-arrow:pressed { background-color: {{ highlight }}; } QScrollBar::add-page, QScrollBar::sub-page { background: none; } /* ----------------- QProgressBar ------------------ */ QProgressBar { border: 1px solid {{ foreground }}; border-radius: 2px; text-align: center; padding: 0px; } QProgressBar::horizontal { height: 18px; } QProgressBar::vertical { width: 18px; } QProgressBar::chunk { width: 1px; background-color: vgradient({{ highlight }} - {{ foreground }}); } /* ----------------- QToolTip ------------------ */ QToolTip { border: 1px solid {{ foreground }}; border-radius: 2px; padding: 2px; background-color: {{ background }}; color: {{ text }}; } /* ----------------- QGroupBox ------------------ */ QGroupBox { background-color: {{ background }}; border: 1px solid {{ foreground }}; border-radius: 5px; margin-top: 1ex; /* leave space at the top for the title */ } QGroupBox::title { subcontrol-origin: margin; subcontrol-position: top left; left: 10px; padding: 0 3px; background-color: {{ background }}; } /* ----------------- QTabWidget ------------------ */ /* The tab widget frame */ QTabWidget::pane { border: 1px solid {{ darken(foreground, 10) }}; border-radius: 2px; } QWidget[emphasized="true"] QTabWidget::pane { border: 1px solid {{ darken(primary, 10) }}; } QTabBar::tab { background-color: {{ foreground }}; border: 1px solid {{ background }}; border-bottom: 0px; border-top-left-radius: 4px; border-top-right-radius: 4px; padding: 3px 6px; background: vgradient({{ lighten(foreground, 15) }} - {{ foreground }}); } QWidget[emphasized="true"] QTabBar::tab { background-color: {{ primary }}; border: 1px solid {{ foreground }}; background: vgradient({{ lighten(primary, 15) }} - {{ primary }}); } QTabBar::tab:selected { background: vgradient({{ lighten(highlight, 15) }} - {{ highlight }}); } QWidget[emphasized="true"] QTabBar::tab:selected { background: vgradient({{ lighten(secondary, 15) }} - {{ secondary }}); } /* ----------------- QLabel ------------------ */ QLabel { background-color: none; } /* ----------------- QMenuBar ------------------ */ QMenuBar::item:selected { background-color: {{ secondary }}; } QLCDNumber { background: none; } /* ----------------- QStatusBar ------------------ */ QStatusBar::item{ border: None; } /* ----------------- QHeaderView ----------------- */ QHeaderView::section { background-color: {{ background }}; padding: 2px; } napari-0.5.0a1/napari/_qt/qt_resources/styles/01_buttons.qss000066400000000000000000000116511437041365600240510ustar00rootroot00000000000000 /* ----------------- Buttons -------------------- */ QtDeleteButton { image: url("theme_{{ id }}:/delete.svg"); min-width : 28px; max-width : 28px; min-height : 28px; max-height : 28px; padding: 0px; } QtViewerPushButton{ min-width : 28px; max-width : 28px; min-height : 28px; max-height : 28px; padding: 0px; } QtViewerPushButton[mode="new_points"] { image: url("theme_{{ id }}:/new_points.svg"); } QtViewerPushButton[mode="new_shapes"] { image: url("theme_{{ id }}:/new_shapes.svg"); } QtViewerPushButton[mode="warning"] { image: url("theme_{{ id }}:/warning.svg"); } QtViewerPushButton[mode="new_labels"] { image: url("theme_{{ id }}:/new_labels.svg"); } QtViewerPushButton[mode="console"] { image: url("theme_{{ id }}:/console.svg"); } QtViewerPushButton[mode="roll"] { image: url("theme_{{ id }}:/roll.svg"); } QtViewerPushButton[mode="transpose"] { image: url("theme_{{ id }}:/transpose.svg"); } QtViewerPushButton[mode="home"] { image: url("theme_{{ id }}:/home.svg"); } QtViewerPushButton[mode="ndisplay_button"]:checked { image: url("theme_{{ id }}:/3D.svg"); } QtViewerPushButton[mode="ndisplay_button"] { image: url("theme_{{ id }}:/2D.svg"); } QtViewerPushButton[mode="grid_view_button"]:checked { image: url("theme_{{ id }}:/square.svg"); } QtViewerPushButton[mode="grid_view_button"] { image: url("theme_{{ id }}:/grid.svg"); } QtModeRadioButton { min-height : 28px; padding: 0px; } QtModeRadioButton::indicator:unchecked { border-radius: 3px; width: 28px; height: 28px; padding: 0; background-color: {{ primary }}; } QtModeRadioButton::indicator:checked { border-radius: 3px; height: 28px; width: 28px; padding: 0; background-color: {{ current }}; } QtModeRadioButton::indicator:disabled { background-color: {{ darken(foreground, 20) }} } QtModeRadioButton::indicator:unchecked:hover { background-color: {{ highlight }}; } QtModeRadioButton[mode="zoom"]::indicator { image: url("theme_{{ id }}:/zoom.svg"); } QtModeRadioButton[mode="select"]::indicator { image: url("theme_{{ id }}:/select.svg"); } QtModeRadioButton[mode="direct"]::indicator { image: url("theme_{{ id }}:/direct.svg"); } QtModeRadioButton[mode="rectangle"]::indicator { image: url("theme_{{ id }}:/rectangle.svg"); } QtModeRadioButton[mode="ellipse"]::indicator { image: url("theme_{{ id }}:/ellipse.svg"); color: red; } QtModeRadioButton[mode="line"]::indicator { image: url("theme_{{ id }}:/line.svg"); } QtModeRadioButton[mode="path"]::indicator { image: url("theme_{{ id }}:/path.svg"); } QtModeRadioButton[mode="polygon"]::indicator { image: url("theme_{{ id }}:/polygon.svg"); } QtModeRadioButton[mode="vertex_insert"]::indicator { image: url("theme_{{ id }}:/vertex_insert.svg"); } QtModeRadioButton[mode="vertex_remove"]::indicator { image: url("theme_{{ id }}:/vertex_remove.svg"); } QtModeRadioButton[mode="paint"]::indicator { image: url("theme_{{ id }}:/paint.svg"); } QtModeRadioButton[mode="fill"]::indicator { image: url("theme_{{ id }}:/fill.svg"); } QtModeRadioButton[mode="picker"]::indicator { image: url("theme_{{ id }}:/picker.svg"); } QtModeRadioButton[mode="erase"]::indicator { image: url("theme_{{ id }}:/erase.svg"); } QtModeRadioButton[mode="pan_zoom"]::indicator { image: url("theme_{{ id }}:/zoom.svg"); } QtModeRadioButton[mode="select_points"]::indicator { image: url("theme_{{ id }}:/select.svg"); } QtModeRadioButton[mode="add_points"]::indicator { image: url("theme_{{ id }}:/add.svg"); } QtModePushButton[mode="shuffle"] { image: url("theme_{{ id }}:/shuffle.svg"); } QtModePushButton[mode="move_back"] { image: url("theme_{{ id }}:/move_back.svg"); } QtModePushButton[mode="move_front"] { image: url("theme_{{ id }}:/move_front.svg"); } QtModePushButton[mode="delete_shape"] { image: url("theme_{{ id }}:/delete_shape.svg"); } QWidget[emphasized="true"] QtModePushButton[mode="delete_shape"]:pressed { background-color: {{ error }}; } QtCopyToClipboardButton { background-color: {{ background }}; margin: 0px; padding: 1px 1px 3px 2px; border: 0px; min-width: 18px; max-width: 18px; min-height: 18px; max-height: 18px; border-radius: 3px; } #QtCopyToClipboardButton { image: url("theme_{{ id }}:/copy_to_clipboard.svg"); } QtPlayButton { border-radius: 2px; height: 11px; width: 11px; margin: 0px 2px; padding: 2px; border: 0px; } QtPlayButton[reverse=True] { image: url("theme_{{ id }}:/left_arrow.svg"); } QtPlayButton[reverse=False] { background: {{ foreground }}; image: url("theme_{{ id }}:/right_arrow.svg"); } QtPlayButton[reverse=True]:hover, QtPlayButton[reverse=False]:hover { background: {{ primary }}; } QtPlayButton[playing=True]:hover { background-color: {{ lighten(error, 10) }}; } QtPlayButton[playing=True] { image: url("theme_{{ id }}:/square.svg"); background-color: {{ error }}; height: 12px; width: 12px; padding: 2px; } napari-0.5.0a1/napari/_qt/qt_resources/styles/02_custom.qss000066400000000000000000000372021437041365600236660ustar00rootroot00000000000000QLabel#h1 { font-size: 28px; } QLabel#h2 { font-size: 22px; color: {{ secondary }}; } QLabel#h3 { font-size: 18px; color: {{ secondary }}; } QtViewer { padding-top: 0px; } QtLayerButtons, QtViewerButtons, QtLayerList { min-width: 242px; } /* ------------- QMainWindow --------- */ /* QDockWidgets will use the MainWindow styles as long as they are docked (though they use the style of QDockWidget when undocked) */ QStatusBar { background: {{ background }}; color: {{ text }}; } /* ------------- Window separator --------- */ QMainWindow::separator { width: 4px; height: 4px; border: none; background-color: {{ background }}; } QMainWindow::separator:hover { background: {{ foreground }}; } QMainWindow::separator:horizontal { image: url("theme_{{ id }}:/horizontal_separator.svg"); } QMainWindow::separator:vertical { image: url("theme_{{ id }}:/vertical_separator.svg"); } /* ------------- DockWidgets --------- */ #QtCustomTitleBar { padding-top:3px; background-color: {{ background }}; } #QtCustomTitleBar:hover { background-color: {{ darken(background, 10) }}; } #QtCustomTitleBarLine { background-color: {{ foreground }}; } #QtCustomTitleBar > QPushButton { background-color: none; max-width: 12px; max-height: 12px; } #QtCustomTitleBar > QPushButton:hover { background-color: {{ foreground }}; } #QtCustomTitleBar > QLabel { color: {{ primary }}; font-size: 11pt; } #QTitleBarCloseButton{ width: 12px; height: 12px; padding: 0; image: url("theme_{{ id }}:/delete_shape.svg"); } #QTitleBarFloatButton{ image: url("theme_{{ id }}:/pop_out.svg"); width: 10px; height: 8px; padding: 2 1 2 1; } #QTitleBarHideButton{ image: url("theme_{{ id }}:/visibility_off.svg"); width: 10px; height: 8px; padding: 2 1 2 1; } /* ----------------- Console ------------------ */ QtConsole { min-height: 100px; } QtConsole > QTextEdit { background-color: {{ console }}; background-clip: padding; color: {{ text }}; selection-background-color: {{ highlight }}; margin: 10px; } .inverted { background-color: {{ background }}; color: {{ foreground }}; } .error { color: #b72121; } .in-prompt-number { font-weight: bold; } .out-prompt-number { font-weight: bold; } .in-prompt { color: #6ab825; } .out-prompt { color: #b72121; } /* controls the area around the canvas */ QSplitter { spacing: 0px; padding: 0px; margin: 0px; } QtDivider { spacing: 0px; padding: 0px; border: 0px; margin: 0px 3px 0px 3px; min-width: 214px; min-height: 1px; max-height: 1px; } QtDivider[selected=true] { background-color: {{ text }}; } QtDivider[selected=false] { background-color: {{ background }}; } /* --------------- QtLayerWidget -------------------- */ QtLayerWidget { padding: 0px; background-color: {{ foreground }}; border-radius: 2px; min-height: 32px; max-height: 32px; min-width: 228px; } QtLayerWidget[selected="true"] { background-color: {{ current }}; } QtLayerWidget > QLabel { background-color: transparent; padding: 0px; qproperty-alignment: AlignCenter; } /* The name of the layer*/ QtLayerWidget > QLineEdit { background-color: transparent; border: none; border-radius: 2px; padding: 2px; font-size: 14px; qproperty-alignment: right; } QtLayerWidget > QLineEdit:disabled { background-color: transparent; border-color: transparent; border-radius: 3px; } QtLayerWidget > QLineEdit:focus { background-color: {{ darken(current, 20) }}; selection-background-color: {{ lighten(current, 20) }}; } QtLayerWidget QCheckBox::indicator { background-color: transparent; } QtLayerWidget QCheckBox::indicator:hover { background-color: transparent; } QtLayerWidget > QCheckBox#visibility { spacing: 0px; margin: 0px 0px 0px 4px; } QtLayerWidget > QCheckBox#visibility::indicator{ width: 18px; height: 18px; } QtLayerWidget > QCheckBox#visibility::indicator:unchecked { image: url("theme_{{ id }}:/visibility_off_50.svg"); } QtLayerWidget > QCheckBox#visibility::indicator:checked { image: url("theme_{{ id }}:/visibility.svg"); } QLabel[layer_type_label="true"] { max-width: 20px; min-width: 20px; min-height: 20px; max-height: 20px; margin-right: 4px; } QLabel#Shapes { image: url("theme_{{ id }}:/new_shapes.svg"); } QLabel#Points { image: url("theme_{{ id }}:/new_points.svg"); } QLabel#Labels { image: url("theme_{{ id }}:/new_labels.svg"); } QLabel#Image { image: url("theme_{{ id }}:/new_image.svg"); } QLabel#Multiscale { image: url("theme_{{ id }}:/new_image.svg"); } QLabel#Surface { image: url("theme_{{ id }}:/new_surface.svg"); } QLabel#Vectors { image: url("theme_{{ id }}:/new_vectors.svg"); } QLabel#logo_silhouette { image: url("theme_{{ id }}:/logo_silhouette.svg"); } /* ------------------------------------------------------ */ QFrame#empty_controls_widget { min-height: 225px; min-width: 240px; } QtLayerControlsContainer { border-radius: 2px; padding: 0px; margin: 10px; margin-left: 10px; margin-right: 8px; margin-bottom: 4px; } QtLayerControlsContainer > QFrame { padding: 5px; padding-right: 8px; border-radius: 2px; } /* the box that shows the current Label color */ QtColorBox { padding: 0px; border: 0px; margin: -1px 0 0 -1px; border-radius: 2px; min-height: 20px; max-height: 20px; min-width: 20px; max-width: 20px; } /* ----------------- QtLayerControls -------------------- */ QtLayerControls > QLabel, QtLayerControls, QtPlaneControls > QLabeledSlider > QAbstractSpinBox { font-size: 11pt; color: {{ text }}; } QLabeledRangeSlider > QAbstractSpinBox { font-size: 12pt; color: {{ secondary }}; } QWidget[emphasized="true"] QDoubleSlider::sub-page:horizontal:disabled { background: {{ primary }}; } QWidget[emphasized="true"] QDoubleSlider::handle:disabled { background: {{ primary }}; } QWidget[emphasized="true"] SliderLabel:disabled { color: {{ opacity(text, 50) }}; } QWidget[emphasized="true"] QLabel:disabled { color: {{ opacity(text, 50) }}; } AutoScaleButtons QPushButton { font-size: 9pt; padding: 4; } PlaneNormalButtons QPushButton { font-size: 9pt; padding: 4; } /* ------------- DimsSliders --------- */ QtDimSliderWidget > QScrollBar::handle[last_used=false]:horizontal { background: {{ highlight }}; } QtDimSliderWidget > QScrollBar::handle[last_used=true]:horizontal { background: {{ secondary }}; } QtDimSliderWidget > QScrollBar:left-arrow:horizontal { image: url("theme_{{ id }}:/step_left.svg"); } QtDimSliderWidget > QScrollBar::right-arrow:horizontal { image: url("theme_{{ id }}:/step_right.svg"); } QtDimSliderWidget > QLineEdit { background-color: {{ background }}; } #QtModalPopup { /* required for rounded corners to not have background color */ background: transparent; } #QtPopupFrame { border: 1px solid {{ secondary }}; border-radius: 5px; } #QtPopupFrame > QLabel { color: {{ darken(text, 35) }}; font-size: 12px; } #playDirectionCheckBox::indicator { image: url("theme_{{ id }}:/long_right_arrow.svg"); width: 22px; height: 22px; padding: 0 6px; border: 0px; } #fpsSpinBox { min-width: 60px; } #playDirectionCheckBox::indicator:checked { image: url("theme_{{ id }}:/long_left_arrow.svg"); } #playDirectionCheckBox::indicator:pressed { background-color: {{ highlight }}; } #colorSwatch { border-radius: 1px; min-height: 22px; max-height: 22px; min-width: 22px; max-width: 22px; } #QtColorPopup{ background-color: transparent; } #CustomColorDialog QPushButton { padding: 4px 10px; } #CustomColorDialog QLabel { background-color: {{ background }}; color: {{ secondary }}; } /* editable slice label and axis name */ QtDimSliderWidget > QLineEdit { padding: 0 0 1px 2px; max-height: 14px; min-height: 12px; min-width: 16px; color: {{ text }}; } #slice_label { font-size: 11pt; color: {{ secondary }}; background: transparent; } #slice_label_sep{ background-color: {{ background }}; border: 1px solid {{ primary }}; } /* ------------ Special Dialogs ------------ */ QtAboutKeybindings { min-width: 600px; min-height: 605px; } QtAbout > QTextEdit{ margin: 0px; border: 0px; padding: 2px; } /* ------------ Shortcut Editor ------------ */ ShortcutEditor QHeaderView::section { padding: 2px; border: None; } /* ------------ Plugin Sorter ------------ */ ImplementationListItem { background-color: {{ background }}; border-radius: 2px; } QtHookImplementationListWidget::item { background: transparent; } QtHookImplementationListWidget { background-color: {{ console }}; } /* for the error reporter */ #pluginInfo { color: text; } QtPluginErrReporter > QTextEdit { background-color: {{ console }}; background-clip: padding; color: {{ text }}; selection-background-color: {{ highlight }}; margin: 10px; } /* ------------ Notifications ------------ */ NapariQtNotification > QWidget { background: none; } NapariQtNotification::hover{ background: {{ lighten(background, 5) }}; } NapariQtNotification #expand_button { background: none; padding: 0px; margin: 0px; max-width: 20px; } NapariQtNotification[expanded="false"] #expand_button { image: url("theme_{{ id }}:/chevron_up.svg"); } NapariQtNotification[expanded="true"] #expand_button { image: url("theme_{{ id }}:/chevron_down.svg"); } NapariQtNotification #close_button { background: none; image: url("theme_{{ id }}:/delete_shape.svg"); padding: 0px; margin: 0px; max-width: 20px; } NapariQtNotification #source_label { color: {{ primary }}; font-size: 11px; } NapariQtNotification #severity_icon { padding: 0; margin: 0 0 -3px 0; min-width: 20px; min-height: 18px; font-size: 15px; color: {{ icon }}; } /* ------------ Activity Dock ------------ */ #QtCustomTitleLabel { color: {{ primary }}; font-size: 11pt; } #QtActivityButton:hover { background-color: {{ lighten(background, 10) }}; } /* ------------ Plugin Dialog ------------ */ QPluginList { background: {{ console }}; } PluginListItem { background: {{ darken(foreground, 20) }}; padding: 0; margin: 2px 4px; border-radius: 3px; } PluginListItem#unavailable { background: {{ lighten(foreground, 20) }}; padding: 0; margin: 2px 4px; border-radius: 3px; } PluginListItem QCheckBox::indicator:disabled { background-color: {{ opacity(foreground, 127) }}; image: url("theme_{{ id }}:/check_50.svg"); } QPushButton#install_button { background-color: {{ current }} } QPushButton#install_button:hover { background-color: {{ lighten(current, 10) }} } QPushButton#install_button:pressed { background-color: {{ darken(current, 10) }} } QPushButton#install_button:disabled { background-color: {{ lighten(current, 20) }} } QPushButton#remove_button { background-color: {{ error }} } QPushButton#remove_button:hover { background-color: {{ lighten(error, 10) }} } QPushButton#remove_button:pressed { background-color: {{ darken(error, 10) }} } QPushButton#busy_button:pressed { background-color: {{ darken(secondary, 10) }} } QPushButton#busy_button { background-color: {{ secondary }} } QPushButton#busy_button:hover { background-color: {{ lighten(secondary, 10) }} } QPushButton#busy_button:pressed { background-color: {{ darken(secondary, 10) }} } QPushButton#close_button:disabled { background-color: {{ lighten(secondary, 10) }} } #small_text { color: {{ opacity(text, 150) }}; font-size: 10px; } #small_italic_text { color: {{ opacity(text, 150) }}; font-size: 12px; font-style: italic; } #plugin_manager_process_status{ background: {{ background }}; color: {{ opacity(text, 200) }}; } #info_icon { image: url("theme_{{ id }}:/info.svg"); min-width: 18px; min-height: 18px; margin: 2px; } #warning_icon { image: url("theme_{{ id }}:/warning.svg"); max-width: 14px; max-height: 14px; min-width: 14px; min-height: 14px; margin: 0px; margin-left: 1px; padding: 2px; background: darken(foreground, 20); } #warning_icon:hover{ background: {{ foreground }}; } #warning_icon:pressed{ background: {{ primary }}; } #error_label { image: url("theme_{{ id }}:/warning.svg"); max-width: 18px; max-height: 18px; min-width: 18px; min-height: 18px; margin: 0px; margin-left: 1px; padding: 2px; } #success_label { image: url("theme_{{ id }}:/check.svg"); max-width: 18px; max-height: 18px; min-width: 18px; min-height: 18px; margin: 0px; margin-left: 1px; padding: 2px; } #help_label { image: url("theme_{{ id }}:/help.svg"); max-width: 18px; max-height: 18px; min-width: 18px; min-height: 18px; margin: 0px; margin-left: 1px; padding: 2px; } QtPluginDialog QSplitter{ padding-right: 2; } QtPluginSorter { padding: 20px; } QtFontSizePreview { border: 1px solid {{ foreground }}; border-radius: 5px; } QListWidget#Preferences { background: {{ background }}; } QtWelcomeWidget, QtWelcomeWidget[drag=false] { background: {{ canvas }}; } QtWelcomeWidget[drag=true] { background: {{ highlight }}; } QtWelcomeLabel { color: {{ foreground }}; font-size: 20px; } QtShortcutLabel { color: {{ foreground }}; font-size: 16px; } /* ------------- Narrow scrollbar for qtlayer list --------- */ QtListView { background: {{ background }}; } QtListView QScrollBar:vertical { max-width: 8px; } QtListView QScrollBar::add-line:vertical, QtListView QScrollBar::sub-line:vertical { height: 10px; width: 8px; margin-top: 2px; margin-bottom: 2px; } QtListView QScrollBar:up-arrow, QtListView QScrollBar:down-arrow { min-height: 6px; min-width: 6px; max-height: 6px; max-width: 6px; } QtListView::item { padding: 4px; margin: 2px 2px 2px 2px; background-color: {{ foreground }}; border: 1px solid {{ foreground }}; } QtListView::item:hover { background-color: {{ lighten(foreground, 3) }}; } /* in the QSS context "active" means the window is active */ /* (as opposed to focused on another application) */ QtListView::item:selected:active{ background: qlineargradient(x1: 0, y1: 0, x2: 0, y2: 1, stop: 0 {{ current }}, stop: 1 {{ darken(current, 15) }}); } QtListView::item:selected:!active { background: qlineargradient(x1: 0, y1: 0, x2: 0, y2: 1, stop: 0 {{ darken(current, 10) }}, stop: 1 {{ darken(current, 25) }}); } QtListView QLineEdit { background-color: {{ darken(current, 20) }}; selection-background-color: {{ lighten(current, 20) }}; font-size: 12px; } QtLayerList::item { margin: 2px 2px 2px 28px; border-top-right-radius: 2px; border-bottom-right-radius: 2px; border: 0; } /* the first one is the "partially checked" state */ QtLayerList::indicator { width: 16px; height: 16px; position: absolute; left: 0px; image: url("theme_{{ id }}:/visibility_off.svg"); } QtLayerList::indicator:unchecked { image: url("theme_{{ id }}:/visibility_off_50.svg"); } QtLayerList::indicator:checked { image: url("theme_{{ id }}:/visibility.svg"); } #error_icon_btn { qproperty-icon: url("theme_{{ id }}:/error.svg"); } #warning_icon_btn { qproperty-icon: url("theme_{{ id }}:/warning.svg"); } #warning_icon_element { image: url("theme_{{ id }}:/warning.svg"); min-height: 36px; min-width: 36px; } #error_icon_element { image: url("theme_{{ id }}:/error.svg"); min-height: 36px; min-width: 36px; } /* --------------- Menus (application and context menus) ---------------- */ QMenu::separator, QModelMenu::separator { height: 1 px; background: {{ opacity(text, 90) }}; margin-left: 17 px; margin-right: 6 px; margin-top: 5 px; margin-bottom: 3 px; } QMenu:disabled, QModelMenu:disabled { background-color: {{ background }}; selection-background-color: transparent; border: 1px solid; border-color: {{ foreground }}; color: {{ opacity(text, 90) }}; } QMenu, QModelMenu { padding: 6 px; } napari-0.5.0a1/napari/_qt/qt_viewer.py000066400000000000000000001274651437041365600176540ustar00rootroot00000000000000from __future__ import annotations import logging import traceback import typing import warnings from pathlib import Path from typing import TYPE_CHECKING, List, Optional, Sequence, Tuple, Union from weakref import WeakSet import numpy as np from qtpy.QtCore import QCoreApplication, QObject, Qt from qtpy.QtGui import QCursor, QGuiApplication from qtpy.QtWidgets import QFileDialog, QSplitter, QVBoxLayout, QWidget from napari._qt.containers import QtLayerList from napari._qt.dialogs.qt_reader_dialog import handle_gui_reading from napari._qt.dialogs.screenshot_dialog import ScreenshotDialog from napari._qt.perf.qt_performance import QtPerformance from napari._qt.utils import ( QImg2array, circle_pixmap, crosshair_pixmap, square_pixmap, ) from napari._qt.widgets.qt_dims import QtDims from napari._qt.widgets.qt_viewer_buttons import ( QtLayerButtons, QtViewerButtons, ) from napari._qt.widgets.qt_viewer_dock_widget import QtViewerDockWidget from napari._qt.widgets.qt_welcome import QtWidgetOverlay from napari.components.camera import Camera from napari.components.layerlist import LayerList from napari.components.overlays import CanvasOverlay, Overlay, SceneOverlay from napari.errors import MultipleReaderError, ReaderPluginError from napari.layers.base.base import Layer from napari.plugins import _npe2 from napari.settings import get_settings from napari.utils import config, perf from napari.utils._proxies import ReadOnlyWrapper from napari.utils.action_manager import action_manager from napari.utils.colormaps.standardize_color import transform_color from napari.utils.history import ( get_open_history, get_save_history, update_open_history, update_save_history, ) from napari.utils.interactions import ( mouse_double_click_callbacks, mouse_move_callbacks, mouse_press_callbacks, mouse_release_callbacks, mouse_wheel_callbacks, ) from napari.utils.io import imsave from napari.utils.key_bindings import KeymapHandler from napari.utils.misc import in_ipython, in_jupyter from napari.utils.theme import get_theme from napari.utils.translations import trans from napari_builtins.io import imsave_extensions from napari._vispy import ( # isort:skip VispyCamera, VispyCanvas, create_vispy_layer, create_vispy_overlay, ) if TYPE_CHECKING: from npe2.manifest.contributions import WriterContribution from napari._qt.layer_controls import QtLayerControlsContainer from napari.components import ViewerModel def _npe2_decode_selected_filter( ext_str: str, selected_filter: str, writers: Sequence[WriterContribution] ) -> Optional[WriterContribution]: """Determine the writer that should be invoked to save data. When npe2 can be imported, resolves a selected file extension string into a specific writer. Otherwise, returns None. """ # When npe2 is not present, `writers` is expected to be an empty list, # `[]`. This function will return None. for entry, writer in zip( ext_str.split(";;"), writers, ): if entry.startswith(selected_filter): return writer return None def _extension_string_for_layers( layers: Sequence[Layer], ) -> Tuple[str, List[WriterContribution]]: """Return an extension string and the list of corresponding writers. The extension string is a ";;" delimeted string of entries. Each entry has a brief description of the file type and a list of extensions. The writers, when provided, are the npe2.manifest.io.WriterContribution objects. There is one writer per entry in the extension string. If npe2 is not importable, the list of writers will be empty. """ # try to use npe2 ext_str, writers = _npe2.file_extensions_string_for_layers(layers) if ext_str: return ext_str, writers # fallback to old behavior if len(layers) == 1: selected_layer = layers[0] # single selected layer. if selected_layer._type_string == 'image': ext = imsave_extensions() ext_list = [f"*{val}" for val in ext] ext_str = ';;'.join(ext_list) ext_str = trans._( "All Files (*);; Image file types:;;{ext_str}", ext_str=ext_str, ) elif selected_layer._type_string == 'points': ext_str = trans._("All Files (*);; *.csv;;") else: # layer other than image or points ext_str = trans._("All Files (*);;") else: # multiple layers. ext_str = trans._("All Files (*);;") return ext_str, [] class QtViewer(QSplitter): """Qt view for the napari Viewer model. Parameters ---------- viewer : napari.components.ViewerModel Napari viewer containing the rendered scene, layers, and controls. show_welcome_screen : bool, optional Flag to show a welcome message when no layers are present in the canvas. Default is `False`. Attributes ---------- canvas : vispy.scene.SceneCanvas Canvas for rendering the current view. console : QtConsole IPython console terminal integrated into the napari GUI. controls : QtLayerControlsContainer Qt view for GUI controls. dims : napari.qt_dims.QtDims Dimension sliders; Qt View for Dims model. dockConsole : QtViewerDockWidget QWidget wrapped in a QDockWidget with forwarded viewer events. dockLayerControls : QtViewerDockWidget QWidget wrapped in a QDockWidget with forwarded viewer events. dockLayerList : QtViewerDockWidget QWidget wrapped in a QDockWidget with forwarded viewer events. layerButtons : QtLayerButtons Button controls for napari layers. layers : QtLayerList Qt view for LayerList controls. layer_to_visual : dict Dictionary mapping napari layers with their corresponding vispy_layers. view : vispy scene widget View displayed by vispy canvas. Adds a vispy ViewBox as a child widget. viewer : napari.components.ViewerModel Napari viewer containing the rendered scene, layers, and controls. viewerButtons : QtViewerButtons Button controls for the napari viewer. """ _instances = WeakSet() def __init__( self, viewer: ViewerModel, show_welcome_screen: bool = False ) -> None: super().__init__() self._instances.add(self) self.setAttribute(Qt.WidgetAttribute.WA_DeleteOnClose) self._show_welcome_screen = show_welcome_screen QCoreApplication.setAttribute( Qt.AA_UseStyleSheetPropagationInWidgetStyles, True ) self.viewer = viewer self.dims = QtDims(self.viewer.dims) self._controls = None self._layers = None self._layersButtons = None self._viewerButtons = None self._key_map_handler = KeymapHandler() self._key_map_handler.keymap_providers = [self.viewer] self._console = None self._dockLayerList = None self._dockLayerControls = None self._dockConsole = None self._dockPerformance = None # This dictionary holds the corresponding vispy visual for each layer self.layer_to_visual = {} self.overlay_to_visual = {} self._create_canvas() # Stacked widget to provide a welcome page self._welcome_widget = QtWidgetOverlay(self, self.canvas.native) self._welcome_widget.set_welcome_visible(show_welcome_screen) self._welcome_widget.sig_dropped.connect(self.dropEvent) self._welcome_widget.leave.connect(self._leave_canvas) self._welcome_widget.enter.connect(self._enter_canvas) main_widget = QWidget() main_layout = QVBoxLayout() main_layout.setContentsMargins(0, 2, 0, 2) main_layout.addWidget(self._welcome_widget) main_layout.addWidget(self.dims) main_layout.setSpacing(0) main_widget.setLayout(main_layout) self.setOrientation(Qt.Orientation.Vertical) self.addWidget(main_widget) self._cursors = { 'cross': Qt.CursorShape.CrossCursor, 'forbidden': Qt.CursorShape.ForbiddenCursor, 'pointing': Qt.CursorShape.PointingHandCursor, 'standard': Qt.CursorShape.ArrowCursor, } self._on_active_change() self.viewer.layers.events.inserted.connect(self._update_welcome_screen) self.viewer.layers.events.removed.connect(self._update_welcome_screen) self.viewer.layers.selection.events.active.connect( self._on_active_change ) self.viewer.camera.events.interactive.connect(self._on_interactive) self.viewer.cursor.events.style.connect(self._on_cursor) self.viewer.cursor.events.size.connect(self._on_cursor) self.viewer.layers.events.reordered.connect(self._reorder_layers) self.viewer.layers.events.inserted.connect(self._on_add_layer_change) self.viewer.layers.events.removed.connect(self._remove_layer) self.setAcceptDrops(True) self.view = self.canvas.central_widget.add_view(border_width=0) self.camera = VispyCamera( self.view, self.viewer.camera, self.viewer.dims ) self.canvas.events.draw.connect(self.camera.on_draw) # Create the experimental QtPool for octree and/or monitor. self._qt_poll = _create_qt_poll(self, self.viewer.camera) # Create the experimental RemoteManager for the monitor. self._remote_manager = _create_remote_manager( self.viewer.layers, self._qt_poll ) # moved from the old layerlist... still feels misplaced. # can you help me move this elsewhere? if config.async_loading: from napari._qt.experimental.qt_chunk_receiver import ( QtChunkReceiver, ) # The QtChunkReceiver object allows the ChunkLoader to pass newly # loaded chunks to the layers that requested them. self.chunk_receiver = QtChunkReceiver(self.layers) else: self.chunk_receiver = None # bind shortcuts stored in settings last. self._bind_shortcuts() for layer in self.viewer.layers: self._add_layer(layer) for overlay in self.viewer._overlays.values(): self._add_overlay(overlay) @property def controls(self) -> QtLayerControlsContainer: if self._controls is None: # Avoid circular import. from napari._qt.layer_controls import QtLayerControlsContainer self._controls = QtLayerControlsContainer(self.viewer) return self._controls @property def layers(self) -> QtLayerList: if self._layers is None: self._layers = QtLayerList(self.viewer.layers) return self._layers @property def layerButtons(self) -> QtLayerButtons: if self._layersButtons is None: self._layersButtons = QtLayerButtons(self.viewer) return self._layersButtons @property def viewerButtons(self) -> QtViewerButtons: if self._viewerButtons is None: self._viewerButtons = QtViewerButtons(self.viewer) return self._viewerButtons @property def dockLayerList(self) -> QtViewerDockWidget: if self._dockLayerList is None: layerList = QWidget() layerList.setObjectName('layerList') layerListLayout = QVBoxLayout() layerListLayout.addWidget(self.layerButtons) layerListLayout.addWidget(self.layers) layerListLayout.addWidget(self.viewerButtons) layerListLayout.setContentsMargins(8, 4, 8, 6) layerList.setLayout(layerListLayout) self._dockLayerList = QtViewerDockWidget( self, layerList, name=trans._('layer list'), area='left', allowed_areas=['left', 'right'], object_name='layer list', close_btn=False, ) return self._dockLayerList @property def dockLayerControls(self) -> QtViewerDockWidget: if self._dockLayerControls is None: self._dockLayerControls = QtViewerDockWidget( self, self.controls, name=trans._('layer controls'), area='left', allowed_areas=['left', 'right'], object_name='layer controls', close_btn=False, ) return self._dockLayerControls @property def dockConsole(self) -> QtViewerDockWidget: if self._dockConsole is None: self._dockConsole = QtViewerDockWidget( self, QWidget(), name=trans._('console'), area='bottom', allowed_areas=['top', 'bottom'], object_name='console', close_btn=False, ) self._dockConsole.setVisible(False) self._dockConsole.visibilityChanged.connect(self._ensure_connect) return self._dockConsole @property def dockPerformance(self) -> QtViewerDockWidget: if self._dockPerformance is None: self._dockPerformance = self._create_performance_dock_widget() return self._dockPerformance def _leave_canvas(self): """disable status on canvas leave""" self.viewer.status = "" self.viewer.mouse_over_canvas = False def _enter_canvas(self): """enable status on canvas enter""" self.viewer.status = "Ready" self.viewer.mouse_over_canvas = True def _ensure_connect(self): # lazy load console id(self.console) def _bind_shortcuts(self): """Bind shortcuts stored in SETTINGS to actions.""" for action, shortcuts in get_settings().shortcuts.shortcuts.items(): action_manager.unbind_shortcut(action) for shortcut in shortcuts: action_manager.bind_shortcut(action, shortcut) def _create_canvas(self) -> None: """Create the canvas and hook up events.""" self.canvas = VispyCanvas( keys=None, vsync=True, parent=self, size=self.viewer._canvas_size[::-1], ) self.canvas.events.draw.connect(self.dims.enable_play) self.canvas.events.mouse_double_click.connect( self.on_mouse_double_click ) self.canvas.events.mouse_move.connect(self.on_mouse_move) self.canvas.events.mouse_press.connect(self.on_mouse_press) self.canvas.events.mouse_release.connect(self.on_mouse_release) self.canvas.events.key_press.connect( self._key_map_handler.on_key_press ) self.canvas.events.key_release.connect( self._key_map_handler.on_key_release ) self.canvas.events.mouse_wheel.connect(self.on_mouse_wheel) self.canvas.events.draw.connect(self.on_draw) self.canvas.events.resize.connect(self.on_resize) self.canvas.bgcolor = transform_color( get_theme(self.viewer.theme, False).canvas.as_hex() )[0] theme = self.viewer.events.theme on_theme_change = self.canvas._on_theme_change theme.connect(on_theme_change) self.canvas.destroyed.connect(self._diconnect_theme) def _diconnect_theme(self): self.viewer.events.theme.disconnect(self.canvas._on_theme_change) def _add_overlay(self, overlay: Overlay) -> None: vispy_overlay = create_vispy_overlay(overlay, viewer=self.viewer) if isinstance(overlay, CanvasOverlay): vispy_overlay.node.parent = self.view elif isinstance(overlay, SceneOverlay): vispy_overlay.node.parent = self.view.scene self.overlay_to_visual[overlay] = vispy_overlay def _create_performance_dock_widget(self): """Create the dock widget that shows performance metrics.""" if perf.USE_PERFMON: return QtViewerDockWidget( self, QtPerformance(), name=trans._('performance'), area='bottom', ) return None @property def console(self): """QtConsole: iPython console terminal integrated into the napari GUI.""" if self._console is None: try: from napari_console import QtConsole import napari with warnings.catch_warnings(): warnings.filterwarnings("ignore") self.console = QtConsole(self.viewer) self.console.push( {'napari': napari, 'action_manager': action_manager} ) except ModuleNotFoundError: warnings.warn( trans._( 'napari-console not found. It can be installed with' ' "pip install napari_console"' ) ) self._console = None except ImportError: traceback.print_exc() warnings.warn( trans._( 'error importing napari-console. See console for full error.' ) ) self._console = None return self._console @console.setter def console(self, console): self._console = console if console is not None: self.dockConsole.setWidget(console) console.setParent(self.dockConsole) def _on_active_change(self): """When active layer changes change keymap handler.""" self._key_map_handler.keymap_providers = ( [self.viewer] if self.viewer.layers.selection.active is None else [self.viewer.layers.selection.active, self.viewer] ) def _on_add_layer_change(self, event): """When a layer is added, set its parent and order. Parameters ---------- event : napari.utils.event.Event The napari event that triggered this method. """ layer = event.value self._add_layer(layer) def _add_layer(self, layer): """When a layer is added, set its parent and order. Parameters ---------- layer : napari.layers.Layer Layer to be added. """ vispy_layer = create_vispy_layer(layer) # QtPoll is experimental. if self._qt_poll is not None: # QtPoll will call VipyBaseImage._on_poll() when the camera # moves or the timer goes off. self._qt_poll.events.poll.connect(vispy_layer._on_poll) # In the other direction, some visuals need to tell QtPoll to # start polling. When they receive new data they need to be # polled to load it, even if the camera is not moving. if vispy_layer.events is not None: vispy_layer.events.loaded.connect(self._qt_poll.wake_up) vispy_layer.node.parent = self.view.scene self.layer_to_visual[layer] = vispy_layer self._reorder_layers() def _remove_layer(self, event): """When a layer is removed, remove its parent. Parameters ---------- event : napari.utils.event.Event The napari event that triggered this method. """ layer = event.value vispy_layer = self.layer_to_visual[layer] vispy_layer.close() del vispy_layer del self.layer_to_visual[layer] self._reorder_layers() def _reorder_layers(self): """When the list is reordered, propagate changes to draw order.""" for i, layer in enumerate(self.viewer.layers): vispy_layer = self.layer_to_visual[layer] vispy_layer.order = i self.canvas._draw_order.clear() self.canvas.update() def _save_layers_dialog(self, selected=False): """Save layers (all or selected) to disk, using ``LayerList.save()``. Parameters ---------- selected : bool If True, only layers that are selected in the viewer will be saved. By default, all layers are saved. """ msg = '' if not len(self.viewer.layers): msg = trans._("There are no layers in the viewer to save") elif selected and not len(self.viewer.layers.selection): msg = trans._( 'Please select one or more layers to save,' '\nor use "Save all layers..."' ) if msg: raise OSError(trans._("Nothing to save")) # prepare list of extensions for drop down menu. ext_str, writers = _extension_string_for_layers( list(self.viewer.layers.selection) if selected else self.viewer.layers ) msg = trans._("selected") if selected else trans._("all") dlg = QFileDialog() hist = get_save_history() dlg.setHistory(hist) filename, selected_filter = dlg.getSaveFileName( parent=self, caption=trans._('Save {msg} layers', msg=msg), directory=hist[0], # home dir by default, filter=ext_str, options=( QFileDialog.DontUseNativeDialog if in_ipython() else QFileDialog.Options() ), ) logging.debug( trans._( 'QFileDialog - filename: {filename} ' 'selected_filter: {selected_filter}', filename=filename or None, selected_filter=selected_filter or None, ) ) if filename: writer = _npe2_decode_selected_filter( ext_str, selected_filter, writers ) with warnings.catch_warnings(record=True) as wa: saved = self.viewer.layers.save( filename, selected=selected, _writer=writer ) logging.debug('Saved %s', saved) error_messages = "\n".join(str(x.message.args[0]) for x in wa) if not saved: raise OSError( trans._( "File {filename} save failed.\n{error_messages}", deferred=True, filename=filename, error_messages=error_messages, ) ) else: update_save_history(saved[0]) def _update_welcome_screen(self): """Update welcome screen display based on layer count.""" if self._show_welcome_screen: self._welcome_widget.set_welcome_visible(not self.viewer.layers) def _screenshot(self, flash=True): """Capture a screenshot of the Vispy canvas. Parameters ---------- flash : bool Flag to indicate whether flash animation should be shown after the screenshot was captured. """ # CAN REMOVE THIS AFTER DEPRECATION IS DONE, see self.screenshot. img = self.canvas.native.grabFramebuffer() if flash: from napari._qt.utils import add_flash_animation # Here we are actually applying the effect to the `_welcome_widget` # and not # the `native` widget because it does not work on the # `native` widget. It's probably because the widget is in a stack # with the `QtWelcomeWidget`. add_flash_animation(self._welcome_widget) return img def screenshot(self, path=None, flash=True): """Take currently displayed screen and convert to an image array. Parameters ---------- path : str Filename for saving screenshot image. flash : bool Flag to indicate whether flash animation should be shown after the screenshot was captured. Returns ------- image : array Numpy array of type ubyte and shape (h, w, 4). Index [0, 0] is the upper-left corner of the rendered region. """ img = QImg2array(self._screenshot(flash)) if path is not None: imsave(path, img) # scikit-image imsave method return img def clipboard(self, flash=True): """Take a screenshot of the currently displayed screen and copy the image to the clipboard. Parameters ---------- flash : bool Flag to indicate whether flash animation should be shown after the screenshot was captured. """ cb = QGuiApplication.clipboard() cb.setImage(self._screenshot(flash)) def _screenshot_dialog(self): """Save screenshot of current display, default .png""" hist = get_save_history() dial = ScreenshotDialog(self.screenshot, self, hist[0], hist) if dial.exec_(): update_save_history(dial.selectedFiles()[0]) def _open_file_dialog_uni(self, caption: str) -> typing.List[str]: """ Open dialog to get list of files from user """ dlg = QFileDialog() hist = get_open_history() dlg.setHistory(hist) open_kwargs = { "parent": self, "caption": caption, } if "pyside" in QFileDialog.__module__.lower(): # PySide6 open_kwargs["dir"] = hist[0] else: open_kwargs["directory"] = hist[0] if in_ipython(): open_kwargs["options"] = QFileDialog.DontUseNativeDialog return dlg.getOpenFileNames(**open_kwargs)[0] def _open_files_dialog(self, choose_plugin=False): """Add files from the menubar.""" filenames = self._open_file_dialog_uni(trans._('Select file(s)...')) if (filenames != []) and (filenames is not None): for filename in filenames: self._qt_open( [filename], stack=False, choose_plugin=choose_plugin ) update_open_history(filenames[0]) def _open_files_dialog_as_stack_dialog(self, choose_plugin=False): """Add files as a stack, from the menubar.""" filenames = self._open_file_dialog_uni(trans._('Select files...')) if (filenames != []) and (filenames is not None): self._qt_open(filenames, stack=True, choose_plugin=choose_plugin) update_open_history(filenames[0]) def _open_folder_dialog(self, choose_plugin=False): """Add a folder of files from the menubar.""" dlg = QFileDialog() hist = get_open_history() dlg.setHistory(hist) folder = dlg.getExistingDirectory( self, trans._('Select folder...'), hist[0], # home dir by default ( QFileDialog.DontUseNativeDialog if in_ipython() else QFileDialog.Options() ), ) if folder not in {'', None}: self._qt_open([folder], stack=False, choose_plugin=choose_plugin) update_open_history(folder) def _qt_open( self, filenames: List[str], stack: Union[bool, List[List[str]]], choose_plugin: bool = False, plugin: str = None, layer_type: str = None, **kwargs, ): """Open files, potentially popping reader dialog for plugin selection. Call ViewerModel.open and catch errors that could be fixed by user making a plugin choice. Parameters ---------- filenames : List[str] paths to open choose_plugin : bool True if user wants to explicitly choose the plugin else False stack : bool or list[list[str]] whether to stack files or not. Can also be a list containing files to stack. plugin : str plugin to use for reading layer_type : str layer type for opened layers """ if choose_plugin: handle_gui_reading( filenames, self, stack, plugin_override=choose_plugin, **kwargs ) return try: self.viewer.open( filenames, stack=stack, plugin=plugin, layer_type=layer_type, **kwargs, ) except ReaderPluginError as e: handle_gui_reading( filenames, self, stack, e.reader_plugin, e, layer_type=layer_type, **kwargs, ) except MultipleReaderError: handle_gui_reading(filenames, self, stack, **kwargs) def _toggle_chunk_outlines(self): """Toggle whether we are drawing outlines around the chunks.""" from napari.layers.image.experimental.octree_image import ( _OctreeImageBase, ) for layer in self.viewer.layers: if isinstance(layer, _OctreeImageBase): layer.display.show_grid = not layer.display.show_grid def _on_interactive(self): """Link interactive attributes of view and viewer.""" self.view.interactive = self.viewer.camera.interactive def _on_cursor(self): """Set the appearance of the mouse cursor.""" cursor = self.viewer.cursor.style if cursor in {'square', 'circle'}: # Scale size by zoom if needed size = self.viewer.cursor.size if self.viewer.cursor.scaled: size *= self.viewer.camera.zoom size = int(size) # make sure the square fits within the current canvas if size < 8 or size > (min(*self.canvas.size) - 4): q_cursor = self._cursors['cross'] elif cursor == 'circle': q_cursor = QCursor(circle_pixmap(size)) else: q_cursor = QCursor(square_pixmap(size)) elif cursor == 'crosshair': q_cursor = QCursor(crosshair_pixmap()) else: q_cursor = self._cursors[cursor] self.canvas.native.setCursor(q_cursor) def toggle_console_visibility(self, event=None): """Toggle console visible and not visible. Imports the console the first time it is requested. """ if in_ipython() or in_jupyter(): return # force instantiation of console if not already instantiated _ = self.console viz = not self.dockConsole.isVisible() # modulate visibility at the dock widget level as console is dockable self.dockConsole.setVisible(viz) if self.dockConsole.isFloating(): self.dockConsole.setFloating(True) if viz: self.dockConsole.raise_() self.dockConsole.setFocus() self.viewerButtons.consoleButton.setProperty( 'expanded', self.dockConsole.isVisible() ) self.viewerButtons.consoleButton.style().unpolish( self.viewerButtons.consoleButton ) self.viewerButtons.consoleButton.style().polish( self.viewerButtons.consoleButton ) def _map_canvas2world(self, position): """Map position from canvas pixels into world coordinates. Parameters ---------- position : 2-tuple Position in canvas (x, y). Returns ------- coords : tuple Position in world coordinates, matches the total dimensionality of the viewer. """ nd = self.viewer.dims.ndisplay transform = self.view.scene.transform mapped_position = transform.imap(list(position))[:nd] position_world_slice = mapped_position[::-1] # handle position for 3D views of 2D data nd_point = len(self.viewer.dims.point) if nd_point < nd: position_world_slice = position_world_slice[-nd_point:] position_world = list(self.viewer.dims.point) for i, d in enumerate(self.viewer.dims.displayed): position_world[d] = position_world_slice[i] return tuple(position_world) @property def _canvas_corners_in_world(self): """Location of the corners of canvas in world coordinates. Returns ------- corners : 2-tuple Coordinates of top left and bottom right canvas pixel in the world. """ # Find corners of canvas in world coordinates top_left = self._map_canvas2world([0, 0]) bottom_right = self._map_canvas2world(self.canvas.size) return np.array([top_left, bottom_right]) def on_resize(self, event): """Called whenever canvas is resized. event : vispy.util.event.Event The vispy event that triggered this method. """ self.viewer._canvas_size = tuple(self.canvas.size[::-1]) def _process_mouse_event(self, mouse_callbacks, event): """Add properties to the mouse event before passing the event to the napari events system. Called whenever the mouse moves or is clicked. As such, care should be taken to reduce the overhead in this function. In future work, we should consider limiting the frequency at which it is called. This method adds following: position: the position of the click in world coordinates. view_direction: a unit vector giving the direction of the camera in world coordinates. up_direction: a unit vector giving the direction of the camera that is up in world coordinates. dims_displayed: a list of the dimensions currently being displayed in the viewer. This comes from viewer.dims.displayed. dims_point: the indices for the data in view in world coordinates. This comes from viewer.dims.point Parameters ---------- mouse_callbacks : function Mouse callbacks function. event : vispy.event.Event The vispy event that triggered this method. """ if event.pos is None: return # Add the view ray to the event event.view_direction = self.viewer.camera.calculate_nd_view_direction( self.viewer.dims.ndim, self.viewer.dims.displayed ) event.up_direction = self.viewer.camera.calculate_nd_up_direction( self.viewer.dims.ndim, self.viewer.dims.displayed ) # Update the cursor position self.viewer.cursor._view_direction = event.view_direction self.viewer.cursor.position = self._map_canvas2world(list(event.pos)) # Add the cursor position to the event event.position = self.viewer.cursor.position # Add the displayed dimensions to the event event.dims_displayed = list(self.viewer.dims.displayed) # Add the current dims indices event.dims_point = list(self.viewer.dims.point) # Put a read only wrapper on the event event = ReadOnlyWrapper(event) mouse_callbacks(self.viewer, event) layer = self.viewer.layers.selection.active if layer is not None: mouse_callbacks(layer, event) def on_mouse_wheel(self, event): """Called whenever mouse wheel activated in canvas. Parameters ---------- event : vispy.event.Event The vispy event that triggered this method. """ self._process_mouse_event(mouse_wheel_callbacks, event) def on_mouse_double_click(self, event): """Called whenever a mouse double-click happen on the canvas Parameters ---------- event : vispy.event.Event The vispy event that triggered this method. The `event.type` will always be `mouse_double_click` Notes ----- Note that this triggers in addition to the usual mouse press and mouse release. Therefore a double click from the user will likely triggers the following event in sequence: - mouse_press - mouse_release - mouse_double_click - mouse_release """ self._process_mouse_event(mouse_double_click_callbacks, event) def on_mouse_press(self, event): """Called whenever mouse pressed in canvas. Parameters ---------- event : vispy.event.Event The vispy event that triggered this method. """ self._process_mouse_event(mouse_press_callbacks, event) def on_mouse_move(self, event): """Called whenever mouse moves over canvas. Parameters ---------- event : vispy.event.Event The vispy event that triggered this method. """ self._process_mouse_event(mouse_move_callbacks, event) def on_mouse_release(self, event): """Called whenever mouse released in canvas. Parameters ---------- event : vispy.event.Event The vispy event that triggered this method. """ self._process_mouse_event(mouse_release_callbacks, event) def on_draw(self, event): """Called whenever the canvas is drawn. This is triggered from vispy whenever new data is sent to the canvas or the camera is moved and is connected in the `QtViewer`. """ # The canvas corners in full world coordinates (i.e. across all layers). canvas_corners_world = self._canvas_corners_in_world for layer in self.viewer.layers: # The following condition should mostly be False. One case when it can # be True is when a callback connected to self.viewer.dims.events.ndisplay # is executed before layer._slice_input has been updated by another callback # (e.g. when changing self.viewer.dims.ndisplay from 3 to 2). displayed_sorted = sorted(layer._slice_input.displayed) nd = len(displayed_sorted) if nd > self.viewer.dims.ndisplay: displayed_axes = displayed_sorted else: displayed_axes = self.viewer.dims.displayed[-nd:] layer._update_draw( scale_factor=1 / self.viewer.camera.zoom, corner_pixels_displayed=canvas_corners_world[ :, displayed_axes ], shape_threshold=self.canvas.size, ) def set_welcome_visible(self, visible): """Show welcome screen widget.""" self._show_welcome_screen = visible self._welcome_widget.set_welcome_visible(visible) def keyPressEvent(self, event): """Called whenever a key is pressed. Parameters ---------- event : qtpy.QtCore.QEvent Event from the Qt context. """ self.canvas._backend._keyEvent(self.canvas.events.key_press, event) event.accept() def keyReleaseEvent(self, event): """Called whenever a key is released. Parameters ---------- event : qtpy.QtCore.QEvent Event from the Qt context. """ self.canvas._backend._keyEvent(self.canvas.events.key_release, event) event.accept() def dragEnterEvent(self, event): """Ignore event if not dragging & dropping a file or URL to open. Using event.ignore() here allows the event to pass through the parent widget to its child widget, otherwise the parent widget would catch the event and not pass it on to the child widget. Parameters ---------- event : qtpy.QtCore.QDragEvent Event from the Qt context. """ if event.mimeData().hasUrls(): self._set_drag_status() event.accept() else: event.ignore() def _set_drag_status(self): """Set dedicated status message when dragging files into viewer""" self.viewer.status = trans._( 'Hold key to open plugin selection. Hold to open files as stack.' ) def dropEvent(self, event): """Add local files and web URLS with drag and drop. For each file, attempt to open with existing associated reader (if available). If no reader is associated or opening fails, and more than one reader is available, open dialog and ask user to choose among available readers. User can choose to persist this choice. Parameters ---------- event : qtpy.QtCore.QDropEvent Event from the Qt context. """ shift_down = ( QGuiApplication.keyboardModifiers() & Qt.KeyboardModifier.ShiftModifier ) alt_down = ( QGuiApplication.keyboardModifiers() & Qt.KeyboardModifier.AltModifier ) filenames = [] for url in event.mimeData().urls(): if url.isLocalFile(): # directories get a trailing "/", Path conversion removes it filenames.append(str(Path(url.toLocalFile()))) else: filenames.append(url.toString()) self._qt_open( filenames, stack=bool(shift_down), choose_plugin=bool(alt_down), ) def closeEvent(self, event): """Cleanup and close. Parameters ---------- event : qtpy.QtCore.QCloseEvent Event from the Qt context. """ self.layers.close() # if the viewer.QtDims object is playing an axis, we need to terminate # the AnimationThread before close, otherwise it will cause a segFault # or Abort trap. (calling stop() when no animation is occurring is also # not a problem) self.dims.stop() self.canvas.native.deleteLater() if self._console is not None: self.console.close() self.dockConsole.deleteLater() event.accept() if TYPE_CHECKING: from napari._qt.experimental.qt_poll import QtPoll from napari.components.experimental.remote import RemoteManager def _create_qt_poll(parent: QObject, camera: Camera) -> Optional[QtPoll]: """Create and return a QtPoll instance, if needed. Create a QtPoll instance for octree or monitor. Octree needs QtPoll so VispyTiledImageLayer can finish in-progress loads even if the camera is not moving. Once loading is finish it will tell QtPoll it no longer needs to be polled. Monitor needs QtPoll to poll for incoming messages. This might be temporary until we can process incoming messages with a dedicated thread. Parameters ---------- parent : QObject Parent Qt object. camera : Camera Camera that the QtPoll object will listen to. Returns ------- Optional[QtPoll] The new QtPoll instance, if we need one. """ if not config.async_octree and not config.monitor: return None from napari._qt.experimental.qt_poll import QtPoll qt_poll = QtPoll(parent) camera.events.connect(qt_poll.on_camera) return qt_poll def _create_remote_manager( layers: LayerList, qt_poll ) -> Optional[RemoteManager]: """Create and return a RemoteManager instance, if we need one. Parameters ---------- layers : LayersList The viewer's layers. qt_poll : QtPoll The viewer's QtPoll instance. """ if not config.monitor: return None # Not using the monitor at all from napari.components.experimental.monitor import monitor from napari.components.experimental.remote import RemoteManager # Start the monitor so we can access its events. The monitor has no # dependencies to napari except to utils.Event. started = monitor.start() if not started: return None # Probably not >= Python 3.9, so no manager is needed. # Create the remote manager and have monitor call its process_command() # method to execute commands from clients. manager = RemoteManager(layers) # RemoteManager will process incoming command from the monitor. monitor.run_command_event.connect(manager.process_command) # QtPoll should pool the RemoteManager and the Monitor. qt_poll.events.poll.connect(manager.on_poll) qt_poll.events.poll.connect(monitor.on_poll) return manager napari-0.5.0a1/napari/_qt/qthreading.py000066400000000000000000000314371437041365600177660ustar00rootroot00000000000000import inspect import warnings from functools import partial, wraps from types import FunctionType, GeneratorType from typing import ( Callable, Dict, List, Optional, Sequence, Type, TypeVar, Union, ) from superqt.utils import _qthreading from typing_extensions import ParamSpec from napari.utils.progress import progress from napari.utils.translations import trans wait_for_workers_to_quit = _qthreading.WorkerBase.await_workers class _NotifyingMixin: def __init__(self: _qthreading.WorkerBase, *args, **kwargs) -> None: # type: ignore super().__init__(*args, **kwargs) # type: ignore self.errored.connect(self._relay_error) self.warned.connect(self._relay_warning) def _relay_error(self, exc: Exception): from napari.utils.notifications import notification_manager notification_manager.receive_error(type(exc), exc, exc.__traceback__) def _relay_warning(self, show_warn_args: tuple): from napari.utils.notifications import notification_manager notification_manager.receive_warning(*show_warn_args) _Y = TypeVar("_Y") _S = TypeVar("_S") _R = TypeVar("_R") _P = ParamSpec("_P") class FunctionWorker(_qthreading.FunctionWorker[_R], _NotifyingMixin): ... class GeneratorWorker( _qthreading.GeneratorWorker[_Y, _S, _R], _NotifyingMixin ): ... # these are re-implemented from superqt just to provide progress def create_worker( func: Union[FunctionType, GeneratorType], *args, _start_thread: Optional[bool] = None, _connect: Optional[Dict[str, Union[Callable, Sequence[Callable]]]] = None, _progress: Optional[Union[bool, Dict[str, Union[int, bool, str]]]] = None, _worker_class: Union[ Type[GeneratorWorker], Type[FunctionWorker], None ] = None, _ignore_errors: bool = False, **kwargs, ) -> Union[FunctionWorker, GeneratorWorker]: """Convenience function to start a function in another thread. By default, uses :class:`Worker`, but a custom ``WorkerBase`` subclass may be provided. If so, it must be a subclass of :class:`Worker`, which defines a standard set of signals and a run method. Parameters ---------- func : Callable The function to call in another thread. _start_thread : bool, optional Whether to immediaetly start the thread. If False, the returned worker must be manually started with ``worker.start()``. by default it will be ``False`` if the ``_connect`` argument is ``None``, otherwise ``True``. _connect : Dict[str, Union[Callable, Sequence]], optional A mapping of ``"signal_name"`` -> ``callable`` or list of ``callable``: callback functions to connect to the various signals offered by the worker class. by default None _progress : Union[bool, Dict[str, Union[int, bool, str]]], optional Can be True, to provide indeterminate progress bar, or dictionary. If dict, requires mapping of 'total' to number of expected yields. If total is not provided, progress bar will be indeterminate. Will connect progress bar update to yields and display this progress in the viewer. Can also take a mapping of 'desc' to the progress bar description. Progress bar will become indeterminate when number of yields exceeds 'total'. By default None. _worker_class : Type[WorkerBase], optional The :class`WorkerBase` to instantiate, by default :class:`FunctionWorker` will be used if ``func`` is a regular function, and :class:`GeneratorWorker` will be used if it is a generator. _ignore_errors : bool, optional If ``False`` (the default), errors raised in the other thread will be reraised in the main thread (makes debugging significantly easier). *args will be passed to ``func`` **kwargs will be passed to ``func`` Returns ------- worker : WorkerBase An instantiated worker. If ``_start_thread`` was ``False``, the worker will have a `.start()` method that can be used to start the thread. Raises ------ TypeError If a worker_class is provided that is not a subclass of WorkerBase. TypeError If _connect is provided and is not a dict of ``{str: callable}`` TypeError If _progress is provided and function is not a generator Examples -------- .. code-block:: python def long_function(duration): import time time.sleep(duration) worker = create_worker(long_function, 10) """ # provide our own classes with the notification mixins if not _worker_class: if inspect.isgeneratorfunction(func): _worker_class = GeneratorWorker else: _worker_class = FunctionWorker worker = _qthreading.create_worker( func, *args, _start_thread=False, _connect=_connect, _worker_class=_worker_class, _ignore_errors=_ignore_errors, **kwargs, ) # either True or a non-empty dictionary if _progress: if isinstance(_progress, bool): _progress = {} desc = _progress.get('desc', None) total = int(_progress.get('total', 0)) if isinstance(worker, FunctionWorker) and total != 0: warnings.warn( trans._( "_progress total != 0 but worker is FunctionWorker and will not yield. Returning indeterminate progress bar...", deferred=True, ), RuntimeWarning, ) total = 0 with progress._all_instances.events.changed.blocker(): pbar = progress(total=total, desc=desc) worker.started.connect( partial( lambda prog: progress._all_instances.events.changed( added={prog}, removed={} ), pbar, ) ) worker.finished.connect(pbar.close) if total != 0 and isinstance(worker, GeneratorWorker): worker.yielded.connect(pbar.increment_with_overflow) worker.pbar = pbar if _start_thread is None: _start_thread = _connect is not None if _start_thread: worker.start() return worker def thread_worker( function: Optional[Callable] = None, start_thread: Optional[bool] = None, connect: Optional[Dict[str, Union[Callable, Sequence[Callable]]]] = None, progress: Optional[Union[bool, Dict[str, Union[int, bool, str]]]] = None, worker_class: Union[ Type[FunctionWorker], Type[GeneratorWorker], None ] = None, ignore_errors: bool = False, ): """Decorator that runs a function in a separate thread when called. When called, the decorated function returns a :class:`WorkerBase`. See :func:`create_worker` for additional keyword arguments that can be used when calling the function. The returned worker will have these signals: - *started*: emitted when the work is started - *finished*: emitted when the work is finished - *returned*: emitted with return value - *errored*: emitted with error object on Exception It will also have a ``worker.start()`` method that can be used to start execution of the function in another thread. (useful if you need to connect callbacks to signals prior to execution) If the decorated function is a generator, the returned worker will also provide these signals: - *yielded*: emitted with yielded values - *paused*: emitted when a running job has successfully paused - *resumed*: emitted when a paused job has successfully resumed - *aborted*: emitted when a running job is successfully aborted And these methods: - *quit*: ask the thread to quit - *toggle_paused*: toggle the running state of the thread. - *send*: send a value into the generator. (This requires that your decorator function uses the ``value = yield`` syntax) Parameters ---------- function : callable Function to call in another thread. For communication between threads may be a generator function. start_thread : bool, optional Whether to immediaetly start the thread. If False, the returned worker must be manually started with ``worker.start()``. by default it will be ``False`` if the ``_connect`` argument is ``None``, otherwise ``True``. connect : Dict[str, Union[Callable, Sequence]], optional A mapping of ``"signal_name"`` -> ``callable`` or list of ``callable``: callback functions to connect to the various signals offered by the worker class. by default None progress : Union[bool, Dict[str, Union[int, bool, str]]], optional Can be True, to provide indeterminate progress bar, or dictionary. If dict, requires mapping of 'total' to number of expected yields. If total is not provided, progress bar will be indeterminate. Will connect progress bar update to yields and display this progress in the viewer. Can also take a mapping of 'desc' to the progress bar description. Progress bar will become indeterminate when number of yields exceeds 'total'. By default None. Must be used in conjunction with a generator function. worker_class : Type[WorkerBase], optional The :class`WorkerBase` to instantiate, by default :class:`FunctionWorker` will be used if ``func`` is a regular function, and :class:`GeneratorWorker` will be used if it is a generator. ignore_errors : bool, optional If ``False`` (the default), errors raised in the other thread will be reraised in the main thread (makes debugging significantly easier). Returns ------- callable function that creates a worker, puts it in a new thread and returns the worker instance. Examples -------- .. code-block:: python @thread_worker def long_function(start, end): # do work, periodically yielding i = start while i <= end: time.sleep(0.1) yield i # do teardown return 'anything' # call the function to start running in another thread. worker = long_function() # connect signals here if desired... or they may be added using the # `connect` argument in the `@thread_worker` decorator... in which # case the worker will start immediately when long_function() is called worker.start() """ def _inner(func): @wraps(func) def worker_function(*args, **kwargs): # decorator kwargs can be overridden at call time by using the # underscore-prefixed version of the kwarg. kwargs['_start_thread'] = kwargs.get('_start_thread', start_thread) kwargs['_connect'] = kwargs.get('_connect', connect) kwargs['_progress'] = kwargs.get('_progress', progress) kwargs['_worker_class'] = kwargs.get('_worker_class', worker_class) kwargs['_ignore_errors'] = kwargs.get( '_ignore_errors', ignore_errors ) return create_worker( func, *args, **kwargs, ) return worker_function return _inner if function is None else _inner(function) _new_worker_qthread = _qthreading.new_worker_qthread def _add_worker_data(worker: FunctionWorker, return_type, source=None): from napari._app_model.injection import _processors cb = _processors._add_layer_data_to_viewer worker.signals.returned.connect( partial(cb, return_type=return_type, source=source) ) def _add_worker_data_from_tuple( worker: FunctionWorker, return_type, source=None ): from napari._app_model.injection import _processors cb = _processors._add_layer_data_tuples_to_viewer worker.signals.returned.connect( partial(cb, return_type=return_type, source=source) ) def register_threadworker_processors(): from functools import partial import magicgui from napari import layers, types from napari._app_model import get_app from napari.types import LayerDataTuple from napari.utils import _magicgui as _mgui app = get_app() for _type in (LayerDataTuple, List[LayerDataTuple]): t = FunctionWorker[_type] magicgui.register_type(t, return_callback=_mgui.add_worker_data) app.injection_store.register( processors={t: _add_worker_data_from_tuple} ) for layer_name in layers.NAMES: _type = getattr(types, f'{layer_name.title()}Data') t = FunctionWorker[_type] magicgui.register_type( t, return_callback=partial(_mgui.add_worker_data, _from_tuple=False), ) app.injection_store.register(processors={t: _add_worker_data}) napari-0.5.0a1/napari/_qt/utils.py000066400000000000000000000351201437041365600167710ustar00rootroot00000000000000from __future__ import annotations import re import signal import socket import weakref from contextlib import contextmanager from functools import lru_cache, partial from typing import Iterable, Sequence, Union import numpy as np import qtpy from qtpy.QtCore import ( QByteArray, QCoreApplication, QPoint, QPropertyAnimation, QSize, QSocketNotifier, Qt, QThread, ) from qtpy.QtGui import QColor, QCursor, QDrag, QImage, QPainter, QPen, QPixmap from qtpy.QtWidgets import ( QGraphicsColorizeEffect, QGraphicsOpacityEffect, QHBoxLayout, QListWidget, QVBoxLayout, QWidget, ) from napari.utils.colormaps.standardize_color import transform_color from napari.utils.events.custom_types import Array from napari.utils.misc import is_sequence from napari.utils.translations import trans QBYTE_FLAG = "!QBYTE_" RICH_TEXT_PATTERN = re.compile("<[^\n]+>") def is_qbyte(string: str) -> bool: """Check if a string is a QByteArray string. Parameters ---------- string : bool State string. """ return isinstance(string, str) and string.startswith(QBYTE_FLAG) def qbytearray_to_str(qbyte: QByteArray) -> str: """Convert a window state to a string. Used for restoring the state of the main window. Parameters ---------- qbyte : QByteArray State array. """ return QBYTE_FLAG + qbyte.toBase64().data().decode() def str_to_qbytearray(string: str) -> QByteArray: """Convert a string to a QbyteArray. Used for restoring the state of the main window. Parameters ---------- string : str State string. """ if len(string) < len(QBYTE_FLAG) or not is_qbyte(string): raise ValueError( trans._( "Invalid QByte string. QByte strings start with '{QBYTE_FLAG}'", QBYTE_FLAG=QBYTE_FLAG, ) ) return QByteArray.fromBase64(string[len(QBYTE_FLAG) :].encode()) def QImg2array(img) -> np.ndarray: """Convert QImage to an array. Parameters ---------- img : qtpy.QtGui.QImage QImage to be converted. Returns ------- arr : array Numpy array of type ubyte and shape (h, w, 4). Index [0, 0] is the upper-left corner of the rendered region. """ # Fix when image is provided in wrong format (ex. test on Azure pipelines) if img.format() != QImage.Format_ARGB32: img = img.convertToFormat(QImage.Format_ARGB32) b = img.constBits() h, w, c = img.height(), img.width(), 4 # As vispy doesn't use qtpy we need to reconcile the differences # between the `QImage` API for `PySide2` and `PyQt5` on how to convert # a QImage to a numpy array. if qtpy.API_NAME.startswith('PySide'): arr = np.array(b).reshape(h, w, c) else: b.setsize(h * w * c) arr = np.frombuffer(b, np.uint8).reshape(h, w, c) # Format of QImage is ARGB32_Premultiplied, but color channels are # reversed. arr = arr[:, :, [2, 1, 0, 3]] return arr @contextmanager def qt_signals_blocked(obj): """Context manager to temporarily block signals from `obj`""" previous = obj.blockSignals(True) try: yield finally: obj.blockSignals(previous) @contextmanager def event_hook_removed(): """Context manager to temporarily remove the PyQt5 input hook""" from qtpy import QtCore if hasattr(QtCore, 'pyqtRemoveInputHook'): QtCore.pyqtRemoveInputHook() try: yield finally: if hasattr(QtCore, 'pyqtRestoreInputHook'): QtCore.pyqtRestoreInputHook() def set_widgets_enabled_with_opacity( parent: QWidget, widgets: Iterable[QWidget], enabled: bool ): """Set enabled state on some widgets. If not enabled, decrease opacity.""" for widget in widgets: widget.setEnabled(enabled) op = QGraphicsOpacityEffect(parent) op.setOpacity(1 if enabled else 0.5) widget.setGraphicsEffect(op) @lru_cache(maxsize=64) def square_pixmap(size): """Create a white/black hollow square pixmap. For use as labels cursor.""" size = max(int(size), 1) pixmap = QPixmap(QSize(size, size)) pixmap.fill(Qt.GlobalColor.transparent) painter = QPainter(pixmap) painter.setPen(Qt.GlobalColor.white) painter.drawRect(0, 0, size - 1, size - 1) painter.setPen(Qt.GlobalColor.black) painter.drawRect(1, 1, size - 3, size - 3) painter.end() return pixmap @lru_cache(maxsize=64) def crosshair_pixmap(): """Create a cross cursor with white/black hollow square pixmap in the middle. For use as points cursor.""" size = 25 pixmap = QPixmap(QSize(size, size)) pixmap.fill(Qt.GlobalColor.transparent) painter = QPainter(pixmap) # Base measures width = 1 center = 3 # Must be odd! rect_size = center + 2 * width square = rect_size + width * 4 pen = QPen(Qt.GlobalColor.white, 1) pen.setJoinStyle(Qt.PenJoinStyle.MiterJoin) painter.setPen(pen) # # Horizontal rectangle painter.drawRect(0, (size - rect_size) // 2, size - 1, rect_size - 1) # Vertical rectangle painter.drawRect((size - rect_size) // 2, 0, rect_size - 1, size - 1) # Square painter.drawRect( (size - square) // 2, (size - square) // 2, square - 1, square - 1 ) pen = QPen(Qt.GlobalColor.black, 2) pen.setJoinStyle(Qt.PenJoinStyle.MiterJoin) painter.setPen(pen) # # Square painter.drawRect( (size - square) // 2 + 2, (size - square) // 2 + 2, square - 4, square - 4, ) pen = QPen(Qt.GlobalColor.black, 3) pen.setJoinStyle(Qt.PenJoinStyle.MiterJoin) painter.setPen(pen) # # # Horizontal lines mid_vpoint = QPoint(2, size // 2) painter.drawLine( mid_vpoint, QPoint(((size - center) // 2) - center + 1, size // 2) ) mid_vpoint = QPoint(size - 3, size // 2) painter.drawLine( mid_vpoint, QPoint(((size - center) // 2) + center + 1, size // 2) ) # # # Vertical lines mid_hpoint = QPoint(size // 2, 2) painter.drawLine( QPoint(size // 2, ((size - center) // 2) - center + 1), mid_hpoint ) mid_hpoint = QPoint(size // 2, size - 3) painter.drawLine( QPoint(size // 2, ((size - center) // 2) + center + 1), mid_hpoint ) painter.end() return pixmap @lru_cache(maxsize=64) def circle_pixmap(size: int): """Create a white/black hollow circle pixmap. For use as labels cursor.""" size = max(size, 1) pixmap = QPixmap(QSize(size, size)) pixmap.fill(Qt.GlobalColor.transparent) painter = QPainter(pixmap) painter.setPen(Qt.GlobalColor.white) painter.drawEllipse(0, 0, size - 1, size - 1) painter.setPen(Qt.GlobalColor.black) painter.drawEllipse(1, 1, size - 3, size - 3) painter.end() return pixmap def drag_with_pixmap(list_widget: QListWidget) -> QDrag: """Create a QDrag object with a pixmap of the currently select list item. This method is useful when you have a QListWidget that displays custom widgets for each QListWidgetItem instance in the list (usually by calling ``QListWidget.setItemWidget(item, widget)``). When used in a ``QListWidget.startDrag`` method, this function creates a QDrag object that shows an image of the item being dragged (rather than an empty rectangle). Parameters ---------- list_widget : QListWidget The QListWidget for which to create a QDrag object. Returns ------- QDrag A QDrag instance with a pixmap of the currently selected item. Examples -------- >>> class QListWidget: ... def startDrag(self, supportedActions): ... drag = drag_with_pixmap(self) ... drag.exec_(supportedActions, Qt.MoveAction) """ drag = QDrag(list_widget) drag.setMimeData(list_widget.mimeData(list_widget.selectedItems())) size = list_widget.viewport().visibleRegion().boundingRect().size() pixmap = QPixmap(size) pixmap.fill(Qt.GlobalColor.transparent) painter = QPainter(pixmap) for index in list_widget.selectedIndexes(): rect = list_widget.visualRect(index) painter.drawPixmap(rect, list_widget.viewport().grab(rect)) painter.end() drag.setPixmap(pixmap) drag.setHotSpot(list_widget.viewport().mapFromGlobal(QCursor.pos())) return drag def combine_widgets( widgets: Union[QWidget, Sequence[QWidget]], vertical: bool = False ) -> QWidget: """Combine a list of widgets into a single QWidget with Layout. Parameters ---------- widgets : QWidget or sequence of QWidget A widget or a list of widgets to combine. vertical : bool, optional Whether the layout should be QVBoxLayout or not, by default QHBoxLayout is used Returns ------- QWidget If ``widgets`` is a sequence, returns combined QWidget with `.layout` property, otherwise returns the original widget. Raises ------ TypeError If ``widgets`` is neither a ``QWidget`` or a sequence of ``QWidgets``. """ if isinstance(getattr(widgets, 'native', None), QWidget): # compatibility with magicgui v0.2.0 which no longer uses QWidgets # directly. Like vispy, the backend widget is at widget.native return widgets.native # type: ignore elif isinstance(widgets, QWidget): return widgets elif is_sequence(widgets): # the same as above, compatibility with magicgui v0.2.0 widgets = [ i.native if isinstance(getattr(i, 'native', None), QWidget) else i for i in widgets ] if all(isinstance(i, QWidget) for i in widgets): container = QWidget() container.setLayout(QVBoxLayout() if vertical else QHBoxLayout()) for widget in widgets: container.layout().addWidget(widget) return container raise TypeError( trans._('"widget" must be a QWidget or a sequence of QWidgets') ) def add_flash_animation( widget: QWidget, duration: int = 300, color: Array = (0.5, 0.5, 0.5, 0.5) ): """Add flash animation to widget to highlight certain action (e.g. taking a screenshot). Parameters ---------- widget : QWidget Any Qt widget. duration : int Duration of the flash animation. color : Array Color of the flash animation. By default, we use light gray. """ color = transform_color(color)[0] color = (255 * color).astype("int") effect = QGraphicsColorizeEffect(widget) widget.setGraphicsEffect(effect) widget._flash_animation = QPropertyAnimation(effect, b"color") widget._flash_animation.setStartValue(QColor(0, 0, 0, 0)) widget._flash_animation.setEndValue(QColor(0, 0, 0, 0)) widget._flash_animation.setLoopCount(1) # let's make sure to remove the animation from the widget because # if we don't, the widget will actually be black and white. widget._flash_animation.finished.connect( partial(remove_flash_animation, weakref.ref(widget)) ) widget._flash_animation.start() # now set an actual time for the flashing and an intermediate color widget._flash_animation.setDuration(duration) widget._flash_animation.setKeyValueAt(0.1, QColor(*color)) def remove_flash_animation(widget_ref: weakref.ref[QWidget]): """Remove flash animation from widget. Parameters ---------- widget_ref : QWidget Any Qt widget. """ if widget_ref() is None: return widget = widget_ref() try: widget.setGraphicsEffect(None) del widget._flash_animation except RuntimeError: # RuntimeError: wrapped C/C++ object of type QtWidgetOverlay deleted pass @contextmanager def _maybe_allow_interrupt(qapp): """ This manager allows to terminate a plot by sending a SIGINT. It is necessary because the running Qt backend prevents Python interpreter to run and process signals (i.e., to raise KeyboardInterrupt exception). To solve this one needs to somehow wake up the interpreter and make it close the plot window. We do this by using the signal.set_wakeup_fd() function which organizes a write of the signal number into a socketpair connected to the QSocketNotifier (since it is part of the Qt backend, it can react to that write event). Afterwards, the Qt handler empties the socketpair by a recv() command to re-arm it (we need this if a signal different from SIGINT was caught by set_wakeup_fd() and we shall continue waiting). If the SIGINT was caught indeed, after exiting the on_signal() function the interpreter reacts to the SIGINT according to the handle() function which had been set up by a signal.signal() call: it causes the qt_object to exit by calling its quit() method. Finally, we call the old SIGINT handler with the same arguments that were given to our custom handle() handler. We do this only if the old handler for SIGINT was not None, which means that a non-python handler was installed, i.e. in Julia, and not SIG_IGN which means we should ignore the interrupts. code from https://github.com/matplotlib/matplotlib/pull/13306 """ old_sigint_handler = signal.getsignal(signal.SIGINT) handler_args = None if old_sigint_handler in (None, signal.SIG_IGN, signal.SIG_DFL): yield return wsock, rsock = socket.socketpair() wsock.setblocking(False) old_wakeup_fd = signal.set_wakeup_fd(wsock.fileno()) sn = QSocketNotifier(rsock.fileno(), QSocketNotifier.Type.Read) # Clear the socket to re-arm the notifier. sn.activated.connect(lambda *args: rsock.recv(1)) def handle(*args): nonlocal handler_args handler_args = args qapp.exit() signal.signal(signal.SIGINT, handle) try: yield finally: wsock.close() rsock.close() sn.setEnabled(False) signal.set_wakeup_fd(old_wakeup_fd) signal.signal(signal.SIGINT, old_sigint_handler) if handler_args is not None: old_sigint_handler(*handler_args) def qt_might_be_rich_text(text) -> bool: """ Check if a text might be rich text in a cross-binding compatible way. """ if qtpy.PYSIDE2: from qtpy.QtGui import Qt as Qt_ else: from qtpy.QtCore import Qt as Qt_ try: return Qt_.mightBeRichText(text) except AttributeError: return bool(RICH_TEXT_PATTERN.search(text)) def in_qt_main_thread(): """ Check if we are in the thread in which QApplication object was created. Returns ------- thread_flag : bool True if we are in the main thread, False otherwise. """ return QCoreApplication.instance().thread() == QThread.currentThread() napari-0.5.0a1/napari/_qt/widgets/000077500000000000000000000000001437041365600167245ustar00rootroot00000000000000napari-0.5.0a1/napari/_qt/widgets/__init__.py000066400000000000000000000000601437041365600210310ustar00rootroot00000000000000"""Custom widgets that inherit from QWidget.""" napari-0.5.0a1/napari/_qt/widgets/_slider_compat.py000066400000000000000000000006301437041365600222610ustar00rootroot00000000000000from qtpy import QT_VERSION from qtpy.QtWidgets import QSlider # noqa from superqt import QDoubleSlider # here until we can debug why labeled sliders render differently on 5.12 if tuple(int(x) for x in QT_VERSION.split(".")) >= (5, 14): from superqt import QLabeledDoubleSlider as QDoubleSlider # noqa from superqt import QLabeledSlider as QSlider # noqa __all__ = ["QSlider", "QDoubleSlider"] napari-0.5.0a1/napari/_qt/widgets/_tests/000077500000000000000000000000001437041365600202255ustar00rootroot00000000000000napari-0.5.0a1/napari/_qt/widgets/_tests/__init__.py000066400000000000000000000000001437041365600223240ustar00rootroot00000000000000napari-0.5.0a1/napari/_qt/widgets/_tests/test_qt_buttons.py000066400000000000000000000024601437041365600240420ustar00rootroot00000000000000from napari._qt.widgets.qt_mode_buttons import ( QtModePushButton, QtModeRadioButton, ) from napari.layers import Points from napari.layers.points._points_constants import Mode def test_radio_button(qtbot): """Make sure the QtModeRadioButton works to change layer modes""" layer = Points() assert layer.mode != Mode.ADD btn = QtModeRadioButton(layer, 'test_button', Mode.ADD, tooltip='tooltip') assert btn.property('mode') == 'test_button' assert btn.toolTip() == 'tooltip' btn.click() qtbot.waitUntil(lambda: layer.mode == Mode.ADD, timeout=500) def test_push_button(qtbot): """Make sure the QtModePushButton works with callbacks""" layer = Points() layer.test_prop = False def set_test_prop(): layer.test_prop = True btn = QtModePushButton( layer, 'test_button', slot=set_test_prop, tooltip='tooltip' ) assert btn.property('mode') == 'test_button' assert btn.toolTip() == 'tooltip' btn.click() qtbot.waitUntil(lambda: layer.test_prop, timeout=500) def test_layers_button_works(make_napari_viewer): v = make_napari_viewer() layer = v.add_layer(Points()) assert layer.mode != "add" controls = v.window._qt_viewer.controls.widgets[layer] controls.addition_button.click() assert layer.mode == "add" napari-0.5.0a1/napari/_qt/widgets/_tests/test_qt_color_swatch.py000066400000000000000000000021231437041365600250270ustar00rootroot00000000000000import numpy import pytest from napari._qt.widgets.qt_color_swatch import ( TRANSPARENT, QColorSwatch, QColorSwatchEdit, ) @pytest.mark.parametrize('color', [None, [1, 1, 1, 1]]) @pytest.mark.parametrize('tooltip', [None, 'This is a test']) def test_succesfull_create_qcolorswatchedit(qtbot, color, tooltip): widget = QColorSwatchEdit(initial_color=color, tooltip=tooltip) qtbot.add_widget(widget) test_color = color or TRANSPARENT test_tooltip = tooltip or 'click to set color' assert widget.color_swatch.toolTip() == test_tooltip numpy.testing.assert_array_equal(widget.color, test_color) @pytest.mark.parametrize('color', [None, [1, 1, 1, 1]]) @pytest.mark.parametrize('tooltip', [None, 'This is a test']) def test_succesfull_create_qcolorswatch(qtbot, color, tooltip): widget = QColorSwatch(initial_color=color, tooltip=tooltip) qtbot.add_widget(widget) test_color = color or TRANSPARENT test_tooltip = tooltip or 'click to set color' assert widget.toolTip() == test_tooltip numpy.testing.assert_array_equal(widget.color, test_color) napari-0.5.0a1/napari/_qt/widgets/_tests/test_qt_dims.py000066400000000000000000000252501437041365600233020ustar00rootroot00000000000000import os from sys import platform from unittest.mock import patch import numpy as np import pytest from qtpy.QtCore import Qt from napari._qt.widgets.qt_dims import QtDims from napari.components import Dims def test_creating_view(qtbot): """ Test creating dims view. """ ndim = 4 dims = Dims(ndim=ndim) view = QtDims(dims) qtbot.addWidget(view) # Check that the dims model has been appended to the dims view assert view.dims == dims # Check the number of displayed sliders is two less than the number of # dimensions assert view.nsliders == view.dims.ndim assert np.sum(view._displayed_sliders) == view.dims.ndim - 2 assert np.all( [ s.isVisibleTo(view) == d for s, d in zip(view.slider_widgets, view._displayed_sliders) ] ) def test_changing_ndim(qtbot): """ Test changing the number of dimensions """ ndim = 4 view = QtDims(Dims(ndim=ndim)) qtbot.addWidget(view) # Check that adding dimensions adds sliders view.dims.ndim = 5 assert view.nsliders == view.dims.ndim assert np.sum(view._displayed_sliders) == view.dims.ndim - 2 assert np.all( [ s.isVisibleTo(view) == d for s, d in zip(view.slider_widgets, view._displayed_sliders) ] ) # Check that removing dimensions removes sliders view.dims.ndim = 2 assert view.nsliders == view.dims.ndim assert np.sum(view._displayed_sliders) == view.dims.ndim - 2 assert np.all( [ s.isVisibleTo(view) == d for s, d in zip(view.slider_widgets, view._displayed_sliders) ] ) def test_changing_focus(qtbot): """Test changing focus updates the dims.last_used prop.""" # Initialize to 0th axis ndim = 2 view = QtDims(Dims(ndim=ndim)) qtbot.addWidget(view) assert view.dims.last_used == 0 view.dims._focus_down() view.dims._focus_up() assert view.dims.last_used == 0 view.dims.ndim = 5 view.dims.last_used = 2 assert view.dims.last_used == 2 view.dims._focus_down() assert view.dims.last_used == 1 view.dims._focus_up() assert view.dims.last_used == 2 view.dims._focus_up() assert view.dims.last_used == 0 view.dims._focus_down() assert view.dims.last_used == 2 def test_changing_display(qtbot): """ Test changing the displayed property of an axis """ ndim = 4 view = QtDims(Dims(ndim=ndim)) qtbot.addWidget(view) assert view.nsliders == view.dims.ndim assert np.sum(view._displayed_sliders) == view.dims.ndim - 2 assert np.all( [ s.isVisibleTo(view) == d for s, d in zip(view.slider_widgets, view._displayed_sliders) ] ) # Check changing displayed removes a slider view.dims.ndisplay = 3 assert view.nsliders == view.dims.ndim assert np.sum(view._displayed_sliders) == view.dims.ndim - 3 assert np.all( [ s.isVisibleTo(view) == d for s, d in zip(view.slider_widgets, view._displayed_sliders) ] ) def test_slider_values(qtbot): """ Test the values of a slider stays matched to the values of the dims point. """ ndim = 4 view = QtDims(Dims(ndim=ndim)) qtbot.addWidget(view) # Check that values of the dimension slider matches the values of the # dims point at initialization first_slider = view.slider_widgets[0].slider assert first_slider.value() == view.dims.point[0] # Check that values of the dimension slider matches the values of the # dims point after the point has been moved within the dims view.dims.set_point(0, 2) assert first_slider.value() == view.dims.point[0] # Check that values of the dimension slider matches the values of the # dims point after the point has been moved within the slider first_slider.setValue(1) assert first_slider.value() == view.dims.point[0] def test_slider_range(qtbot): """ Tests range of the slider is matched to the range of the dims """ ndim = 4 view = QtDims(Dims(ndim=ndim)) qtbot.addWidget(view) # Check the maximum allowed value of the slider is one less # than the allowed nsteps of the dims at initialization first_slider = view.slider_widgets[0].slider assert first_slider.minimum() == 0 assert first_slider.maximum() == view.dims.nsteps[0] - 1 assert first_slider.singleStep() == 1 # Check the maximum allowed value of the slider stays one less # than the allowed nsteps of the dims after updates view.dims.set_range(0, (1, 5, 2)) assert first_slider.minimum() == 0 assert first_slider.maximum() == view.dims.nsteps[0] - 1 assert first_slider.singleStep() == 1 def test_singleton_dims(qtbot): """ Test singleton dims causes no slider. """ ndim = 4 dims = Dims(ndim=ndim) dims.set_range(0, (0, 1, 1)) view = QtDims(dims) qtbot.addWidget(view) # Check that the dims model has been appended to the dims view assert view.dims == dims # Check the number of displayed sliders is only one assert view.nsliders == 4 assert np.sum(view._displayed_sliders) == 1 assert np.all( [ s.isVisibleTo(view) == d for s, d in zip(view.slider_widgets, view._displayed_sliders) ] ) # Change ndisplay to three view.dims.ndisplay = 3 # Check no sliders now shown assert np.sum(view._displayed_sliders) == 0 # Change ndisplay back to two view.dims.ndisplay = 2 # Check only slider now shown assert np.sum(view._displayed_sliders) == 1 def test_order_when_changing_ndim(qtbot): """ Test order of the sliders when changing the number of dimensions. """ ndim = 4 view = QtDims(Dims(ndim=ndim)) qtbot.addWidget(view) # Check that values of the dimension slider matches the values of the # dims point after the point has been moved within the dims view.dims.set_point(0, 2) view.dims.set_point(1, 1) for i in range(view.dims.ndim - 2): slider = view.slider_widgets[i].slider assert slider.value() == view.dims.point[i] # Check the matching dimensions and sliders are preserved when # dimensions are added view.dims.ndim = 5 for i in range(view.dims.ndim - 2): slider = view.slider_widgets[i].slider assert slider.value() == view.dims.point[i] # Check the matching dimensions and sliders are preserved when dims # dimensions are removed view.dims.ndim = 4 for i in range(view.dims.ndim - 2): slider = view.slider_widgets[i].slider assert slider.value() == view.dims.point[i] # Check the matching dimensions and sliders are preserved when dims # dimensions are removed view.dims.ndim = 3 for i in range(view.dims.ndim - 2): slider = view.slider_widgets[i].slider assert slider.value() == view.dims.point[i] def test_update_dims_labels(qtbot): """ Test that the slider_widget axis labels are updated with the dims model and vice versa. """ ndim = 4 view = QtDims(Dims(ndim=ndim)) qtbot.addWidget(view) view.dims.axis_labels = list('TZYX') assert [w.axis_label.text() for w in view.slider_widgets] == list('TZYX') first_label = view.slider_widgets[0].axis_label assert first_label.text() == view.dims.axis_labels[0] first_label.setText('napari') # first_label.editingFinished.emit() assert first_label.text() == view.dims.axis_labels[0] def test_slider_press_updates_last_used(qtbot): """pressing on the slider should update the dims.last_used property""" ndim = 5 view = QtDims(Dims(ndim=ndim)) qtbot.addWidget(view) for i, widg in enumerate(view.slider_widgets): widg.slider.sliderPressed.emit() assert view.dims.last_used == i @pytest.mark.skipif( os.environ.get('CI') and platform == 'win32', reason='not working in windows VM', ) def test_play_button(qtbot): """test that the play button and its popup dialog work""" ndim = 3 view = QtDims(Dims(ndim=ndim)) qtbot.addWidget(view) slider = view.slider_widgets[0] button = slider.play_button # Need looping playback so that it does not stop before we can assert that. assert slider.loop_mode == 'loop' assert not view.is_playing qtbot.mouseClick(button, Qt.LeftButton) qtbot.waitUntil(lambda: view.is_playing) qtbot.mouseClick(button, Qt.LeftButton) qtbot.waitUntil(lambda: not view.is_playing) qtbot.waitUntil(lambda: view._animation_worker is None) with patch.object(button.popup, 'show_above_mouse') as mock_popup: qtbot.mouseClick(button, Qt.RightButton) mock_popup.assert_called_once() def test_slice_labels(qtbot): ndim = 4 dims = Dims(ndim=ndim) dims.set_range(0, (0, 20, 1)) view = QtDims(dims) qtbot.addWidget(view) # make sure the totslice_label is showing the correct number assert int(view.slider_widgets[0].totslice_label.text()) == 19 # make sure setting the dims.point updates the slice label label_edit = view.slider_widgets[0].curslice_label dims.set_point(0, 15) assert int(label_edit.text()) == 15 # make sure setting the current slice label updates the model label_edit.setText(str(8)) label_edit.editingFinished.emit() assert dims.point[0] == 8 def test_not_playing_after_ndim_changes(qtbot): """See https://github.com/napari/napari/issues/3998""" dims = Dims(ndim=3, ndisplay=2, range=((0, 10, 1), (0, 20, 1), (0, 30, 1))) view = QtDims(dims) qtbot.addWidget(view) # Loop to prevent finishing before the assertions in this test. view.play(loop_mode='loop') qtbot.waitUntil(lambda: view.is_playing) dims.ndim = 2 qtbot.waitUntil(lambda: not view.is_playing) qtbot.waitUntil(lambda: view._animation_worker is None) def test_not_playing_after_ndisplay_changes(qtbot): """See https://github.com/napari/napari/issues/3998""" dims = Dims(ndim=3, ndisplay=2, range=((0, 10, 1), (0, 20, 1), (0, 30, 1))) view = QtDims(dims) qtbot.addWidget(view) # Loop to prevent finishing before the assertions in this test. view.play(loop_mode='loop') qtbot.waitUntil(lambda: view.is_playing) dims.ndisplay = 3 qtbot.waitUntil(lambda: not view.is_playing) qtbot.waitUntil(lambda: view._animation_worker is None) def test_set_axis_labels_after_ndim_changes(qtbot): """See https://github.com/napari/napari/issues/3753""" dims = Dims(ndim=3, ndisplay=2) view = QtDims(dims) qtbot.addWidget(view) dims.ndim = 2 dims.axis_labels = ['y', 'x'] assert len(view.slider_widgets) == 2 assert view.slider_widgets[0].axis_label.text() == 'y' assert view.slider_widgets[1].axis_label.text() == 'x' napari-0.5.0a1/napari/_qt/widgets/_tests/test_qt_dims_sorter.py000066400000000000000000000014011437041365600246700ustar00rootroot00000000000000from napari._qt.widgets.qt_dims_sorter import QtDimsSorter from napari.components.viewer_model import ViewerModel def test_dims_sorter(qtbot): viewer = ViewerModel() dim_sorter = QtDimsSorter(viewer) qtbot.addWidget(dim_sorter) assert tuple(dim_sorter.axes_list) == (0, 1) viewer.dims.axis_labels = ('y', 'x') assert tuple(dim_sorter.axes_list) == ('y', 'x') dim_sorter.axes_list.move(1, 0) assert tuple(dim_sorter.axes_list) == ('x', 'y') assert tuple(viewer.dims.order) == (1, 0) def test_dims_sorter_with_reordered_init(qtbot): viewer = ViewerModel() viewer.dims.order = (1, 0) dim_sorter = QtDimsSorter(viewer) qtbot.addWidget(dim_sorter) assert tuple(dim_sorter.axes_list) == tuple(viewer.dims.order) napari-0.5.0a1/napari/_qt/widgets/_tests/test_qt_dock_widget.py000066400000000000000000000115051437041365600246270ustar00rootroot00000000000000import pytest from qtpy.QtWidgets import ( QDockWidget, QHBoxLayout, QPushButton, QTextEdit, QVBoxLayout, QWidget, ) def test_add_dock_widget(make_napari_viewer): """Test basic add_dock_widget functionality""" viewer = make_napari_viewer() widg = QPushButton('button') dwidg = viewer.window.add_dock_widget(widg, name='test', area='bottom') assert not dwidg.is_vertical assert viewer.window._qt_window.findChild(QDockWidget, 'test') assert dwidg.widget() == widg dwidg._on_visibility_changed(True) # smoke test widg2 = QPushButton('button') dwidg2 = viewer.window.add_dock_widget(widg2, name='test2', area='right') assert dwidg2.is_vertical assert viewer.window._qt_window.findChild(QDockWidget, 'test2') assert dwidg2.widget() == widg2 dwidg2._on_visibility_changed(True) # smoke test with pytest.raises(ValueError): # 'under' is not a valid area viewer.window.add_dock_widget(widg2, name='test2', area='under') with pytest.raises(ValueError): # 'under' is not a valid area viewer.window.add_dock_widget( widg2, name='test2', allowed_areas=['under'] ) with pytest.raises(TypeError): # allowed_areas must be a list viewer.window.add_dock_widget( widg2, name='test2', allowed_areas='under' ) def test_add_dock_widget_from_list(make_napari_viewer): """Test that we can add a list of widgets and they will be combined""" viewer = make_napari_viewer() widg = QPushButton('button') widg2 = QPushButton('button') dwidg = viewer.window.add_dock_widget( [widg, widg2], name='test', area='right' ) assert viewer.window._qt_window.findChild(QDockWidget, 'test') assert isinstance(dwidg.widget().layout(), QVBoxLayout) dwidg = viewer.window.add_dock_widget( [widg, widg2], name='test2', area='bottom' ) assert viewer.window._qt_window.findChild(QDockWidget, 'test2') assert isinstance(dwidg.widget().layout(), QHBoxLayout) def test_add_dock_widget_raises(make_napari_viewer): """Test that the widget passed must be a DockWidget.""" viewer = make_napari_viewer() widg = object() with pytest.raises(TypeError): viewer.window.add_dock_widget(widg, name='test') def test_remove_dock_widget_orphans_widget(make_napari_viewer): viewer = make_napari_viewer() widg = QPushButton('button') assert not widg.parent() dw = viewer.window.add_dock_widget( widg, name='test', menu=viewer.window.window_menu ) assert widg.parent() is dw assert dw.toggleViewAction() in viewer.window.window_menu.actions() viewer.window.remove_dock_widget(dw, menu=viewer.window.window_menu) assert dw.toggleViewAction() not in viewer.window.window_menu.actions() del dw # if dw didn't release widg, we'd get an exception when next accessing widg assert not widg.parent() def test_remove_dock_widget_by_widget_reference(make_napari_viewer): viewer = make_napari_viewer() widg = QPushButton('button') dw = viewer.window.add_dock_widget(widg, name='test') assert widg.parent() is dw assert dw in viewer.window._qt_window.findChildren(QDockWidget) viewer.window.remove_dock_widget(widg) with pytest.raises(LookupError): # it's gone this time: viewer.window.remove_dock_widget(widg) assert not widg.parent() def test_adding_modified_widget(make_napari_viewer): viewer = make_napari_viewer() widg = QWidget() # not uncommon to see people shadow the builtin layout() # which breaks our ability to add vertical stretch... but shouldn't crash widg.layout = None dw = viewer.window.add_dock_widget(widg, name='test', area='right') assert dw.widget() is widg def test_adding_stretch(make_napari_viewer): """Make sure that vertical stretch only gets added when appropriate.""" viewer = make_napari_viewer() # adding a widget to the left/right will usually addStretch to the layout widg = QWidget() widg.setLayout(QVBoxLayout()) widg.layout().addWidget(QPushButton()) assert widg.layout().count() == 1 dw = viewer.window.add_dock_widget(widg, area='right') assert widg.layout().count() == 2 dw.close() # ... unless the widget has a widget with a large vertical sizePolicy widg = QWidget() widg.setLayout(QVBoxLayout()) widg.layout().addWidget(QTextEdit()) assert widg.layout().count() == 1 dw = viewer.window.add_dock_widget(widg, area='right') assert widg.layout().count() == 1 dw.close() # ... widgets on the bottom do not get stretch widg = QWidget() widg.setLayout(QHBoxLayout()) widg.layout().addWidget(QPushButton()) assert widg.layout().count() == 1 dw = viewer.window.add_dock_widget(widg, area='bottom') assert widg.layout().count() == 1 dw.close() napari-0.5.0a1/napari/_qt/widgets/_tests/test_qt_extension2reader.py000066400000000000000000000157131437041365600256320ustar00rootroot00000000000000import pytest from npe2 import DynamicPlugin from qtpy.QtCore import Qt from qtpy.QtWidgets import QLabel, QPushButton from napari._qt.widgets.qt_extension2reader import Extension2ReaderTable from napari.settings import get_settings @pytest.fixture def extension2reader_widget(qtbot): def _extension2reader_widget(**kwargs): widget = Extension2ReaderTable(**kwargs) widget.show() qtbot.addWidget(widget) return widget return _extension2reader_widget @pytest.fixture def tif_reader(tmp_plugin: DynamicPlugin): tmp2 = tmp_plugin.spawn(name='tif_reader', register=True) @tmp2.contribute.reader(filename_patterns=['*.tif']) def _(path): ... return tmp2 @pytest.fixture def npy_reader(tmp_plugin: DynamicPlugin): tmp2 = tmp_plugin.spawn(name='npy_reader', register=True) @tmp2.contribute.reader(filename_patterns=['*.npy']) def _(path): ... return tmp2 def test_extension2reader_defaults( extension2reader_widget, ): get_settings().plugins.extension2reader = {} widget = extension2reader_widget() assert widget._table.rowCount() == 1 assert ( widget._table.itemAt(0, 0).text() == 'No filename preferences found.' ) def test_extension2reader_with_settings( extension2reader_widget, ): get_settings().plugins.extension2reader = {'.test': 'test-plugin'} widget = extension2reader_widget() assert widget._table.rowCount() == 1 assert widget._table.item(0, 0).text() == '.test' assert ( widget._table.cellWidget(0, 1).findChild(QLabel).text() == 'test-plugin' ) def test_extension2reader_removal(extension2reader_widget, qtbot): get_settings().plugins.extension2reader = { '.test': 'test-plugin', '.abc': 'abc-plugin', } widget = extension2reader_widget() assert widget._table.rowCount() == 2 btn_to_click = widget._table.cellWidget(0, 1).findChild(QPushButton) qtbot.mouseClick(btn_to_click, Qt.LeftButton) assert get_settings().plugins.extension2reader == {'.abc': 'abc-plugin'} assert widget._table.rowCount() == 1 assert widget._table.item(0, 0).text() == '.abc' # remove remaining extension btn_to_click = widget._table.cellWidget(0, 1).findChild(QPushButton) qtbot.mouseClick(btn_to_click, Qt.LeftButton) assert not get_settings().plugins.extension2reader assert widget._table.rowCount() == 1 assert "No filename preferences found" in widget._table.item(0, 0).text() def test_all_readers_in_dropdown( extension2reader_widget, tmp_plugin, tif_reader ): @tmp_plugin.contribute.reader(filename_patterns=['*']) def _(path): ... npe2_readers = { tmp_plugin.name: tmp_plugin.display_name, tif_reader.name: tif_reader.display_name, } widget = extension2reader_widget(npe2_readers=npe2_readers) all_dropdown_items = { widget._new_reader_dropdown.itemText(i) for i in range(widget._new_reader_dropdown.count()) } assert all(i in all_dropdown_items for i in npe2_readers.values()) def test_directory_readers_not_in_dropdown( extension2reader_widget, tmp_plugin ): @tmp_plugin.contribute.reader( filename_patterns=[], accepts_directories=True ) def f(path): ... widget = extension2reader_widget( npe2_readers={tmp_plugin.name: tmp_plugin.display_name}, npe1_readers={}, ) all_dropdown_items = [ widget._new_reader_dropdown.itemText(i) for i in range(widget._new_reader_dropdown.count()) ] assert tmp_plugin.display_name not in all_dropdown_items def test_filtering_readers( extension2reader_widget, builtins, tif_reader, npy_reader ): widget = extension2reader_widget( npe1_readers={builtins.display_name: builtins.display_name} ) assert widget._new_reader_dropdown.count() == 3 widget._filter_compatible_readers('*.npy') assert widget._new_reader_dropdown.count() == 2 all_dropdown_items = [ widget._new_reader_dropdown.itemText(i) for i in range(widget._new_reader_dropdown.count()) ] assert ( sorted([npy_reader.display_name, builtins.display_name]) == all_dropdown_items ) def test_filtering_readers_complex_pattern( extension2reader_widget, npy_reader, tif_reader ): @tif_reader.contribute.reader( filename_patterns=['my-specific-folder/*.tif'] ) def f(path): ... widget = extension2reader_widget(npe1_readers={}) assert widget._new_reader_dropdown.count() == 2 widget._filter_compatible_readers('my-specific-folder/my-file.tif') assert widget._new_reader_dropdown.count() == 1 all_dropdown_items = [ widget._new_reader_dropdown.itemText(i) for i in range(widget._new_reader_dropdown.count()) ] assert sorted([tif_reader.name]) == all_dropdown_items def test_adding_new_preference( extension2reader_widget, tif_reader, npy_reader ): widget = extension2reader_widget(npe1_readers={}) widget._fn_pattern_edit.setText('*.tif') # will be filtered and tif-reader will be only item widget._new_reader_dropdown.setCurrentIndex(0) get_settings().plugins.extension2reader = {} widget._save_new_preference(True) settings = get_settings().plugins.extension2reader assert '*.tif' in settings assert settings['*.tif'] == tif_reader.name assert ( widget._table.item(widget._table.rowCount() - 1, 0).text() == '*.tif' ) plugin_label = widget._table.cellWidget( widget._table.rowCount() - 1, 1 ).findChild(QLabel) assert plugin_label.text() == tif_reader.display_name def test_adding_new_preference_no_asterisk( extension2reader_widget, tif_reader, npy_reader ): widget = extension2reader_widget(npe1_readers={}) widget._fn_pattern_edit.setText('.tif') # will be filtered and tif-reader will be only item widget._new_reader_dropdown.setCurrentIndex(0) get_settings().plugins.extension2reader = {} widget._save_new_preference(True) settings = get_settings().plugins.extension2reader assert '*.tif' in settings assert settings['*.tif'] == tif_reader.name def test_editing_preference(extension2reader_widget, tif_reader): tiff2 = tif_reader.spawn(register=True) @tiff2.contribute.reader(filename_patterns=["*.tif"]) def ff(path): ... get_settings().plugins.extension2reader = {'*.tif': tif_reader.name} widget = extension2reader_widget() widget._fn_pattern_edit.setText('*.tif') # set to tiff2 widget._new_reader_dropdown.setCurrentText(tiff2.display_name) original_row_count = widget._table.rowCount() widget._save_new_preference(True) settings = get_settings().plugins.extension2reader assert '*.tif' in settings assert settings['*.tif'] == tiff2.name assert widget._table.rowCount() == original_row_count plugin_label = widget._table.cellWidget( original_row_count - 1, 1 ).findChild(QLabel) assert plugin_label.text() == tiff2.name napari-0.5.0a1/napari/_qt/widgets/_tests/test_qt_highlight_preview.py000066400000000000000000000136311437041365600260560ustar00rootroot00000000000000import pytest from napari._qt.widgets.qt_highlight_preview import ( QtHighlightSizePreviewWidget, QtStar, QtTriangle, ) @pytest.fixture def star_widget(qtbot): def _star_widget(**kwargs): widget = QtStar(**kwargs) widget.show() qtbot.addWidget(widget) return widget return _star_widget @pytest.fixture def triangle_widget(qtbot): def _triangle_widget(**kwargs): widget = QtTriangle(**kwargs) widget.show() qtbot.addWidget(widget) return widget return _triangle_widget @pytest.fixture def highlight_size_preview_widget(qtbot): def _highlight_size_preview_widget(**kwargs): widget = QtHighlightSizePreviewWidget(**kwargs) widget.show() qtbot.addWidget(widget) return widget return _highlight_size_preview_widget # QtStar # ---------------------------------------------------------------------------- def test_qt_star_defaults(star_widget): star_widget() def test_qt_star_value(star_widget): widget = star_widget(value=5) assert widget.value() <= 5 widget = star_widget() widget.setValue(5) assert widget.value() == 5 # QtTriangle # ---------------------------------------------------------------------------- def test_qt_triangle_defaults(triangle_widget): triangle_widget() def test_qt_triangle_value(triangle_widget): widget = triangle_widget(value=5) assert widget.value() <= 5 widget = triangle_widget() widget.setValue(5) assert widget.value() == 5 def test_qt_triangle_minimum(triangle_widget): minimum = 1 widget = triangle_widget(min_value=minimum) assert widget.minimum() == minimum assert widget.value() >= minimum widget = triangle_widget() widget.setMinimum(2) assert widget.minimum() == 2 assert widget.value() == 2 def test_qt_triangle_maximum(triangle_widget): maximum = 10 widget = triangle_widget(max_value=maximum) assert widget.maximum() == maximum assert widget.value() <= maximum widget = triangle_widget() widget.setMaximum(20) assert widget.maximum() == 20 def test_qt_triangle_signal(qtbot, triangle_widget): widget = triangle_widget() with qtbot.waitSignal(widget.valueChanged, timeout=500): widget.setValue(7) with qtbot.waitSignal(widget.valueChanged, timeout=500): widget.setValue(-5) # QtHighlightSizePreviewWidget # ---------------------------------------------------------------------------- def test_qt_highlight_size_preview_widget_defaults( highlight_size_preview_widget, ): highlight_size_preview_widget() def test_qt_highlight_size_preview_widget_description( highlight_size_preview_widget, ): description = "Some text" widget = highlight_size_preview_widget(description=description) assert widget.description() == description widget = highlight_size_preview_widget() widget.setDescription(description) assert widget.description() == description def test_qt_highlight_size_preview_widget_unit(highlight_size_preview_widget): unit = "CM" widget = highlight_size_preview_widget(unit=unit) assert widget.unit() == unit widget = highlight_size_preview_widget() widget.setUnit(unit) assert widget.unit() == unit def test_qt_highlight_size_preview_widget_minimum( highlight_size_preview_widget, ): minimum = 5 widget = highlight_size_preview_widget(min_value=minimum) assert widget.minimum() == minimum assert widget.value() >= minimum widget = highlight_size_preview_widget() widget.setMinimum(3) assert widget.minimum() == 3 assert widget.value() == 3 assert widget._slider.minimum() == 3 assert widget._slider_min_label.text() == "3" assert widget._triangle.minimum() == 3 assert widget._lineedit.text() == "3" def test_qt_highlight_size_preview_widget_minimum_invalid( highlight_size_preview_widget, ): widget = highlight_size_preview_widget() with pytest.raises(ValueError): widget.setMinimum(60) def test_qt_highlight_size_preview_widget_maximum( highlight_size_preview_widget, ): maximum = 10 widget = highlight_size_preview_widget(max_value=maximum) assert widget.maximum() == maximum assert widget.value() <= maximum widget = highlight_size_preview_widget() widget.setMaximum(20) assert widget.maximum() == 20 assert widget._slider.maximum() == 20 assert widget._triangle.maximum() == 20 assert widget._slider_max_label.text() == "20" widget.setMaximum(5) assert widget.maximum() == 5 # assert widget.value() == 5 # assert widget._slider.maximum() == 5 # assert widget._triangle.maximum() == 20 # assert widget._lineedit.text() == "5" # assert widget._slider_max_label.text() == "5" def test_qt_highlight_size_preview_widget_maximum_invalid( highlight_size_preview_widget, ): widget = highlight_size_preview_widget() with pytest.raises(ValueError): widget.setMaximum(-5) def test_qt_highlight_size_preview_widget_value(highlight_size_preview_widget): widget = highlight_size_preview_widget(value=5) assert widget.value() <= 5 widget = highlight_size_preview_widget() widget.setValue(5) assert widget.value() == 5 def test_qt_highlight_size_preview_widget_value_invalid( qtbot, highlight_size_preview_widget ): widget = highlight_size_preview_widget() widget.setMaximum(50) widget.setValue(51) assert widget.value() == 50 assert widget._lineedit.text() == "50" widget.setMinimum(5) widget.setValue(1) assert widget.value() == 5 assert widget._lineedit.text() == "5" def test_qt_highlight_size_preview_widget_signal( qtbot, highlight_size_preview_widget ): widget = highlight_size_preview_widget() with qtbot.waitSignal(widget.valueChanged, timeout=500): widget.setValue(7) with qtbot.waitSignal(widget.valueChanged, timeout=500): widget.setValue(-5) napari-0.5.0a1/napari/_qt/widgets/_tests/test_qt_play.py000066400000000000000000000114011437041365600233040ustar00rootroot00000000000000from contextlib import contextmanager from weakref import ref import numpy as np import pytest from napari._qt.widgets.qt_dims import QtDims from napari._qt.widgets.qt_dims_slider import AnimationWorker from napari.components import Dims from napari.settings._constants import LoopMode @contextmanager def make_worker( qtbot, nframes=8, fps=20, frame_range=None, loop_mode=LoopMode.LOOP ): # sets up an AnimationWorker ready for testing, and breaks down when done dims = Dims(ndim=4) qtdims = QtDims(dims) qtbot.addWidget(qtdims) nz = 8 step = 1 dims.set_range(0, (0, nz, step)) slider_widget = qtdims.slider_widgets[0] slider_widget.loop_mode = loop_mode slider_widget.fps = fps slider_widget.frame_range = frame_range worker = AnimationWorker(slider_widget) worker._count = 0 worker.nz = nz def bump(*args): if worker._count < nframes: worker._count += 1 else: worker._stop() def count_reached(): assert worker._count >= nframes def go(): worker.work() qtbot.waitUntil(count_reached, timeout=6000) worker._stop() return worker.current worker.frame_requested.connect(bump) worker.go = go yield worker # Each tuple represents different arguments we will pass to make_thread # frames, fps, mode, frame_range, expected_result(nframes, nz) CONDITIONS = [ # regular nframes < nz (5, 10, LoopMode.LOOP, None, lambda x, y: x), # loops around to the beginning (10, 10, LoopMode.LOOP, None, lambda x, y: x % y), # loops correctly with frame_range specified (10, 10, LoopMode.LOOP, (2, 6), lambda x, y: x % y), # loops correctly going backwards (10, -10, LoopMode.LOOP, None, lambda x, y: y - (x % y)), # loops back and forth (10, 10, LoopMode.BACK_AND_FORTH, None, lambda x, y: x - y + 2), # loops back and forth, with negative fps (10, -10, LoopMode.BACK_AND_FORTH, None, lambda x, y: y - (x % y) - 2), ] @pytest.mark.parametrize("nframes,fps,mode,rng,result", CONDITIONS) def test_animation_thread_variants(qtbot, nframes, fps, mode, rng, result): """This is mostly testing that AnimationWorker.advance works as expected""" with make_worker( qtbot, fps=fps, nframes=nframes, frame_range=rng, loop_mode=mode ) as worker: current = worker.go() if rng: nrange = rng[1] - rng[0] + 1 expected = rng[0] + result(nframes, nrange) else: expected = result(nframes, worker.nz) # assert current == expected # relaxing for CI OSX tests assert expected - 1 <= current <= expected + 1 def test_animation_thread_once(qtbot): """Single shot animation should stop when it reaches the last frame""" nframes = 13 with make_worker( qtbot, nframes=nframes, loop_mode=LoopMode.ONCE ) as worker: with qtbot.waitSignal(worker.finished, timeout=8000): worker.work() assert worker.current == worker.nz @pytest.fixture() def ref_view(make_napari_viewer): """basic viewer with data that we will use a few times It is problematic to yield the qt_viewer directly as it will stick around in the generator frames and causes issues if we want to make sure there is only a single instance of QtViewer instantiated at all times during the test suite. Thus we yield a weak reference that we resolve immediately in the test suite. """ viewer = make_napari_viewer() np.random.seed(0) data = np.random.random((10, 10, 15)) viewer.add_image(data) yield ref(viewer.window._qt_viewer) viewer.close() def test_play_raises_index_errors(qtbot, ref_view): view = ref_view() # play axis is out of range with pytest.raises(IndexError): view.dims.play(4, 20) # data doesn't have 20 frames with pytest.raises(IndexError): view.dims.play(0, 20, frame_range=[2, 20]) def test_play_raises_value_errors(qtbot, ref_view): view = ref_view() # frame_range[1] not > frame_range[0] with pytest.raises(ValueError): view.dims.play(0, 20, frame_range=[2, 2]) # that's not a valid loop_mode with pytest.raises(ValueError): view.dims.play(0, 20, loop_mode=5) def test_playing_hidden_slider_does_nothing(ref_view): """Make sure playing a dimension without a slider does nothing""" view = ref_view() def increment(e): view.dims._frame = e.value # this is provided by the step event # if we don't "enable play" again, view.dims won't request a new frame view.dims._play_ready = True view.dims.dims.events.current_step.connect(increment) with pytest.warns(UserWarning): view.dims.play(2, 20) view.dims.dims.events.current_step.disconnect(increment) assert not view.dims.is_playing napari-0.5.0a1/napari/_qt/widgets/_tests/test_qt_progress_bar.py000066400000000000000000000027701437041365600250400ustar00rootroot00000000000000from argparse import Namespace from napari._qt.widgets.qt_progress_bar import ( QtLabeledProgressBar, QtProgressBarGroup, ) def test_create_qt_labeled_progress_bar(qtbot): progress = QtLabeledProgressBar() qtbot.addWidget(progress) def test_qt_labeled_progress_bar_base(qtbot): progress = QtLabeledProgressBar() qtbot.addWidget(progress) progress.setRange(0, 10) assert progress.qt_progress_bar.value() == -1 progress.setValue(5) assert progress.qt_progress_bar.value() == 5 progress.setDescription("text") assert progress.description_label.text() == "text: " def test_qt_labeled_progress_bar_event_handle(qtbot): progress = QtLabeledProgressBar() qtbot.addWidget(progress) assert progress.qt_progress_bar.maximum() != 10 progress._set_total(Namespace(value=10)) assert progress.qt_progress_bar.maximum() == 10 assert progress._get_value() == -1 progress._set_value(Namespace(value=5)) assert progress._get_value() == 5 assert progress.description_label.text() == "" progress._set_description(Namespace(value="text")) assert progress.description_label.text() == "text: " assert progress.eta_label.text() == "" progress._set_eta(Namespace(value="test")) assert progress.eta_label.text() == "test" progress._make_indeterminate(None) assert progress.qt_progress_bar.maximum() == 0 def test_create_qt_progress_bar_group(qtbot): group = QtProgressBarGroup(QtLabeledProgressBar()) qtbot.addWidget(group) napari-0.5.0a1/napari/_qt/widgets/_tests/test_qt_range_slider_popup.py000066400000000000000000000015131437041365600262230ustar00rootroot00000000000000import pytest from napari._qt.widgets.qt_range_slider_popup import QRangeSliderPopup initial = (100, 400) range_ = (0, 500) @pytest.fixture def popup(qtbot): popup = QRangeSliderPopup() popup.slider.setRange(*range_) popup.slider.setValue(initial) qtbot.addWidget(popup) return popup def test_range_slider_popup_labels(popup): """make sure labels are correct""" assert popup.slider._handle_labels[0].value() == initial[0] assert popup.slider._handle_labels[1].value() == initial[1] assert (popup.slider.minimum(), popup.slider.maximum()) == range_ def test_range_slider_changes_labels(popup): """make sure setting the slider updates the labels""" popup.slider.setValue((10, 20)) assert popup.slider._handle_labels[0].value() == 10 assert popup.slider._handle_labels[1].value() == 20 napari-0.5.0a1/napari/_qt/widgets/_tests/test_qt_scrollbar.py000066400000000000000000000005611437041365600243270ustar00rootroot00000000000000from qtpy.QtCore import QPoint, Qt from napari._qt.widgets.qt_scrollbar import ModifiedScrollBar def test_modified_scrollbar_click(qtbot): w = ModifiedScrollBar(Qt.Horizontal) w.resize(100, 10) assert w.value() == 0 qtbot.mousePress(w, Qt.LeftButton, pos=QPoint(50, 5)) # the normal QScrollBar would have moved to "10" assert w.value() >= 40 napari-0.5.0a1/napari/_qt/widgets/_tests/test_qt_size_preview.py000066400000000000000000000113011437041365600250510ustar00rootroot00000000000000import pytest from napari._qt.widgets.qt_size_preview import ( QtFontSizePreview, QtSizeSliderPreviewWidget, ) @pytest.fixture def preview_widget(qtbot): def _preview_widget(**kwargs): widget = QtFontSizePreview(**kwargs) widget.show() qtbot.addWidget(widget) return widget return _preview_widget @pytest.fixture def font_size_preview_widget(qtbot): def _font_size_preview_widget(**kwargs): widget = QtSizeSliderPreviewWidget(**kwargs) widget.show() qtbot.addWidget(widget) return widget return _font_size_preview_widget # QtFontSizePreview # ---------------------------------------------------------------------------- def test_qt_font_size_preview_defaults(preview_widget): preview_widget() def test_qt_font_size_preview_text(preview_widget): text = "Some text" widget = preview_widget(text=text) assert widget.text() == text widget = preview_widget() widget.setText(text) assert widget.text() == text # QtSizeSliderPreviewWidget # ---------------------------------------------------------------------------- def test_qt_size_slider_preview_widget_defaults(font_size_preview_widget): font_size_preview_widget() def test_qt_size_slider_preview_widget_description(font_size_preview_widget): description = "Some text" widget = font_size_preview_widget(description=description) assert widget.description() == description widget = font_size_preview_widget() widget.setDescription(description) assert widget.description() == description def test_qt_size_slider_preview_widget_unit(font_size_preview_widget): unit = "EM" widget = font_size_preview_widget(unit=unit) assert widget.unit() == unit widget = font_size_preview_widget() widget.setUnit(unit) assert widget.unit() == unit def test_qt_size_slider_preview_widget_preview(font_size_preview_widget): preview = "Some preview" widget = font_size_preview_widget(preview_text=preview) assert widget.previewText() == preview widget = font_size_preview_widget() widget.setPreviewText(preview) assert widget.previewText() == preview def test_qt_size_slider_preview_widget_minimum(font_size_preview_widget): minimum = 10 widget = font_size_preview_widget(min_value=minimum) assert widget.minimum() == minimum assert widget.value() >= minimum widget = font_size_preview_widget() widget.setMinimum(5) assert widget.minimum() == 5 assert widget._slider.minimum() == 5 assert widget._slider_min_label.text() == "5" widget.setMinimum(20) assert widget.minimum() == 20 assert widget.value() == 20 assert widget._slider.minimum() == 20 assert widget._slider_min_label.text() == "20" assert widget._lineedit.text() == "20" def test_qt_size_slider_preview_widget_minimum_invalid( font_size_preview_widget, ): widget = font_size_preview_widget() with pytest.raises(ValueError): widget.setMinimum(60) def test_qt_size_slider_preview_widget_maximum(font_size_preview_widget): maximum = 10 widget = font_size_preview_widget(max_value=maximum) assert widget.maximum() == maximum assert widget.value() <= maximum widget = font_size_preview_widget() widget.setMaximum(20) assert widget.maximum() == 20 assert widget._slider.maximum() == 20 assert widget._slider_max_label.text() == "20" widget.setMaximum(5) assert widget.maximum() == 5 assert widget.value() == 5 assert widget._slider.maximum() == 5 assert widget._lineedit.text() == "5" assert widget._slider_max_label.text() == "5" def test_qt_size_slider_preview_widget_maximum_invalid( font_size_preview_widget, ): widget = font_size_preview_widget() with pytest.raises(ValueError): widget.setMaximum(-5) def test_qt_size_slider_preview_widget_value(font_size_preview_widget): widget = font_size_preview_widget(value=5) assert widget.value() <= 5 widget = font_size_preview_widget() widget.setValue(5) assert widget.value() == 5 def test_qt_size_slider_preview_widget_value_invalid( qtbot, font_size_preview_widget ): widget = font_size_preview_widget() widget.setMaximum(50) widget.setValue(51) assert widget.value() == 50 assert widget._lineedit.text() == "50" widget.setMinimum(5) widget.setValue(1) assert widget.value() == 5 assert widget._lineedit.text() == "5" def test_qt_size_slider_preview_signal(qtbot, font_size_preview_widget): widget = font_size_preview_widget() with qtbot.waitSignal(widget.valueChanged, timeout=500): widget.setValue(7) with qtbot.waitSignal(widget.valueChanged, timeout=500): widget.setValue(-5) napari-0.5.0a1/napari/_qt/widgets/_tests/test_shortcut_editor_widget.py000066400000000000000000000030661437041365600264270ustar00rootroot00000000000000from unittest.mock import patch import pytest from napari._qt.widgets.qt_keyboard_settings import ShortcutEditor, WarnPopup from napari.utils.action_manager import action_manager @pytest.fixture def shortcut_editor_widget(qtbot): def _shortcut_editor_widget(**kwargs): widget = ShortcutEditor(**kwargs) widget.show() qtbot.addWidget(widget) return widget return _shortcut_editor_widget def test_shortcut_editor_defaults( shortcut_editor_widget, ): shortcut_editor_widget() def test_layer_actions(shortcut_editor_widget): widget = shortcut_editor_widget() assert widget.layer_combo_box.currentText() == widget.VIEWER_KEYBINDINGS actions1 = widget._get_layer_actions() assert actions1 == widget.key_bindings_strs[widget.VIEWER_KEYBINDINGS] widget.layer_combo_box.setCurrentText("Labels layer") actions2 = widget._get_layer_actions() assert actions2 == {**widget.key_bindings_strs["Labels layer"], **actions1} def test_mark_conflicts(shortcut_editor_widget, qtbot): widget = shortcut_editor_widget() widget._table.item(0, widget._shortcut_col).setText("U") act = widget._table.item(0, widget._action_col).text() assert action_manager._shortcuts[act][0] == "U" with patch.object(WarnPopup, "exec_") as mock: assert not widget._mark_conflicts(action_manager._shortcuts[act][0], 1) assert mock.called assert widget._mark_conflicts("Y", 1) # "Y" is arbitrary chosen and on conflict with existing shortcut should be changed qtbot.add_widget(widget._warn_dialog) napari-0.5.0a1/napari/_qt/widgets/_tests/test_theme_sample.py000066400000000000000000000003771437041365600243100ustar00rootroot00000000000000from napari._qt.widgets.qt_theme_sample import SampleWidget def test_theme_sample(qtbot): """Just a smoke test to make sure that the theme sample can be created.""" w = SampleWidget() qtbot.addWidget(w) w.show() assert w.isVisible() napari-0.5.0a1/napari/_qt/widgets/qt_color_swatch.py000066400000000000000000000236121437041365600224750ustar00rootroot00000000000000import re from typing import Optional, Union import numpy as np from qtpy.QtCore import QEvent, Qt, Signal, Slot from qtpy.QtGui import QColor, QKeyEvent, QMouseEvent from qtpy.QtWidgets import ( QColorDialog, QCompleter, QFrame, QHBoxLayout, QLineEdit, QVBoxLayout, QWidget, ) from vispy.color import get_color_dict from napari._qt.dialogs.qt_modal import QtPopup from napari.utils.colormaps.colormap_utils import ColorType from napari.utils.colormaps.standardize_color import ( hex_to_name, rgb_to_hex, transform_color, ) from napari.utils.translations import trans # matches any 3- or 4-tuple of int or float, with or without parens # captures the numbers into groups. # this is used to allow users to enter colors as e.g.: "(1, 0.7, 0)" rgba_regex = re.compile( r"\(?([\d.]+),\s*([\d.]+),\s*([\d.]+),?\s*([\d.]+)?\)?" ) TRANSPARENT = np.array([0, 0, 0, 0], np.float32) AnyColorType = Union[ColorType, QColor] class QColorSwatchEdit(QWidget): """A widget that combines a QColorSwatch with a QColorLineEdit. emits a color_changed event with a 1x4 numpy array when the current color changes. Note, the "model" for the current color is the ``_color`` attribute on the QColorSwatch. Parameters ---------- parent : QWidget, optional parent widget, by default None initial_color : AnyColorType, optional Starting color, by default None tooltip : str, optional Tooltip when hovering on the swatch, by default 'click to set color' Attributes ---------- line_edit : QColorLineEdit An instance of QColorLineEdit, which takes hex, rgb, or autocompletes common color names. On invalid input, this field will return to the previous color value. color_swatch : QColorSwatch The square that shows the current color, and can be clicked to show a color dialog. color : np.ndarray The current color (just an alias for the colorSwatch.color) Signals ------- color_changed : np.ndarray Emits the new color when the current color changes. """ color_changed = Signal(np.ndarray) def __init__( self, parent: Optional[QWidget] = None, *, initial_color: Optional[AnyColorType] = None, tooltip: Optional[str] = None, ) -> None: super().__init__(parent=parent) self.setObjectName('QColorSwatchEdit') layout = QHBoxLayout() layout.setContentsMargins(0, 0, 0, 0) layout.setSpacing(6) self.setLayout(layout) self.line_edit = QColorLineEdit(self) self.line_edit.editingFinished.connect(self._on_line_edit_edited) self.color_swatch = QColorSwatch(self, tooltip=tooltip) self.color_swatch.color_changed.connect(self._on_swatch_changed) self.setColor = self.color_swatch.setColor if initial_color is not None: self.setColor(initial_color) layout.addWidget(self.color_swatch) layout.addWidget(self.line_edit) @property def color(self): """Return the current color.""" return self.color_swatch.color def _on_line_edit_edited(self): """When the user hits enter or loses focus on the LineEdit widget.""" text = self.line_edit.text() rgb_match = rgba_regex.match(text) if rgb_match: text = [float(x) for x in rgb_match.groups() if x] self.color_swatch.setColor(text) @Slot(np.ndarray) def _on_swatch_changed(self, color: np.ndarray): """Receive QColorSwatch change event, update the lineEdit, re-emit.""" self.line_edit.setText(color) self.color_changed.emit(color) class QColorSwatch(QFrame): """A QFrame that displays a color and can be clicked to show a QColorPopup. Parameters ---------- parent : QWidget, optional parent widget, by default None tooltip : Optional[str], optional Tooltip when hovering on swatch, by default 'click to set color' initial_color : ColorType, optional initial color, by default will be transparent Attributes ---------- color : np.ndarray The current color Signals ------- color_changed : np.ndarray Emits the new color when the current color changes. """ color_changed = Signal(np.ndarray) def __init__( self, parent: Optional[QWidget] = None, tooltip: Optional[str] = None, initial_color: Optional[ColorType] = None, ) -> None: super().__init__(parent) self.setObjectName('colorSwatch') self.setToolTip(tooltip or trans._('click to set color')) self.setCursor(Qt.CursorShape.PointingHandCursor) self.color_changed.connect(self._update_swatch_style) self._color: np.ndarray = TRANSPARENT if initial_color is not None: self.setColor(initial_color) @property def color(self): """Return the current color""" return self._color @Slot(np.ndarray) def _update_swatch_style(self, color: np.ndarray) -> None: """Convert the current color to rgba() string and update appearance.""" rgba = f'rgba({",".join(map(lambda x: str(int(x*255)), self._color))})' self.setStyleSheet('#colorSwatch {background-color: ' + rgba + ';}') def mouseReleaseEvent(self, event: QMouseEvent): """Show QColorPopup picker when the user clicks on the swatch.""" if event.button() == Qt.MouseButton.LeftButton: initial = QColor(*(255 * self._color).astype('int')) popup = QColorPopup(self, initial) popup.colorSelected.connect(self.setColor) popup.show_right_of_mouse() def setColor(self, color: AnyColorType) -> None: """Set the color of the swatch. Parameters ---------- color : ColorType Can be any ColorType recognized by our utils.colormaps.standardize_color.transform_color function. """ if isinstance(color, QColor): _color = (np.array(color.getRgb()) / 255).astype(np.float32) else: try: _color = transform_color(color)[0] except ValueError: return self.color_changed.emit(self._color) emit = np.any(self._color != _color) self._color = _color if emit or np.all(_color == TRANSPARENT): self.color_changed.emit(_color) class QColorLineEdit(QLineEdit): """A LineEdit that takes hex, rgb, or autocompletes common color names. Parameters ---------- parent : QWidget, optional The parent widget, by default None """ def __init__(self, parent=None) -> None: super().__init__(parent) self._compl = QCompleter(list(get_color_dict()) + ['transparent']) self._compl.setCompletionMode(QCompleter.InlineCompletion) self.setCompleter(self._compl) self.setTextMargins(2, 2, 2, 2) def setText(self, color: ColorType): """Set the text of the lineEdit using any ColorType. Colors will be converted to standard SVG spec names if possible, or shown as #RGBA hex if not. Parameters ---------- color : ColorType Can be any ColorType recognized by our utils.colormaps.standardize_color.transform_color function. """ _rgb = transform_color(color)[0] _hex = rgb_to_hex(_rgb)[0] super().setText(hex_to_name.get(_hex, _hex)) class CustomColorDialog(QColorDialog): def __init__(self, parent=None) -> None: super().__init__(parent=parent) self.setObjectName('CustomColorDialog') def keyPressEvent(self, event: QEvent): event.ignore() class QColorPopup(QtPopup): """A QColorDialog inside of our QtPopup. Allows all of the show methods of QtPopup (like show relative to mouse). Passes through signals from the ColorDialogm, and handles some keypress events. Parameters ---------- parent : QWidget, optional The parent widget. by default None initial_color : AnyColorType, optional The initial color set in the color dialog, by default None Attributes ---------- color_dialog : CustomColorDialog The main color dialog in the popup """ currentColorChanged = Signal(QColor) colorSelected = Signal(QColor) def __init__( self, parent: QWidget = None, initial_color: AnyColorType = None ) -> None: super().__init__(parent) self.setObjectName('QtColorPopup') self.color_dialog = CustomColorDialog(self) # native dialog doesn't get added to the QtPopup frame # so more would need to be done to use it self.color_dialog.setOptions( QColorDialog.DontUseNativeDialog | QColorDialog.ShowAlphaChannel ) layout = QVBoxLayout() self.frame.setLayout(layout) layout.addWidget(self.color_dialog) self.color_dialog.currentColorChanged.connect( self.currentColorChanged.emit ) self.color_dialog.colorSelected.connect(self._on_color_selected) self.color_dialog.rejected.connect(self._on_rejected) self.color_dialog.setCurrentColor(QColor(initial_color)) def _on_color_selected(self, color: QColor): """When a color has beeen selected and the OK button clicked.""" self.colorSelected.emit(color) self.close() def _on_rejected(self): self.close() def keyPressEvent(self, event: QKeyEvent): """Accept current color on enter, cancel on escape. Parameters ---------- event : QKeyEvent The keypress event that triggered this method. """ if event.key() in (Qt.Key.Key_Return, Qt.Key.Key_Enter): return self.color_dialog.accept() if event.key() == Qt.Key.Key_Escape: return self.color_dialog.reject() self.color_dialog.keyPressEvent(event) napari-0.5.0a1/napari/_qt/widgets/qt_dict_table.py000066400000000000000000000121311437041365600220720ustar00rootroot00000000000000import re from typing import List, Optional from qtpy.QtCore import QSize, Slot from qtpy.QtGui import QFont from qtpy.QtWidgets import QTableWidget, QTableWidgetItem from napari.utils.translations import trans email_pattern = re.compile(r"^[a-zA-Z0-9_.+-]+@[a-zA-Z0-9-]+\.[a-zA-Z0-9-.]+$") url_pattern = re.compile( r"https?:\/\/(www\.)?[-a-zA-Z0-9@:%._\+~#=]{1,256}" r"\.[a-zA-Z0-9()]{1,6}\b([-a-zA-Z0-9()@:%_\+.~#?&//=]*)" ) class QtDictTable(QTableWidget): """A QTableWidget subclass that makes a table from a list of dicts. This will also make any cells that contain emails address or URLs clickable to open the link in a browser/email client. Parameters ---------- parent : QWidget, optional The parent widget, by default None source : list of dict, optional A list of dicts where each dict in the list is a row, and each key in the dict is a header, by default None. (call set_data later to add data) headers : list of str, optional If provided, will be used in order as the headers of the table. All items in ``headers`` must be present in at least one of the dicts. by default headers will be the set of all keys in all dicts in ``source`` min_section_width : int, optional If provided, sets a minimum width on the columns, by default None max_section_width : int, optional Sets a maximum width on the columns, by default 480 Raises ------ ValueError if ``source`` is not a list of dicts. """ def __init__( self, parent=None, source: List[dict] = None, *, headers: List[str] = None, min_section_width: Optional[int] = None, max_section_width: int = 480, ) -> None: super().__init__(parent=parent) if min_section_width: self.horizontalHeader().setMinimumSectionSize(min_section_width) self.horizontalHeader().setMaximumSectionSize(max_section_width) self.horizontalHeader().setStretchLastSection(True) if source: self.set_data(source, headers) self.cellClicked.connect(self._go_to_links) self.setMouseTracking(True) def set_data(self, data: List[dict], headers: Optional[List[str]] = None): """Set the data in the table, given a list of dicts. Parameters ---------- data : List[dict] A list of dicts where each dict in the list is a row, and each key in the dict is a header, by default None. (call set_data later to add data) headers : list of str, optional If provided, will be used in order as the headers of the table. All items in ``headers`` must be present in at least one of the dicts. by default headers will be the set of all keys in all dicts in ``source`` """ if not isinstance(data, list) or any( not isinstance(i, dict) for i in data ): raise ValueError( trans._( "'data' argument must be a list of dicts", deferred=True ) ) nrows = len(data) _headers = sorted(set().union(*data)) if headers: for h in headers: if h not in _headers: raise ValueError( trans._( "Argument 'headers' got item '{header}', which was not found in any of the items in 'data'", deferred=True, header=h, ) ) _headers = headers self.setRowCount(nrows) self.setColumnCount(len(_headers)) for row, elem in enumerate(data): for key, value in elem.items(): value = value or '' try: col = _headers.index(key) except ValueError: continue item = QTableWidgetItem(value) # underline links if email_pattern.match(value) or url_pattern.match(value): font = QFont() font.setUnderline(True) item.setFont(font) self.setItem(row, col, item) self.setHorizontalHeaderLabels(_headers) self.resize_to_fit() @Slot(int, int) def _go_to_links(self, row, col): """if a cell is clicked and it contains an email or url, go to link.""" import webbrowser item = self.item(row, col) text = item.text().strip() if email_pattern.match(text): webbrowser.open(f'mailto:{text}', new=1) return if url_pattern.match(text): webbrowser.open(text, new=1) def resize_to_fit(self): self.resizeColumnsToContents() self.resize(self.sizeHint()) def sizeHint(self): """Return (width, height) of the table""" width = sum(map(self.columnWidth, range(self.columnCount()))) + 25 height = self.rowHeight(0) * (self.rowCount() + 1) return QSize(width, height) napari-0.5.0a1/napari/_qt/widgets/qt_dims.py000066400000000000000000000320531437041365600207410ustar00rootroot00000000000000import warnings from typing import Optional, Tuple import numpy as np from qtpy.QtCore import Slot from qtpy.QtGui import QFont, QFontMetrics from qtpy.QtWidgets import QLineEdit, QSizePolicy, QVBoxLayout, QWidget from napari._qt.widgets.qt_dims_slider import QtDimSliderWidget from napari.components.dims import Dims from napari.settings._constants import LoopMode from napari.utils.translations import trans class QtDims(QWidget): """Qt view for the napari Dims model. Parameters ---------- dims : napari.components.dims.Dims Dims object to be passed to Qt object. parent : QWidget, optional QWidget that will be the parent of this widget. Attributes ---------- dims : napari.components.dims.Dims Dimensions object modeling slicing and displaying. slider_widgets : list[QtDimSliderWidget] List of slider widgets. """ def __init__(self, dims: Dims, parent=None) -> None: super().__init__(parent=parent) self.SLIDERHEIGHT = 22 # We keep a reference to the view: self.dims = dims # list of sliders self.slider_widgets = [] # True / False if slider is or is not displayed self._displayed_sliders = [] self._play_ready = True # False if currently awaiting a draw event self._animation_thread = None self._animation_worker = None # Initialises the layout: layout = QVBoxLayout() layout.setContentsMargins(0, 0, 0, 0) layout.setSpacing(3) self.setLayout(layout) self.setSizePolicy(QSizePolicy.Preferred, QSizePolicy.Fixed) # Update the number of sliders now that the dims have been added self._update_nsliders() self.dims.events.ndim.connect(self._update_nsliders) self.dims.events.current_step.connect(self._update_slider) self.dims.events.range.connect(self._update_range) self.dims.events.ndisplay.connect(self._update_display) self.dims.events.order.connect(self._update_display) self.dims.events.last_used.connect(self._on_last_used_changed) @property def nsliders(self): """Returns the number of sliders. Returns ------- nsliders: int Number of sliders. """ return len(self.slider_widgets) def _on_last_used_changed(self): """Sets the style of the last used slider.""" for i, widget in enumerate(self.slider_widgets): sld = widget.slider sld.setProperty('last_used', i == self.dims.last_used) sld.style().unpolish(sld) sld.style().polish(sld) def _update_slider(self): """Updates position for a given slider.""" for widget in self.slider_widgets: widget._update_slider() def _update_range(self): """Updates range for a given slider.""" for widget in self.slider_widgets: widget._update_range() nsliders = np.sum(self._displayed_sliders) self.setMinimumHeight(nsliders * self.SLIDERHEIGHT) self._resize_slice_labels() def _update_display(self): """Updates display for all sliders.""" self.stop() widgets = reversed(list(enumerate(self.slider_widgets))) nsteps = self.dims.nsteps for (axis, widget) in widgets: if axis in self.dims.displayed or nsteps[axis] <= 1: # Displayed dimensions correspond to non displayed sliders self._displayed_sliders[axis] = False self.dims.last_used = 0 widget.hide() else: # Non displayed dimensions correspond to displayed sliders self._displayed_sliders[axis] = True self.dims.last_used = axis widget.show() nsliders = np.sum(self._displayed_sliders) self.setMinimumHeight(nsliders * self.SLIDERHEIGHT) self._resize_slice_labels() def _update_nsliders(self): """Updates the number of sliders based on the number of dimensions.""" self.stop() self._trim_sliders(0) self._create_sliders(self.dims.ndim) self._update_display() for i in range(self.dims.ndim): self._update_range() if self._displayed_sliders[i]: self._update_slider() def _resize_axis_labels(self): """When any of the labels get updated, this method updates all label widths to the width of the longest label. This keeps the sliders left-aligned and allows the full label to be visible at all times, with minimal space, without setting stretch on the layout. """ fm = QFontMetrics(QFont("", 0)) labels = self.findChildren(QLineEdit, 'axis_label') newwidth = max(fm.boundingRect(lab.text()).width() for lab in labels) if any(self._displayed_sliders): # set maximum width to no more than 20% of slider width maxwidth = self.slider_widgets[0].width() * 0.2 newwidth = min([newwidth, maxwidth]) for labl in labels: labl.setFixedWidth(int(newwidth) + 10) def _resize_slice_labels(self): """When the size of any dimension changes, we want to resize all of the slice labels to width of the longest label, to keep all the sliders right aligned. The width is determined by the number of digits in the largest dimensions, plus a little padding. """ width = 0 for ax, maxi in enumerate(self.dims.nsteps): if self._displayed_sliders[ax]: length = len(str(maxi - 1)) if length > width: width = length # gui width of a string of length `width` fm = QFontMetrics(QFont("", 0)) width = fm.boundingRect("8" * width).width() for labl in self.findChildren(QWidget, 'slice_label'): labl.setFixedWidth(width + 6) def _create_sliders(self, number_of_sliders: int): """Creates sliders to match new number of dimensions. Parameters ---------- number_of_sliders : int New number of sliders. """ # add extra sliders so that number_of_sliders are present # add to the beginning of the list for slider_num in range(self.nsliders, number_of_sliders): dim_axis = number_of_sliders - slider_num - 1 slider_widget = QtDimSliderWidget(self, dim_axis) slider_widget.axis_label_changed.connect(self._resize_axis_labels) slider_widget.play_button.play_requested.connect(self.play) self.layout().addWidget(slider_widget) self.slider_widgets.insert(0, slider_widget) self._displayed_sliders.insert(0, True) nsliders = np.sum(self._displayed_sliders) self.setMinimumHeight(nsliders * self.SLIDERHEIGHT) self._resize_axis_labels() def _trim_sliders(self, number_of_sliders): """Trims number of dimensions to a lower number. Parameters ---------- number_of_sliders : int New number of sliders. """ # remove extra sliders so that only number_of_sliders are left # remove from the beginning of the list for _slider_num in range(number_of_sliders, self.nsliders): self._remove_slider_widget(0) def _remove_slider_widget(self, index): """Remove slider_widget at index, including all sub-widgets. Parameters ---------- index : int Index of slider to remove """ # remove particular slider slider_widget = self.slider_widgets.pop(index) self._displayed_sliders.pop(index) self.layout().removeWidget(slider_widget) # As we delete this widget later, callbacks with a weak reference # to it may successfully grab the instance, but may be incompatible # with other update state like dims. self.dims.events.axis_labels.disconnect(slider_widget._pull_label) slider_widget.deleteLater() nsliders = np.sum(self._displayed_sliders) self.setMinimumHeight(int(nsliders * self.SLIDERHEIGHT)) self.dims.last_used = 0 def play( self, axis: int = 0, fps: Optional[float] = None, loop_mode: Optional[str] = None, frame_range: Optional[Tuple[int, int]] = None, ): """Animate (play) axis. Parameters ---------- axis : int Index of axis to play fps : float Frames per second for playback. Negative values will play in reverse. fps == 0 will stop the animation. The view is not guaranteed to keep up with the requested fps, and may drop frames at higher fps. loop_mode : str Mode for animation playback. Must be one of the following options: "once": Animation will stop once movie reaches the max frame (if fps > 0) or the first frame (if fps < 0). "loop": Movie will return to the first frame after reaching the last frame, looping until stopped. "back_and_forth": Movie will loop back and forth until stopped frame_range : tuple | list If specified, will constrain animation to loop [first, last] frames Raises ------ IndexError If ``axis`` requested is out of the range of the dims IndexError If ``frame_range`` is provided and out of the range of the dims ValueError If ``frame_range`` is provided and range[0] >= range[1] """ # doing manual check here to avoid issue in StringEnum # see https://github.com/napari/napari/issues/754 if loop_mode is not None: _modes = LoopMode.keys() if loop_mode not in _modes: raise ValueError( trans._( 'loop_mode must be one of {_modes}. Got: {loop_mode}', _modes=_modes, loop_mode=loop_mode, ) ) loop_mode = LoopMode(loop_mode) if axis >= self.dims.ndim: raise IndexError(trans._('axis argument out of range')) if self.is_playing: if self._animation_worker.axis == axis: self.slider_widgets[axis]._update_play_settings( fps, loop_mode, frame_range ) return else: self.stop() # we want to avoid playing a dimension that does not have a slider # (like X or Y, or a third dimension in volume view.) if self._displayed_sliders[axis]: work = self.slider_widgets[axis]._play(fps, loop_mode, frame_range) if work: self._animation_worker, self._animation_thread = work else: self._animation_worker, self._animation_thread = None, None else: warnings.warn( trans._( 'Refusing to play a hidden axis', deferred=True, ) ) @Slot() def stop(self): """Stop axis animation""" if self._animation_worker is not None: # Thread will be stop by the worker self._animation_worker._stop() @Slot() def cleaned_worker(self): self._animation_thread = None self._animation_worker = None self.enable_play() @property def is_playing(self): """Return True if any axis is currently animated.""" try: return ( self._animation_thread and self._animation_thread.isRunning() ) except RuntimeError as e: # pragma: no cover if ( "wrapped C/C++ object of type" not in e.args[0] and "Internal C++ object" not in e.args[0] ): # checking if threat is partially deleted. Otherwise # reraise exception. For more details see: # https://github.com/napari/napari/pull/5499 raise return False def _set_frame(self, axis, frame): """Safely tries to set `axis` to the requested `point`. This function is debounced: if the previous frame has not yet drawn to the canvas, it will simply do nothing. If the timer plays faster than the canvas can draw, this will drop the intermediate frames, keeping the effective frame rate constant even if the canvas cannot keep up. """ if self._play_ready: # disable additional point advance requests until this one draws self._play_ready = False self.dims.set_current_step(axis, frame) def enable_play(self, *args): # this is mostly here to connect to the main SceneCanvas.events.draw # event in the qt_viewer self._play_ready = True def closeEvent(self, event): [w.deleteLater() for w in self.slider_widgets] self.deleteLater() event.accept() napari-0.5.0a1/napari/_qt/widgets/qt_dims_slider.py000066400000000000000000000676761437041365600223260ustar00rootroot00000000000000from typing import Optional, Tuple from weakref import ref import numpy as np from qtpy.QtCore import QObject, Qt, QThread, QTimer, Signal, Slot from qtpy.QtGui import QIntValidator from qtpy.QtWidgets import ( QApplication, QCheckBox, QComboBox, QDoubleSpinBox, QFormLayout, QFrame, QHBoxLayout, QLabel, QLineEdit, QPushButton, QWidget, ) from superqt import ensure_object_thread from napari._qt.dialogs.qt_modal import QtPopup from napari._qt.qthreading import _new_worker_qthread from napari._qt.widgets.qt_scrollbar import ModifiedScrollBar from napari.settings import get_settings from napari.settings._constants import LoopMode from napari.utils.events.event_utils import connect_setattr_value from napari.utils.translations import trans class QtDimSliderWidget(QWidget): """Compound widget to hold the label, slider and play button for an axis. These will usually be instantiated in the QtDims._create_sliders method. This widget *must* be instantiated with a parent QtDims. """ axis_label_changed = Signal(int, str) # axis, label fps_changed = Signal(float) mode_changed = Signal(str) range_changed = Signal(tuple) play_started = Signal() play_stopped = Signal() def __init__(self, parent: QWidget, axis: int) -> None: super().__init__(parent=parent) self.axis = axis self.qt_dims = parent self.dims = parent.dims self.axis_label = None self.slider = None self.play_button = None self.curslice_label = QLineEdit(self) self.curslice_label.setToolTip( trans._('Current slice for axis {axis}', axis=axis) ) # if we set the QIntValidator to actually reflect the range of the data # then an invalid (i.e. too large) index doesn't actually trigger the # editingFinished event (the user is expected to change the value)... # which is confusing to the user, so instead we use an IntValidator # that makes sure the user can only enter integers, but we do our own # value validation in self.change_slice self.curslice_label.setValidator(QIntValidator(0, 999999)) self.curslice_label.editingFinished.connect(self._set_slice_from_label) self.totslice_label = QLabel(self) self.totslice_label.setToolTip( trans._('Total slices for axis {axis}', axis=axis) ) self.curslice_label.setObjectName('slice_label') self.totslice_label.setObjectName('slice_label') sep = QFrame(self) sep.setFixedSize(1, 14) sep.setObjectName('slice_label_sep') settings = get_settings() self._fps = settings.application.playback_fps connect_setattr_value( settings.application.events.playback_fps, self, "fps" ) self._minframe = None self._maxframe = None self._loop_mode = settings.application.playback_mode connect_setattr_value( settings.application.events.playback_mode, self, "loop_mode" ) layout = QHBoxLayout() self._create_axis_label_widget() self._create_range_slider_widget() self._create_play_button_widget() layout.addWidget(self.axis_label) layout.addWidget(self.play_button) layout.addWidget(self.slider, stretch=1) layout.addWidget(self.curslice_label) layout.addWidget(sep) layout.addWidget(self.totslice_label) layout.setContentsMargins(0, 0, 0, 0) layout.setSpacing(2) layout.setAlignment(Qt.AlignmentFlag.AlignVCenter) self.setLayout(layout) self.dims.events.axis_labels.connect(self._pull_label) def _set_slice_from_label(self): """Update the dims point based on the curslice_label.""" # On teardown some tests fail on OSX with an `IndexError` try: max_allowed = self.dims.nsteps[self.axis] - 1 except IndexError: return val = int(self.curslice_label.text()) if val > max_allowed: val = max_allowed self.curslice_label.setText(str(val)) self.curslice_label.clearFocus() self.qt_dims.setFocus() self.dims.set_current_step(self.axis, val) def _create_axis_label_widget(self): """Create the axis label widget which accompanies its slider.""" label = QLineEdit(self) label.setObjectName('axis_label') # needed for _update_label label.setText(self.dims.axis_labels[self.axis]) label.home(False) label.setToolTip(trans._('Edit to change axis label')) label.setAcceptDrops(False) label.setEnabled(True) label.setAlignment(Qt.AlignmentFlag.AlignRight) label.setContentsMargins(0, 0, 2, 0) label.textChanged.connect(self._update_label) label.editingFinished.connect(self._clear_label_focus) self.axis_label = label def _value_changed(self, value): """Slider changed to this new value. We split this out as a separate function for perfmon. """ self.dims.set_current_step(self.axis, value) def _create_range_slider_widget(self): """Creates a range slider widget for a given axis.""" # Set the maximum values of the range slider to be one step less than # the range of the layer as otherwise the slider can move beyond the # shape of the layer as the endpoint is included slider = ModifiedScrollBar(Qt.Orientation.Horizontal) slider.setFocusPolicy(Qt.FocusPolicy.NoFocus) slider.setMinimum(0) slider.setMaximum(self.dims.nsteps[self.axis] - 1) slider.setSingleStep(1) slider.setPageStep(1) slider.setValue(self.dims.current_step[self.axis]) # Listener to be used for sending events back to model: slider.valueChanged.connect(self._value_changed) def slider_focused_listener(): self.dims.last_used = self.axis # linking focus listener to the last used: slider.sliderPressed.connect(slider_focused_listener) self.slider = slider def _create_play_button_widget(self): """Creates the actual play button, which has the modal popup.""" self.play_button = QtPlayButton( self.qt_dims, self.axis, fps=self._fps, mode=self._loop_mode ) self.play_button.setToolTip( trans._('Right click on button for playback setting options.') ) self.play_button.mode_combo.currentTextChanged.connect( lambda x: self.__class__.loop_mode.fset( self, LoopMode(x.replace(' ', '_')) ) ) def fps_listener(*args): fps = self.play_button.fpsspin.value() fps *= -1 if self.play_button.reverse_check.isChecked() else 1 self.__class__.fps.fset(self, fps) self.play_button.fpsspin.editingFinished.connect(fps_listener) self.play_button.reverse_check.stateChanged.connect(fps_listener) self.play_stopped.connect(self.play_button._handle_stop) self.play_started.connect(self.play_button._handle_start) def _pull_label(self): """Updates the label LineEdit from the dims model.""" label = self.dims.axis_labels[self.axis] self.axis_label.setText(label) self.axis_label_changed.emit(self.axis, label) def _update_label(self): """Update dimension slider label.""" with self.dims.events.axis_labels.blocker(): self.dims.set_axis_label(self.axis, self.axis_label.text()) self.axis_label_changed.emit(self.axis, self.axis_label.text()) def _clear_label_focus(self): """Clear focus from dimension slider label.""" self.axis_label.clearFocus() self.qt_dims.setFocus() def _update_range(self): """Updates range for slider.""" displayed_sliders = self.qt_dims._displayed_sliders nsteps = self.dims.nsteps[self.axis] - 1 if nsteps == 0: displayed_sliders[self.axis] = False self.qt_dims.last_used = 0 self.hide() else: if ( not displayed_sliders[self.axis] and self.axis not in self.dims.displayed ): displayed_sliders[self.axis] = True self.last_used = self.axis self.show() self.slider.setMinimum(0) self.slider.setMaximum(nsteps) self.slider.setSingleStep(1) self.slider.setPageStep(1) self.slider.setValue(self.dims.current_step[self.axis]) self.totslice_label.setText(str(nsteps)) self.totslice_label.setAlignment(Qt.AlignmentFlag.AlignLeft) self._update_slice_labels() def _update_slider(self): """Update dimension slider.""" self.slider.setValue(self.dims.current_step[self.axis]) self._update_slice_labels() def _update_slice_labels(self): """Update slice labels to match current dimension slider position.""" self.curslice_label.setText(str(self.dims.current_step[self.axis])) self.curslice_label.setAlignment(Qt.AlignmentFlag.AlignRight) @property def fps(self): """Frames per second for animation.""" return self._fps @fps.setter def fps(self, value): """Frames per second for animation. Parameters ---------- value : float Frames per second for animation. """ self._fps = value self.play_button.fpsspin.setValue(abs(value)) self.play_button.reverse_check.setChecked(value < 0) self.fps_changed.emit(value) @property def loop_mode(self): """Loop mode for animation. Loop mode enumeration napari._qt._constants.LoopMode Available options for the loop mode string enumeration are: - LoopMode.ONCE Animation will stop once movie reaches the max frame (if fps > 0) or the first frame (if fps < 0). - LoopMode.LOOP Movie will return to the first frame after reaching the last frame, looping continuously until stopped. - LoopMode.BACK_AND_FORTH Movie will loop continuously until stopped, reversing direction when the maximum or minimum frame has been reached. """ return self._loop_mode @loop_mode.setter def loop_mode(self, value): """Loop mode for animation. Parameters ---------- value : napari._qt._constants.LoopMode Loop mode for animation. Available options for the loop mode string enumeration are: - LoopMode.ONCE Animation will stop once movie reaches the max frame (if fps > 0) or the first frame (if fps < 0). - LoopMode.LOOP Movie will return to the first frame after reaching the last frame, looping continuously until stopped. - LoopMode.BACK_AND_FORTH Movie will loop continuously until stopped, reversing direction when the maximum or minimum frame has been reached. """ value = LoopMode(value) self._loop_mode = value self.play_button.mode_combo.setCurrentText( str(value).replace('_', ' ') ) self.mode_changed.emit(str(value)) @property def frame_range(self): """Frame range for animation, as (minimum_frame, maximum_frame).""" frame_range = (self._minframe, self._maxframe) frame_range = frame_range if any(frame_range) else None return frame_range @frame_range.setter def frame_range(self, value): """Frame range for animation, as (minimum_frame, maximum_frame). Parameters ---------- value : tuple(int, int) Frame range as tuple/list with range (minimum_frame, maximum_frame) """ if not isinstance(value, (tuple, list, type(None))): raise TypeError( trans._('frame_range value must be a list or tuple') ) if value and len(value) != 2: raise ValueError(trans._('frame_range must have a length of 2')) if value is None: value = (None, None) self._minframe, self._maxframe = value self.range_changed.emit(tuple(value)) def _update_play_settings(self, fps, loop_mode, frame_range): """Update settings for animation. Parameters ---------- fps : float Frames per second to play the animation. loop_mode : napari._qt._constants.LoopMode Loop mode for animation. Available options for the loop mode string enumeration are: - LoopMode.ONCE Animation will stop once movie reaches the max frame (if fps > 0) or the first frame (if fps < 0). - LoopMode.LOOP Movie will return to the first frame after reaching the last frame, looping continuously until stopped. - LoopMode.BACK_AND_FORTH Movie will loop continuously until stopped, reversing direction when the maximum or minimum frame has been reached. frame_range : tuple(int, int) Frame range as tuple/list with range (minimum_frame, maximum_frame) """ if fps is not None: self.fps = fps if loop_mode is not None: self.loop_mode = loop_mode if frame_range is not None: self.frame_range = frame_range def _play( self, fps: Optional[float] = None, loop_mode: Optional[str] = None, frame_range: Optional[Tuple[int, int]] = None, ): """Animate (play) axis. Same API as QtDims.play() Putting the AnimationWorker logic here makes it easier to call QtDims.play(axis), or hit the keybinding, and have each axis remember it's own settings (fps, mode, etc...). Parameters ---------- fps : float Frames per second for animation. loop_mode : napari._qt._constants.LoopMode Loop mode for animation. Available options for the loop mode string enumeration are: - LoopMode.ONCE Animation will stop once movie reaches the max frame (if fps > 0) or the first frame (if fps < 0). - LoopMode.LOOP Movie will return to the first frame after reaching the last frame, looping continuously until stopped. - LoopMode.BACK_AND_FORTH Movie will loop continuously until stopped, reversing direction when the maximum or minimum frame has been reached. frame_range : tuple(int, int) Frame range as tuple/list with range (minimum_frame, maximum_frame) """ # having this here makes sure that using the QtDims.play() API # keeps the play preferences synchronized with the play_button.popup self._update_play_settings(fps, loop_mode, frame_range) # setting fps to 0 just stops the animation if fps == 0: return worker, thread = _new_worker_qthread( AnimationWorker, self, _start_thread=True, _connect={'frame_requested': self.qt_dims._set_frame}, ) thread.finished.connect(self.qt_dims.cleaned_worker) thread.finished.connect(self.play_stopped) self.play_started.emit() return worker, thread class QtCustomDoubleSpinBox(QDoubleSpinBox): """Custom Spinbox that emits an additional editingFinished signal whenever the valueChanged event is emitted AND the left mouse button is down. The original use case here was the FPS spinbox in the play button, where hooking to the actual valueChanged event is undesirable, because if the user clears the LineEdit to type, for example, "0.5", then play back will temporarily pause when "0" is typed (if the animation is currently running). However, the editingFinished event ignores mouse click events on the spin buttons. This subclass class triggers an event both during editingFinished and when the user clicks on the spin buttons. """ def __init__(self, *args, **kwargs) -> None: super().__init__(*args, *kwargs) self.valueChanged.connect(self.custom_change_event) def custom_change_event(self, value): """Emits editingFinished if valueChanged AND left mouse button is down. (i.e. when the user clicks on the spin buttons) Paramters --------- value : float The value of this custom double spin box. """ if QApplication.mouseButtons() & Qt.MouseButton.LeftButton: self.editingFinished.emit() def textFromValue(self, value): """This removes the decimal places if the float is an integer. Parameters ---------- value : float The value of this custom double spin box. """ if value.is_integer(): value = int(value) return str(value) def keyPressEvent(self, event): """Handle key press event for the dimension slider spinbox. Parameters ---------- event : qtpy.QtCore.QKeyEvent Event from the Qt context. """ # this is here to intercept Return/Enter keys when editing the FPS # SpinBox. We WANT the return key to close the popup normally, # but if the user is editing the FPS spinbox, we simply want to # register the change and lose focus on the lineEdit, in case they # want to make an additional change (without reopening the popup) if event.key() in (Qt.Key.Key_Return, Qt.Key.Key_Enter): self.editingFinished.emit() self.clearFocus() return super().keyPressEvent(event) class QtPlayButton(QPushButton): """Play button, included in the DimSliderWidget, to control playback the button also owns the QtModalPopup that controls the playback settings. """ play_requested = Signal(int) # axis, fps def __init__( self, qt_dims, axis, reverse=False, fps=10, mode=LoopMode.LOOP ) -> None: super().__init__() self.qt_dims_ref = ref(qt_dims) self.axis = axis self.reverse = reverse self.fps = fps self.mode = mode self.setProperty('reverse', str(reverse)) # for styling self.setProperty('playing', 'False') # for styling # build popup modal form self.popup = QtPopup(self) form_layout = QFormLayout() self.popup.frame.setLayout(form_layout) fpsspin = QtCustomDoubleSpinBox(self.popup) fpsspin.setObjectName("fpsSpinBox") fpsspin.setAlignment(Qt.AlignmentFlag.AlignCenter) fpsspin.setValue(self.fps) if hasattr(fpsspin, 'setStepType'): # this was introduced in Qt 5.12. Totally optional, just nice. fpsspin.setStepType(QDoubleSpinBox.AdaptiveDecimalStepType) fpsspin.setMaximum(500) fpsspin.setMinimum(0) form_layout.insertRow( 0, QLabel(trans._('frames per second:'), parent=self.popup), fpsspin, ) self.fpsspin = fpsspin revcheck = QCheckBox(self.popup) revcheck.setObjectName("playDirectionCheckBox") form_layout.insertRow( 1, QLabel(trans._('play direction:'), parent=self.popup), revcheck ) self.reverse_check = revcheck mode_combo = QComboBox(self.popup) mode_combo.addItems([str(i).replace('_', ' ') for i in LoopMode]) form_layout.insertRow( 2, QLabel(trans._('play mode:'), parent=self.popup), mode_combo ) mode_combo.setCurrentText(str(self.mode).replace('_', ' ')) self.mode_combo = mode_combo def mouseReleaseEvent(self, event): """Show popup for right-click, toggle animation for right click. Parameters ---------- event : qtpy.QtCore.QMouseEvent Event from the qt context. """ # using this instead of self.customContextMenuRequested.connect and # clicked.connect because the latter was not sending the # rightMouseButton release event. if event.button() == Qt.MouseButton.RightButton: self.popup.show_above_mouse() elif event.button() == Qt.MouseButton.LeftButton: self._on_click() def _on_click(self): """Toggle play/stop animation control.""" qt_dims = self.qt_dims_ref() if not qt_dims: # pragma: no cover return if self.property('playing') == "True": return qt_dims.stop() self.play_requested.emit(self.axis) def _handle_start(self): """On animation start, set playing property to True & update style.""" self.setProperty('playing', 'True') self.style().unpolish(self) self.style().polish(self) def _handle_stop(self): """On animation stop, set playing property to False & update style.""" self.setProperty('playing', 'False') self.style().unpolish(self) self.style().polish(self) class AnimationWorker(QObject): """A thread to keep the animation timer independent of the main event loop. This prevents mouseovers and other events from causing animation lag. See QtDims.play() for public-facing docstring. """ frame_requested = Signal(int, int) # axis, point finished = Signal() started = Signal() def __init__(self, slider) -> None: # FIXME there are attributes defined outsid of __init__. super().__init__() self._interval = 1 self.slider = slider self.dims = slider.dims self.axis = slider.axis self.loop_mode = slider.loop_mode self.timer = QTimer() slider.fps_changed.connect(self.set_fps) slider.mode_changed.connect(self.set_loop_mode) slider.range_changed.connect(self.set_frame_range) self.set_fps(self.slider.fps) self.set_frame_range(slider.frame_range) # after dims.set_current_step is called, it will emit a dims.events.current_step() # we use this to update this threads current frame (in case it # was some other event that updated the axis) self.dims.events.current_step.connect(self._on_axis_changed) self.current = max(self.dims.current_step[self.axis], self.min_point) self.current = min(self.current, self.max_point) self.timer.setSingleShot(True) self.timer.timeout.connect(self.advance) @property def interval(self): return self._interval @interval.setter def interval(self, value): self._interval = value self.timer.setInterval(int(self._interval)) @Slot() def work(self): """Play the animation.""" # if loop_mode is once and we are already on the last frame, # return to the first frame... (so the user can keep hitting once) if self.loop_mode == LoopMode.ONCE: if self.step > 0 and self.current >= self.max_point - 1: self.frame_requested.emit(self.axis, self.min_point) elif self.step < 0 and self.current <= self.min_point + 1: self.frame_requested.emit(self.axis, self.max_point) self.timer.start() else: # immediately advance one frame self.advance() self.started.emit() @ensure_object_thread def _stop(self): """Stop the animation.""" if self.timer.isActive(): self.timer.stop() self.finish() @Slot(float) def set_fps(self, fps): """Set the frames per second value for the animation. Parameters ---------- fps : float Frames per second for the animation. """ if fps == 0: return self.finish() self.step = 1 if fps > 0 else -1 # negative fps plays in reverse self.interval = 1000 / abs(fps) @Slot(tuple) def set_frame_range(self, frame_range): """Frame range for animation, as (minimum_frame, maximum_frame). Parameters ---------- frame_range : tuple(int, int) Frame range as tuple/list with range (minimum_frame, maximum_frame) """ self.dimsrange = (0, self.dims.nsteps[self.axis], 1) if frame_range is not None: if frame_range[0] >= frame_range[1]: raise ValueError( trans._("frame_range[0] must be <= frame_range[1]") ) if frame_range[0] < self.dimsrange[0]: raise IndexError(trans._("frame_range[0] out of range")) if frame_range[1] * self.dimsrange[2] >= self.dimsrange[1]: raise IndexError(trans._("frame_range[1] out of range")) self.frame_range = frame_range if self.frame_range is not None: self.min_point, self.max_point = self.frame_range else: self.min_point = 0 self.max_point = int( np.floor(self.dimsrange[1] - self.dimsrange[2]) ) self.max_point += 1 # range is inclusive @Slot(str) def set_loop_mode(self, mode): """Set the loop mode for the animation. Parameters ---------- mode : str Loop mode for animation. Available options for the loop mode string enumeration are: - LoopMode.ONCE Animation will stop once movie reaches the max frame (if fps > 0) or the first frame (if fps < 0). - LoopMode.LOOP Movie will return to the first frame after reaching the last frame, looping continuously until stopped. - LoopMode.BACK_AND_FORTH Movie will loop continuously until stopped, reversing direction when the maximum or minimum frame has been reached. """ self.loop_mode = LoopMode(mode) @Slot() def advance(self): """Advance the current frame in the animation. Takes dims scale into account and restricts the animation to the requested frame_range, if entered. """ self.current += self.step * self.dimsrange[2] if self.current < self.min_point: if ( self.loop_mode == LoopMode.BACK_AND_FORTH ): # 'loop_back_and_forth' self.step *= -1 self.current = self.min_point + self.step * self.dimsrange[2] elif self.loop_mode == LoopMode.LOOP: # 'loop' self.current = self.max_point + self.current - self.min_point else: # loop_mode == 'once' return self.finish() elif self.current >= self.max_point: if ( self.loop_mode == LoopMode.BACK_AND_FORTH ): # 'loop_back_and_forth' self.step *= -1 self.current = ( self.max_point + 2 * self.step * self.dimsrange[2] ) elif self.loop_mode == LoopMode.LOOP: # 'loop' self.current = self.min_point + self.current - self.max_point else: # loop_mode == 'once' return self.finish() with self.dims.events.current_step.blocker(self._on_axis_changed): self.frame_requested.emit(self.axis, self.current) self.timer.start() def finish(self): """Emit the finished event signal.""" self.finished.emit() def _on_axis_changed(self): """Update the current frame if the axis has changed.""" # slot for external events to update the current frame self.current = self.dims.current_step[self.axis] def moveToThread(self, thread: QThread): """Move the animation to a given thread. Parameters ---------- thread : QThread The thread to move the animation to. """ super().moveToThread(thread) self.timer.moveToThread(thread) napari-0.5.0a1/napari/_qt/widgets/qt_dims_sorter.py000066400000000000000000000060761437041365600223450ustar00rootroot00000000000000from typing import TYPE_CHECKING, Tuple, Union import numpy as np from qtpy.QtWidgets import QGridLayout, QLabel, QWidget from napari._qt.containers import QtListView from napari._qt.widgets.qt_tooltip import QtToolTipLabel from napari.components import Dims from napari.utils.events import SelectableEventedList from napari.utils.translations import trans if TYPE_CHECKING: from napari.viewer import Viewer class AxisModel: """View of an axis within a dims model keeping track of axis names.""" def __init__(self, dims: Dims, axis: int) -> None: self.dims = dims self.axis = axis def __hash__(self) -> int: return id(self) def __str__(self) -> str: return repr(self) def __repr__(self) -> str: return self.dims.axis_labels[self.axis] def __eq__(self, other: Union[int, str]) -> bool: if isinstance(other, int): return self.axis == other else: return repr(self) == other def set_dims_order(dims: Dims, order: Tuple[int, ...]): if type(order[0]) == AxisModel: order = [a.axis for a in order] dims.order = order def _array_in_range(arr: np.ndarray, low: int, high: int) -> bool: return (arr >= low) & (arr < high) def move_indices(axes_list: SelectableEventedList, order: Tuple[int, ...]): with axes_list.events.blocker_all(): if tuple(axes_list) == tuple(order): return axes = [a.axis for a in axes_list] ax_to_existing_position = {a: ix for ix, a in enumerate(axes)} move_list = np.asarray( [(ax_to_existing_position[order[i]], i) for i in range(len(order))] ) for src, dst in move_list: axes_list.move(src, dst) move_list[_array_in_range(move_list[:, 0], dst, src)] += 1 # remove the elements from the back if order has changed length while len(axes_list) > len(order): axes_list.pop() class QtDimsSorter(QWidget): """ Modified from: https://github.com/jni/zarpaint/blob/main/zarpaint/_dims_chooser.py """ def __init__(self, viewer: 'Viewer', parent=None) -> None: super().__init__(parent=parent) dims = viewer.dims root = SelectableEventedList( [AxisModel(dims, dims.order[i]) for i in range(dims.ndim)] ) root.events.reordered.connect( lambda event: set_dims_order(dims, event.value) ) dims.events.order.connect( lambda event: move_indices(root, event.value) ) view = QtListView(root) view.setSizeAdjustPolicy(QtListView.AdjustToContents) self.axes_list = root layout = QGridLayout() self.setLayout(layout) widget_tooltip = QtToolTipLabel(self) widget_tooltip.setObjectName('help_label') widget_tooltip.setToolTip(trans._('Drag dimensions to reorder.')) widget_title = QLabel(trans._('Dims. Ordering'), self) self.layout().addWidget(widget_title, 0, 0) self.layout().addWidget(widget_tooltip, 0, 1) self.layout().addWidget(view, 1, 0) napari-0.5.0a1/napari/_qt/widgets/qt_extension2reader.py000066400000000000000000000247661437041365600233020ustar00rootroot00000000000000import os from qtpy.QtCore import Qt, Signal from qtpy.QtWidgets import ( QComboBox, QGridLayout, QHBoxLayout, QLabel, QLineEdit, QPushButton, QSizePolicy, QTableWidget, QTableWidgetItem, QVBoxLayout, QWidget, ) from napari.plugins.utils import ( get_all_readers, get_filename_patterns_for_reader, get_potential_readers, ) from napari.settings import get_settings from napari.utils.translations import trans class Extension2ReaderTable(QWidget): """Table showing extension to reader mappings with removal button. Widget presented in preferences-plugin dialog.""" valueChanged = Signal(int) def __init__( self, parent=None, npe2_readers=None, npe1_readers=None ) -> None: super().__init__(parent=parent) npe2, npe1 = get_all_readers() if npe2_readers is None: npe2_readers = npe2 if npe1_readers is None: npe1_readers = npe1 self._npe2_readers = npe2_readers self._npe1_readers = npe1_readers self._table = QTableWidget() self._table.setShowGrid(False) self._set_up_table() self._edit_row = self._make_new_preference_row() self._populate_table() instructions = QLabel( trans._( 'Enter a filename pattern to associate with a reader e.g. "*.tif" for all TIFF files. Available readers will be filtered to those compatible with your pattern. Hover over a reader to see what patterns it accepts. \n\nYou can save a preference for a specific folder by listing the folder name with a "/" at the end (for example, "/test_images/"). \n\nFor documentation on valid filename patterns, see https://docs.python.org/3/library/fnmatch.html' ) ) instructions.setWordWrap(True) instructions.setOpenExternalLinks(True) layout = QVBoxLayout() instructions.setSizePolicy( QSizePolicy.MinimumExpanding, QSizePolicy.Expanding ) layout.addWidget(instructions) layout.addWidget(self._edit_row) layout.addWidget(self._table) self.setLayout(layout) def _set_up_table(self): """Add table columns and headers, define styling""" self._fn_pattern_col = 0 self._reader_col = 1 header_strs = [trans._('Filename Pattern'), trans._('Reader Plugin')] self._table.setColumnCount(2) self._table.setColumnWidth(self._fn_pattern_col, 200) self._table.setColumnWidth(self._reader_col, 200) self._table.verticalHeader().setVisible(False) self._table.setMinimumHeight(120) self._table.horizontalHeader().setStyleSheet( 'border-bottom: 2px solid white;' ) self._table.setHorizontalHeaderLabels(header_strs) self._table.setSizePolicy(QSizePolicy.Expanding, QSizePolicy.Expanding) def _populate_table(self): """Add row for each extension to reader mapping in settings""" fnpattern2reader = get_settings().plugins.extension2reader if len(fnpattern2reader) > 0: for fn_pattern, plugin_name in fnpattern2reader.items(): self._add_new_row(fn_pattern, plugin_name) else: # Display that there are no filename patterns with reader associations self._display_no_preferences_found() def _make_new_preference_row(self): """Make row for user to add a new filename pattern assignment""" edit_row_widget = QWidget() edit_row_widget.setLayout(QGridLayout()) edit_row_widget.layout().setContentsMargins(0, 0, 0, 0) self._fn_pattern_edit = QLineEdit() self._fn_pattern_edit.setPlaceholderText( trans._("Start typing filename pattern...") ) self._fn_pattern_edit.textChanged.connect( self._filter_compatible_readers ) add_reader_widg = QWidget() add_reader_widg.setLayout(QHBoxLayout()) add_reader_widg.layout().setContentsMargins(0, 0, 0, 0) self._new_reader_dropdown = QComboBox() for i, (plugin_name, display_name) in enumerate( sorted(dict(self._npe2_readers, **self._npe1_readers).items()) ): self._add_reader_choice(i, plugin_name, display_name) add_btn = QPushButton(trans._('Add')) add_btn.setToolTip(trans._('Save reader preference for pattern')) add_btn.clicked.connect(self._save_new_preference) add_reader_widg.layout().addWidget(self._new_reader_dropdown) add_reader_widg.layout().addWidget(add_btn) edit_row_widget.layout().addWidget( self._fn_pattern_edit, 0, 0, ) edit_row_widget.layout().addWidget(add_reader_widg, 0, 1) return edit_row_widget def _display_no_preferences_found(self): self._table.setRowCount(1) item = QTableWidgetItem(trans._('No filename preferences found.')) item.setFlags(Qt.ItemFlag.NoItemFlags) self._table.setItem(self._fn_pattern_col, 0, item) def _add_reader_choice(self, i, plugin_name, display_name): """Add dropdown item for plugin_name with reader pattern tooltip""" reader_patterns = get_filename_patterns_for_reader(plugin_name) # TODO: no reader_patterns means directory reader, # we don't support preference association yet if not reader_patterns: return self._new_reader_dropdown.addItem(display_name, plugin_name) if '*' in reader_patterns: tooltip_text = trans._('Accepts all') else: reader_patterns_formatted = ', '.join( sorted(list(reader_patterns)) ) tooltip_text = trans._( 'Accepts: {reader_patterns_formatted}', reader_patterns_formatted=reader_patterns_formatted, ) self._new_reader_dropdown.setItemData( i, tooltip_text, role=Qt.ItemDataRole.ToolTipRole ) def _filter_compatible_readers(self, new_pattern): """Filter reader dropwdown items to those that accept `new_extension`""" self._new_reader_dropdown.clear() readers = self._npe2_readers.copy() to_delete = [] compatible_readers = get_potential_readers(new_pattern) for plugin_name in readers: if plugin_name not in compatible_readers: to_delete.append(plugin_name) for reader in to_delete: del readers[reader] readers.update(self._npe1_readers) if not readers: self._new_reader_dropdown.addItem(trans._("None available")) else: for i, (plugin_name, display_name) in enumerate( sorted(readers.items()) ): self._add_reader_choice(i, plugin_name, display_name) def _save_new_preference(self, event): """Save current preference to settings and show in table""" fn_pattern = self._fn_pattern_edit.text() reader = self._new_reader_dropdown.currentData() if not fn_pattern or not reader: return # if user types pattern that starts with a . it's probably a file extension so prepend the * if fn_pattern.startswith('.'): fn_pattern = f'*{fn_pattern}' if fn_pattern in get_settings().plugins.extension2reader: self._edit_existing_preference(fn_pattern, reader) else: self._add_new_row(fn_pattern, reader) get_settings().plugins.extension2reader = { **get_settings().plugins.extension2reader, fn_pattern: reader, } def _edit_existing_preference(self, fn_pattern, reader): """Edit existing extension preference""" current_reader_label = self.findChild(QLabel, fn_pattern) if reader in self._npe2_readers: reader = self._npe2_readers[reader] current_reader_label.setText(reader) def _add_new_row(self, fn_pattern, reader): """Add new reader preference to table""" last_row = self._table.rowCount() if ( last_row == 1 and 'No filename preferences found' in self._table.item(0, 0).text() ): self._table.removeRow(0) last_row = 0 self._table.insertRow(last_row) item = QTableWidgetItem(fn_pattern) if fn_pattern.endswith(os.sep): item.setTextAlignment(Qt.AlignmentFlag.AlignLeft) item.setFlags(Qt.ItemFlag.NoItemFlags) self._table.setItem(last_row, self._fn_pattern_col, item) plugin_widg = QWidget() # need object name to easily find row plugin_widg.setObjectName(f'{fn_pattern}') plugin_widg.setLayout(QHBoxLayout()) plugin_widg.layout().setContentsMargins(0, 0, 0, 0) if reader in self._npe2_readers: reader = self._npe2_readers[reader] plugin_label = QLabel(reader, objectName=fn_pattern) # need object name to easily work out which button was clicked remove_btn = QPushButton('X', objectName=fn_pattern) remove_btn.setFixedWidth(30) remove_btn.setStyleSheet('margin: 4px;') remove_btn.setToolTip( trans._('Remove this filename pattern to reader association') ) remove_btn.clicked.connect(self.remove_existing_preference) plugin_widg.layout().addWidget(plugin_label) plugin_widg.layout().addWidget(remove_btn) self._table.setCellWidget(last_row, self._reader_col, plugin_widg) def remove_existing_preference(self, event): """Delete extension to reader mapping setting and remove table row""" pattern_to_remove = self.sender().objectName() current_settings = get_settings().plugins.extension2reader # need explicit assignment to new object here for persistence get_settings().plugins.extension2reader = { k: v for k, v in current_settings.items() if k != pattern_to_remove } for i in range(self._table.rowCount()): row_widg_name = self._table.cellWidget( i, self._reader_col ).objectName() if row_widg_name == pattern_to_remove: self._table.removeRow(i) break if self._table.rowCount() == 0: self._display_no_preferences_found() def value(self): """Return extension:reader mapping from settings. Returns ------- Dict[str, str] mapping of extension to reader plugin display name """ return get_settings().plugins.extension2reader napari-0.5.0a1/napari/_qt/widgets/qt_highlight_preview.py000066400000000000000000000361111437041365600235140ustar00rootroot00000000000000import numpy as np from qtpy.QtCore import QSize, Qt, Signal from qtpy.QtGui import QColor, QIntValidator, QPainter, QPainterPath, QPen from qtpy.QtWidgets import ( QFrame, QHBoxLayout, QLabel, QLineEdit, QSlider, QVBoxLayout, QWidget, ) from napari.utils.translations import translator trans = translator.load() class QtStar(QFrame): """Creates a star for the preview pane in the highlight widget. Parameters ---------- value : int The line width of the star. """ def __init__( self, parent: QWidget = None, value: int = None, ) -> None: super().__init__(parent) self._value = value def sizeHint(self): """Override Qt sizeHint.""" return QSize(100, 100) def minimumSizeHint(self): """Override Qt minimumSizeHint.""" return QSize(100, 100) def paintEvent(self, e): """Paint star on frame.""" qp = QPainter() qp.begin(self) self.drawStar(qp) qp.end() def value(self): """Return value of star widget. Returns ------- int The value of the star widget. """ return self._value def setValue(self, value: int): """Set line width value of star widget. Parameters ---------- value : int line width value for star """ self._value = value self.update() def drawStar(self, qp): """Draw a star in the preview pane. Parameters ---------- qp : QPainter object """ width = self.rect().width() height = self.rect().height() col = QColor(135, 206, 235) pen = QPen(col, self._value) pen.setJoinStyle(Qt.PenJoinStyle.MiterJoin) qp.setPen(pen) path = QPainterPath() # draw pentagram star_center_x = width / 2 star_center_y = height / 2 # make sure the star equal no matter the size of the qframe radius_outer = width * 0.35 if width < height else height * 0.35 # start at the top point of the star and move counter clockwise to draw the path. # every other point is the shorter radius (1/(1+golden_ratio)) of the larger radius golden_ratio = (1 + np.sqrt(5)) / 2 radius_inner = radius_outer / (1 + golden_ratio) theta_start = np.pi / 2 theta_inc = (2 * np.pi) / 10 for n in range(11): theta = theta_start + (n * theta_inc) theta = np.mod(theta, 2 * np.pi) if np.mod(n, 2) == 0: # use radius_outer x = radius_outer * np.cos(theta) y = radius_outer * np.sin(theta) else: # use radius_inner x = radius_inner * np.cos(theta) y = radius_inner * np.sin(theta) x_adj = star_center_x - x y_adj = star_center_y - y + 3 if n == 0: path.moveTo(x_adj, y_adj) else: path.lineTo(x_adj, y_adj) qp.drawPath(path) class QtTriangle(QFrame): """Draw the triangle in highlight widget. Parameters ---------- value : int Current value of the highlight size. min_value : int Minimum value possible for highlight size. max_value : int Maximum value possible for highlight size. """ valueChanged = Signal(int) def __init__( self, parent: QWidget = None, value: int = 1, min_value: int = 1, max_value: int = 10, ) -> None: super().__init__(parent) self._max_value = max_value self._min_value = min_value self._value = value def mousePressEvent(self, event): """When mouse is clicked, adjust to new values.""" # set value based on position of event perc = event.pos().x() / self.rect().width() value = ((self._max_value - self._min_value) * perc) + self._min_value self.setValue(value) def paintEvent(self, e): """Paint triangle on frame.""" qp = QPainter() qp.begin(self) self.drawTriangle(qp) perc = (self._value - self._min_value) / ( self._max_value - self._min_value ) self.drawLine(qp, self.rect().width() * perc) qp.end() def sizeHint(self): """Override Qt sizeHint.""" return QSize(75, 30) def minimumSizeHint(self): """Override Qt minimumSizeHint.""" return QSize(75, 30) def drawTriangle(self, qp): """Draw triangle. Parameters ---------- qp : QPainter object """ width = self.rect().width() col = QColor(135, 206, 235) qp.setPen(QPen(col, 1)) qp.setBrush(col) path = QPainterPath() height = 10 path.moveTo(0, height) path.lineTo(width, height) path.lineTo(width, 0) path.closeSubpath() qp.drawPath(path) def value(self): """Return value of triangle widget. Returns ------- int Current value of triangle widget. """ return self._value def setValue(self, value): """Set value for triangle widget. Parameters ---------- value : int Value to use for line in triangle widget. """ self._value = value self.update() def minimum(self): """Return minimum value. Returns ------- int Mininum value of triangle widget. """ return self._min_value def maximum(self): """Return maximum value. Returns ------- int Maximum value of triangle widget. """ return self._max_value def setMinimum(self, value: int): """Set minimum value Parameters ---------- value : int Minimum value of triangle. """ self._min_value = value self._value = max(self._value, value) def setMaximum(self, value: int): """Set maximum value. Parameters ---------- value : int Maximum value of triangle. """ self._max_value = value self._value = min(self._value, value) def drawLine(self, qp, value: int): """Draw line on triangle indicating value. Parameters ---------- qp : QPainter object value : int Value of highlight thickness. """ col = QColor('white') qp.setPen(QPen(col, 2)) qp.setBrush(col) path = QPainterPath() path.moveTo(value, 15) path.lineTo(value, 0) path.closeSubpath() qp.drawPath(path) self.valueChanged.emit(self._value) class QtHighlightSizePreviewWidget(QWidget): """Creates custom widget to set highlight size. Parameters ---------- description : str Text to explain and display on widget. value : int Value of highlight size. min_value : int Minimum possible value of highlight size. max_value : int Maximum possible value of highlight size. unit : str Unit of highlight size. """ valueChanged = Signal(int) def __init__( self, parent: QWidget = None, description: str = "", value: int = 1, min_value: int = 1, max_value: int = 10, unit: str = "px", ) -> None: super().__init__(parent) self.setGeometry(300, 300, 125, 110) self._value = value or self.fontMetrics().height() self._min_value = min_value self._max_value = max_value # Widget self._lineedit = QLineEdit() self._description = QLabel(self) self._unit = QLabel(self) self._slider = QSlider(Qt.Orientation.Horizontal) self._triangle = QtTriangle(self) self._slider_min_label = QLabel(self) self._slider_max_label = QLabel(self) self._preview = QtStar(self) self._preview_label = QLabel(self) self._validator = QIntValidator(min_value, max_value, self) # Widgets setup self._description.setText(description) self._description.setWordWrap(True) self._unit.setText(unit) self._unit.setAlignment(Qt.AlignmentFlag.AlignBottom) self._lineedit.setValidator(self._validator) self._lineedit.setAlignment(Qt.AlignmentFlag.AlignRight) self._lineedit.setAlignment(Qt.AlignmentFlag.AlignBottom) self._slider_min_label.setText(str(min_value)) self._slider_min_label.setAlignment(Qt.AlignmentFlag.AlignBottom) self._slider_max_label.setText(str(max_value)) self._slider_max_label.setAlignment(Qt.AlignmentFlag.AlignBottom) self._slider.setMinimum(min_value) self._slider.setMaximum(max_value) self._preview.setValue(value) self._triangle.setValue(value) self._triangle.setMinimum(min_value) self._triangle.setMaximum(max_value) self._preview_label.setText(trans._("Preview")) self._preview_label.setAlignment(Qt.AlignmentFlag.AlignHCenter) self._preview_label.setAlignment(Qt.AlignmentFlag.AlignBottom) self._preview.setStyleSheet('border: 1px solid white;') # Signals self._slider.valueChanged.connect(self._update_value) self._lineedit.textChanged.connect(self._update_value) self._triangle.valueChanged.connect(self._update_value) # Layout triangle_layout = QHBoxLayout() triangle_layout.addWidget(self._triangle) triangle_layout.setContentsMargins(6, 35, 6, 0) triangle_slider_layout = QVBoxLayout() triangle_slider_layout.addLayout(triangle_layout) triangle_slider_layout.setContentsMargins(0, 0, 0, 0) triangle_slider_layout.addWidget(self._slider) triangle_slider_layout.setAlignment(Qt.AlignmentFlag.AlignVCenter) # Bottom row layout lineedit_layout = QHBoxLayout() lineedit_layout.addWidget(self._lineedit) lineedit_layout.setAlignment(Qt.AlignmentFlag.AlignBottom) bottom_left_layout = QHBoxLayout() bottom_left_layout.addLayout(lineedit_layout) bottom_left_layout.addWidget(self._unit) bottom_left_layout.addWidget(self._slider_min_label) bottom_left_layout.addLayout(triangle_slider_layout) bottom_left_layout.addWidget(self._slider_max_label) bottom_left_layout.setAlignment(Qt.AlignmentFlag.AlignBottom) left_layout = QVBoxLayout() left_layout.addWidget(self._description) left_layout.addLayout(bottom_left_layout) left_layout.setAlignment(Qt.AlignmentFlag.AlignLeft) preview_label_layout = QHBoxLayout() preview_label_layout.addWidget(self._preview_label) preview_label_layout.setAlignment(Qt.AlignmentFlag.AlignHCenter) preview_layout = QVBoxLayout() preview_layout.addWidget(self._preview) preview_layout.addLayout(preview_label_layout) preview_layout.setAlignment(Qt.AlignmentFlag.AlignCenter) layout = QHBoxLayout() layout.addLayout(left_layout) layout.addLayout(preview_layout) self.setLayout(layout) self._refresh() def _update_value(self, value): """Update highlight value. Parameters ---------- value : int Highlight value. """ if value == "": return value = int(value) value = max(min(value, self._max_value), self._min_value) if value == self._value: return self._value = value self.valueChanged.emit(self._value) self._refresh() def _refresh(self): """Set every widget value to the new set value.""" self.blockSignals(True) self._lineedit.setText(str(self._value)) self._slider.setValue(self._value) self._triangle.setValue(self._value) self._preview.setValue(self._value) self.blockSignals(False) def value(self): """Return current value. Returns ------- int Current value of highlight widget. """ return self._value def setValue(self, value): """Set new value and update widget. Parameters ---------- value : int Highlight value. """ self._update_value(value) self._refresh() def description(self): """Return the description text. Returns ------- str Current text in description. """ return self._description.text() def setDescription(self, text): """Set the description text. Parameters ---------- text : str Text to use in description box. """ self._description.setText(text) def unit(self): """Return highlight value unit text. Returns ------- str Current text in unit text. """ return self._unit.text() def setUnit(self, text): """Set highlight value unit. Parameters ---------- text : str Text used to describe units. """ self._unit.setText(text) def setMinimum(self, value): """Set minimum highlight value for star, triangle, text and slider. Parameters ---------- value : int Minimum highlight value. """ value = int(value) if value >= self._max_value: raise ValueError( trans._( "Minimum value must be smaller than {max_value}", deferred=True, max_value=self._max_value, ) ) self._min_value = value self._slider_min_label.setText(str(value)) self._slider.setMinimum(value) self._triangle.setMinimum(value) self._value = max(self._value, self._min_value) self._refresh() def minimum(self): """Return minimum highlight value. Returns ------- int Minimum value of highlight widget. """ return self._min_value def setMaximum(self, value): """Set maximum highlight value. Parameters ---------- value : int Maximum highlight value. """ value = int(value) if value <= self._min_value: raise ValueError( trans._( "Maximum value must be larger than {min_value}", deferred=True, min_value=self._min_value, ) ) self._max_value = value self._slider_max_label.setText(str(value)) self._slider.setMaximum(value) self._triangle.setMaximum(value) self._value = min(self._value, self._max_value) self._refresh() def maximum(self): """Return maximum highlight value. Returns ------- int Maximum value of highlight widget. """ return self._max_value napari-0.5.0a1/napari/_qt/widgets/qt_keyboard_settings.py000066400000000000000000000544671437041365600235420ustar00rootroot00000000000000import contextlib import re from collections import OrderedDict from qtpy.QtCore import QEvent, QPoint, Qt, Signal from qtpy.QtGui import QKeySequence from qtpy.QtWidgets import ( QAbstractItemView, QComboBox, QHBoxLayout, QItemDelegate, QKeySequenceEdit, QLabel, QLineEdit, QMessageBox, QPushButton, QTableWidget, QTableWidgetItem, QVBoxLayout, QWidget, ) from vispy.util import keys from napari._qt.widgets.qt_message_popup import WarnPopup from napari.layers import Image, Labels, Points, Shapes, Surface, Vectors from napari.settings import get_settings from napari.utils.action_manager import action_manager from napari.utils.interactions import Shortcut from napari.utils.translations import trans # Dict used to format strings returned from converted key press events. # For example, the ShortcutTranslator returns 'Ctrl' instead of 'Control'. # In order to be consistent with the code base, the values in KEY_SUBS will # be subsituted. KEY_SUBS = {'Ctrl': 'Control'} class ShortcutEditor(QWidget): """Widget to edit keybindings for napari.""" valueChanged = Signal(dict) VIEWER_KEYBINDINGS = trans._('Viewer key bindings') def __init__( self, parent: QWidget = None, description: str = "", value: dict = None, ) -> None: super().__init__(parent=parent) # Flag to not run _set_keybinding method after setting special symbols. # When changing line edit to special symbols, the _set_keybinding # method will be called again (and breaks) and is not needed. self._skip = False layers = [ Image, Labels, Points, Shapes, Surface, Vectors, ] self.key_bindings_strs = OrderedDict() # widgets self.layer_combo_box = QComboBox(self) self._label = QLabel(self) self._table = QTableWidget(self) self._table.setSelectionBehavior(QAbstractItemView.SelectItems) self._table.setSelectionMode(QAbstractItemView.SingleSelection) self._table.setShowGrid(False) self._restore_button = QPushButton(trans._("Restore All Keybindings")) # Set up dictionary for layers and associated actions. all_actions = action_manager._actions.copy() self.key_bindings_strs[self.VIEWER_KEYBINDINGS] = {} for layer in layers: if len(layer.class_keymap) == 0: actions = {} else: actions = action_manager._get_provider_actions(layer) for name in actions.keys(): all_actions.pop(name) self.key_bindings_strs[f"{layer.__name__} layer"] = actions # Left over actions can go here. self.key_bindings_strs[self.VIEWER_KEYBINDINGS] = all_actions # Widget set up self.layer_combo_box.addItems(list(self.key_bindings_strs)) self.layer_combo_box.currentTextChanged.connect(self._set_table) self.layer_combo_box.setCurrentText(self.VIEWER_KEYBINDINGS) self._set_table() self._label.setText(trans._("Group")) self._restore_button.clicked.connect(self.restore_defaults) # layout hlayout1 = QHBoxLayout() hlayout1.addWidget(self._label) hlayout1.addWidget(self.layer_combo_box) hlayout1.setContentsMargins(0, 0, 0, 0) hlayout1.setSpacing(20) hlayout1.addStretch(0) hlayout2 = QHBoxLayout() hlayout2.addLayout(hlayout1) hlayout2.addWidget(self._restore_button) layout = QVBoxLayout() layout.addLayout(hlayout2) layout.addWidget(self._table) layout.addWidget( QLabel( trans._( "To edit, double-click the keybinding. To unbind a shortcut, use Backspace or Delete. To set Backspace or Delete, first unbind." ) ) ) self.setLayout(layout) def restore_defaults(self): """Launches dialog to confirm restore choice.""" response = QMessageBox.question( self, trans._("Restore Shortcuts"), trans._("Are you sure you want to restore default shortcuts?"), QMessageBox.RestoreDefaults | QMessageBox.Cancel, QMessageBox.RestoreDefaults, ) if response == QMessageBox.RestoreDefaults: self._reset_shortcuts() def _reset_shortcuts(self): """Reset shortcuts to default settings.""" get_settings().shortcuts.reset() for ( action, shortcuts, ) in get_settings().shortcuts.shortcuts.items(): action_manager.unbind_shortcut(action) for shortcut in shortcuts: action_manager.bind_shortcut(action, shortcut) self._set_table(layer_str=self.layer_combo_box.currentText()) def _set_table(self, layer_str: str = ''): """Builds and populates keybindings table. Parameters ---------- layer_str : str If layer_str is not empty, then show the specified layers' keybinding shortcut table. """ # Keep track of what is in each column. self._action_name_col = 0 self._icon_col = 1 self._shortcut_col = 2 self._shortcut_col2 = 3 self._action_col = 4 # Set header strings for table. header_strs = ['', '', '', '', ''] header_strs[self._action_name_col] = trans._('Action') header_strs[self._shortcut_col] = trans._('Keybinding') header_strs[self._shortcut_col2] = trans._('Alternative Keybinding') # If no layer_str, then set the page to the viewer keybindings page. if not layer_str: layer_str = self.VIEWER_KEYBINDINGS # If rebuilding the table, then need to disconnect the connection made # previously as well as clear the table contents. with contextlib.suppress(TypeError, RuntimeError): self._table.cellChanged.disconnect(self._set_keybinding) self._table.clearContents() # Table styling set up. self._table.horizontalHeader().setStretchLastSection(True) self._table.horizontalHeader().setStyleSheet( 'border-bottom: 2px solid white;' ) # Get all actions for the layer. actions = self.key_bindings_strs[layer_str] if len(actions) > 0: # Set up table based on number of actions and needed columns. self._table.setRowCount(len(actions)) self._table.setColumnCount(5) # Set up delegate in order to capture keybindings. self._table.setItemDelegateForColumn( self._shortcut_col, ShortcutDelegate(self._table) ) self._table.setItemDelegateForColumn( self._shortcut_col2, ShortcutDelegate(self._table) ) self._table.setHorizontalHeaderLabels(header_strs) self._table.verticalHeader().setVisible(False) # Hide the column with action names. These are kept here for reference when needed. self._table.setColumnHidden(self._action_col, True) # Column set up. self._table.setColumnWidth(self._action_name_col, 250) self._table.setColumnWidth(self._shortcut_col, 200) self._table.setColumnWidth(self._shortcut_col2, 200) self._table.setColumnWidth(self._icon_col, 50) # Go through all the actions in the layer and add them to the table. for row, (action_name, action) in enumerate(actions.items()): shortcuts = action_manager._shortcuts.get(action_name, []) # Set action description. Make sure its not selectable/editable. item = QTableWidgetItem(action.description) item.setFlags(Qt.ItemFlag.NoItemFlags) self._table.setItem(row, self._action_name_col, item) # Create empty item in order to make sure this column is not # selectable/editable. item = QTableWidgetItem("") item.setFlags(Qt.ItemFlag.NoItemFlags) self._table.setItem(row, self._icon_col, item) # Set the shortcuts in table. item_shortcut = QTableWidgetItem( Shortcut(list(shortcuts)[0]).platform if shortcuts else "" ) self._table.setItem(row, self._shortcut_col, item_shortcut) item_shortcut2 = QTableWidgetItem( Shortcut(list(shortcuts)[1]).platform if len(shortcuts) > 1 else "" ) self._table.setItem(row, self._shortcut_col2, item_shortcut2) # action_name is stored in the table to use later, but is not shown on dialog. item_action = QTableWidgetItem(action_name) self._table.setItem(row, self._action_col, item_action) # If a cell is changed, run .set_keybinding. self._table.cellChanged.connect(self._set_keybinding) else: # Display that there are no actions for this layer. self._table.setRowCount(1) self._table.setColumnCount(4) self._table.setHorizontalHeaderLabels(header_strs) self._table.verticalHeader().setVisible(False) self._table.setColumnHidden(self._action_col, True) item = QTableWidgetItem(trans._('No key bindings')) item.setFlags(Qt.ItemFlag.NoItemFlags) self._table.setItem(0, 0, item) def _get_layer_actions(self): current_layer_text = self.layer_combo_box.currentText() layer_actions = self.key_bindings_strs[current_layer_text] actions_all = layer_actions.copy() if current_layer_text is not self.VIEWER_KEYBINDINGS: viewer_actions = self.key_bindings_strs[self.VIEWER_KEYBINDINGS] actions_all.update(viewer_actions) return actions_all def _restore_shortcuts(self, row): action_name = self._table.item(row, self._action_col).text() shortcuts = action_manager._shortcuts.get(action_name, []) with lock_keybind_update(self): self._table.item(row, self._shortcut_col).setText( Shortcut(list(shortcuts)[0]).platform if shortcuts else "" ) self._table.item(row, self._shortcut_col2).setText( Shortcut(list(shortcuts)[1]).platform if len(shortcuts) > 1 else "" ) def _mark_conflicts(self, new_shortcut, row) -> bool: # Go through all layer actions to determine if the new shortcut is already here. current_action = self._table.item(row, self._action_col).text() actions_all = self._get_layer_actions() current_item = self._table.currentItem() for row1, (action_name, action) in enumerate(actions_all.items()): shortcuts = action_manager._shortcuts.get(action_name, []) if new_shortcut not in shortcuts: continue # Shortcut is here (either same action or not), don't replace in settings. if action_name != current_action: # the shortcut is saved to a different action # show warning symbols self._show_warning_icons([row, row1]) # show warning message message = trans._( "The keybinding {new_shortcut} is already assigned to {action_description}; change or clear that shortcut before assigning {new_shortcut} to this one.", new_shortcut=new_shortcut, action_description=action.description, ) self._show_warning(new_shortcut, action, row, message) self._restore_shortcuts(row) self._cleanup_warning_icons([row, row1]) return False else: # This shortcut was here. Reformat and reset text. format_shortcut = Shortcut(new_shortcut).platform with lock_keybind_update(self): current_item.setText(format_shortcut) return True def _show_bind_shortcut_error( self, current_action, current_shortcuts, row, new_shortcut ): action_manager._shortcuts[current_action] = [] # need to rebind the old shortcut action_manager.unbind_shortcut(current_action) for short in current_shortcuts: action_manager.bind_shortcut(current_action, short) # Show warning message to let user know this shortcut is invalid. self._show_warning_icons([row]) message = trans._( "{new_shortcut} is not a valid keybinding.", new_shortcut=new_shortcut, ) self._show_warning(new_shortcut, current_action, row, message) self._cleanup_warning_icons([row]) self._restore_shortcuts(row) def _set_keybinding(self, row, col): """Checks the new keybinding to determine if it can be set. Parameters ---------- row : int Row in keybindings table that is being edited. col : int Column being edited (shortcut column). """ if self._skip: return self._table.setCurrentItem(self._table.item(row, col)) if col in {self._shortcut_col, self._shortcut_col2}: # Get all layer actions and viewer actions in order to determine # the new shortcut is not already set to an action. current_layer_text = self.layer_combo_box.currentText() layer_actions = self.key_bindings_strs[current_layer_text] actions_all = layer_actions.copy() if current_layer_text is not self.VIEWER_KEYBINDINGS: viewer_actions = self.key_bindings_strs[ self.VIEWER_KEYBINDINGS ] actions_all.update(viewer_actions) # get the current item from shortcuts column current_item = self._table.currentItem() new_shortcut = current_item.text() if new_shortcut: new_shortcut = new_shortcut[0].upper() + new_shortcut[1:] # get the current action name current_action = self._table.item(row, self._action_col).text() # get the original shortcutS current_shortcuts = list( action_manager._shortcuts.get(current_action, []) ) # Flag to indicate whether to set the new shortcut. replace = self._mark_conflicts(new_shortcut, row) if replace is True: # This shortcut is not taken. # Unbind current action from shortcuts in action manager. action_manager.unbind_shortcut(current_action) shortcuts_list = list(current_shortcuts) ind = col - self._shortcut_col if new_shortcut != "": if ind < len(shortcuts_list): shortcuts_list[ind] = new_shortcut else: shortcuts_list.append(new_shortcut) elif ind < len(shortcuts_list): shortcuts_list.pop(col - self._shortcut_col) new_value_dict = {} # Bind the new shortcut. try: for short in shortcuts_list: action_manager.bind_shortcut(current_action, short) except TypeError: self._show_bind_shortcut_error( current_action, current_shortcuts, row, new_shortcut, ) return # The new shortcut is valid and can be displayed in widget. # Keep track of what changed. new_value_dict = {current_action: shortcuts_list} self._restore_shortcuts(row) # Emit signal when new value set for shortcut. self.valueChanged.emit(new_value_dict) def _show_warning_icons(self, rows): """Creates and displays the warning icons. Parameters ---------- rows : list[int] List of row numbers that should have the icon. """ for row in rows: self.warning_indicator = QLabel(self) self.warning_indicator.setObjectName("error_label") self._table.setCellWidget( row, self._icon_col, self.warning_indicator ) def _cleanup_warning_icons(self, rows): """Remove the warning icons from the shortcut table. Parameters ---------- rows : list[int] List of row numbers to remove warning icon from. """ for row in rows: self._table.setCellWidget(row, self._icon_col, QLabel("")) def _show_warning(self, new_shortcut='', action=None, row=0, message=''): """Creates and displays warning message when shortcut is already assigned. Parameters ---------- new_shortcut : str The new shortcut attempting to be set. action : Action Action that is already assigned with the shortcut. row : int Row in table where the shortcut is attempting to be set. message : str Message to be displayed in warning pop up. """ # Determine placement of warning message. delta_y = 105 delta_x = 10 global_point = self.mapToGlobal( QPoint( self._table.columnViewportPosition(self._shortcut_col) + delta_x, self._table.rowViewportPosition(row) + delta_y, ) ) # Create warning pop up and move it to desired position. self._warn_dialog = WarnPopup( text=message, ) self._warn_dialog.move(global_point) # Styling adjustments. self._warn_dialog.resize(250, self._warn_dialog.sizeHint().height()) self._warn_dialog._message.resize( 200, self._warn_dialog._message.sizeHint().height() ) self._warn_dialog.exec_() def value(self): """Return the actions and shortcuts currently assigned in action manager. Returns ------- value: dict Dictionary of action names and shortcuts assigned to them. """ value = {} for action_name in action_manager._actions.keys(): shortcuts = action_manager._shortcuts.get(action_name, []) value[action_name] = list(shortcuts) return value class ShortcutDelegate(QItemDelegate): """Delegate that handles when user types in new shortcut.""" def createEditor(self, widget, style_option, model_index): self._editor = EditorWidget(widget) return self._editor def setEditorData(self, widget, model_index): text = model_index.model().data(model_index, Qt.ItemDataRole.EditRole) widget.setText(str(text) if text else "") def updateEditorGeometry(self, widget, style_option, model_index): widget.setGeometry(style_option.rect) def setModelData(self, widget, abstract_item_model, model_index): text = widget.text() abstract_item_model.setData( model_index, text, Qt.ItemDataRole.EditRole ) class EditorWidget(QLineEdit): """Editor widget set in the delegate column in shortcut table.""" def __init__(self, parent=None) -> None: super().__init__(parent) def event(self, event): """Qt method override.""" if event.type() == QEvent.Type.ShortcutOverride: self.keyPressEvent(event) return True elif event.type() in [QEvent.Type.KeyPress, QEvent.Type.Shortcut]: return True else: return super().event(event) def keyPressEvent(self, event): """Qt method override.""" event_key = event.key() if not event_key or event_key == Qt.Key.Key_unknown: return if ( event_key in {Qt.Key.Key_Delete, Qt.Key.Key_Backspace} and self.text() != '' ): # Allow user to delete shortcut. self.setText('') return key_map = { Qt.Key.Key_Control: keys.CONTROL.name, Qt.Key.Key_Shift: keys.SHIFT.name, Qt.Key.Key_Alt: keys.ALT.name, Qt.Key.Key_Meta: keys.META.name, Qt.Key.Key_Delete: keys.DELETE.name, } if event_key in key_map: self.setText(key_map[event_key]) return if event_key in { Qt.Key.Key_Return, Qt.Key.Key_Tab, Qt.Key.Key_CapsLock, Qt.Key.Key_Enter, }: # Do not allow user to set these keys as shortcut. return # Translate key value to key string. translator = ShortcutTranslator() event_keyseq = translator.keyevent_to_keyseq(event) event_keystr = event_keyseq.toString(QKeySequence.PortableText) # Split the shortcut if it contains a symbol. parsed = re.split('[-(?=.+)]', event_keystr) keys_li = [] # Format how the shortcut is written (ex. 'Ctrl+B' is changed to 'Control-B') for val in parsed: if val in KEY_SUBS.keys(): keys_li.append(KEY_SUBS[val]) else: keys_li.append(val) keys_li = '-'.join(keys_li) self.setText(keys_li) class ShortcutTranslator(QKeySequenceEdit): """ Convert QKeyEvent into QKeySequence. """ def __init__(self) -> None: super().__init__() self.hide() def keyevent_to_keyseq(self, event): """Return a QKeySequence representation of the provided QKeyEvent.""" self.keyPressEvent(event) event.accept() return self.keySequence() def keyReleaseEvent(self, event): """Qt Override""" return False def timerEvent(self, event): """Qt Override""" return False def event(self, event): """Qt Override""" return False @contextlib.contextmanager def lock_keybind_update(widget: ShortcutEditor): prev = widget._skip widget._skip = True try: yield finally: widget._skip = prev napari-0.5.0a1/napari/_qt/widgets/qt_message_popup.py000066400000000000000000000021531437041365600226520ustar00rootroot00000000000000from qtpy.QtCore import Qt from qtpy.QtWidgets import QDialog, QLabel, QPushButton, QVBoxLayout from napari._qt.qt_resources import get_stylesheet from napari.settings import get_settings class WarnPopup(QDialog): """Dialog to inform user that shortcut is already assigned.""" def __init__( self, parent=None, text: str = "", ) -> None: super().__init__(parent) self.setWindowFlags(Qt.WindowType.FramelessWindowHint) # Widgets self._message = QLabel() self._xbutton = QPushButton('x', self) self._xbutton.setFixedSize(20, 20) # Widget set up self._message.setText(text) self._message.setWordWrap(True) self._xbutton.clicked.connect(self._close) self._xbutton.setStyleSheet("background-color: rgba(0, 0, 0, 0);") # Layout main_layout = QVBoxLayout() main_layout.addWidget(self._message) self.setLayout(main_layout) self.setStyleSheet(get_stylesheet(get_settings().appearance.theme)) self._xbutton.raise_() def _close(self): self.close() napari-0.5.0a1/napari/_qt/widgets/qt_mode_buttons.py000066400000000000000000000054161437041365600225120ustar00rootroot00000000000000import weakref from qtpy.QtWidgets import QPushButton, QRadioButton class QtModeRadioButton(QRadioButton): """Creates a radio button that can enable a specific layer mode. Parameters ---------- layer : napari.layers.Layer The layer instance that this button controls. button_name : str Name for the button. This is mostly used to identify the button in stylesheets (e.g. to add a custom icon) mode : Enum The mode to enable when this button is clicked. tooltip : str, optional A tooltip to display when hovering the mouse on this button, by default it will be set to `button_name`. checked : bool, optional Whether the button is activate, by default False. One button in a QButtonGroup should be initially checked. Attributes ---------- layer : napari.layers.Layer The layer instance that this button controls. """ def __init__( self, layer, button_name, mode, *, tooltip=None, checked=False ) -> None: super().__init__() self.layer_ref = weakref.ref(layer) self.setToolTip(tooltip or button_name) self.setChecked(checked) self.setProperty('mode', button_name) self.setFixedWidth(28) self.mode = mode if mode is not None: self.toggled.connect(self._set_mode) def _set_mode(self, bool): """Toggle the mode associated with the layer. Parameters ---------- bool : bool Whether this mode is currently selected or not. """ layer = self.layer_ref() if layer is None: return with layer.events.mode.blocker(self._set_mode): if bool: layer.mode = self.mode class QtModePushButton(QPushButton): """Creates a radio button that can trigger a specific action. Parameters ---------- layer : napari.layers.Layer The layer instance that this button controls. button_name : str Name for the button. This is mostly used to identify the button in stylesheets (e.g. to add a custom icon) slot : callable, optional The function to call when this button is clicked. tooltip : str, optional A tooltip to display when hovering the mouse on this button. Attributes ---------- layer : napari.layers.Layer The layer instance that this button controls. """ def __init__(self, layer, button_name, *, slot=None, tooltip=None) -> None: super().__init__() self.layer = layer self.setProperty('mode', button_name) self.setToolTip(tooltip or button_name) self.setFixedWidth(28) self.setFixedHeight(28) if slot is not None: self.clicked.connect(slot) napari-0.5.0a1/napari/_qt/widgets/qt_plugin_sorter.py000066400000000000000000000351051437041365600227020ustar00rootroot00000000000000"""Provides a QtPluginSorter that allows the user to change plugin call order. """ from __future__ import annotations import re from typing import TYPE_CHECKING, List, Optional, Union from napari_plugin_engine import HookCaller, HookImplementation from qtpy.QtCore import QEvent, Qt, Signal, Slot from qtpy.QtWidgets import ( QAbstractItemView, QCheckBox, QComboBox, QFrame, QGraphicsOpacityEffect, QHBoxLayout, QLabel, QListView, QListWidget, QListWidgetItem, QSizePolicy, QVBoxLayout, QWidget, ) from superqt import QElidingLabel from napari._qt.utils import drag_with_pixmap from napari._qt.widgets.qt_tooltip import QtToolTipLabel from napari.plugins import plugin_manager as napari_plugin_manager from napari.settings import get_settings from napari.utils.translations import trans if TYPE_CHECKING: from napari_plugin_engine import PluginManager def rst2html(text): def ref(match): _text = match.groups()[0].split()[0] if _text.startswith("~"): _text = _text.split(".")[-1] return f'``{_text}``' def link(match): _text, _link = match.groups()[0].split('<') return f'")}">{_text.strip()}' text = re.sub(r'\*\*([^*]+)\*\*', '\\1', text) text = re.sub(r'\*([^*]+)\*', '\\1', text) text = re.sub(r':[a-z]+:`([^`]+)`', ref, text, re.DOTALL) text = re.sub(r'`([^`]+)`_', link, text, re.DOTALL) text = re.sub(r'``([^`]+)``', '\\1', text) return text.replace("\n", "
") class ImplementationListItem(QFrame): """A Widget to render each hook implementation item in a ListWidget. Parameters ---------- item : QListWidgetItem An item instance from a QListWidget. This will most likely come from :meth:`QtHookImplementationListWidget.add_hook_implementation_to_list`. parent : QWidget, optional The parent widget, by default None Attributes ---------- plugin_name_label : QLabel The name of the plugin providing the hook implementation. enabled_checkbox : QCheckBox Checkbox to set the ``enabled`` status of the corresponding hook implementation. opacity : QGraphicsOpacityEffect The opacity of the whole widget. When self.enabled_checkbox is unchecked, the opacity of the item is decreased. """ on_changed = Signal() # when user changes whether plugin is enabled. def __init__(self, item: QListWidgetItem, parent: QWidget = None) -> None: super().__init__(parent) self.item = item self.opacity = QGraphicsOpacityEffect(self) self.setGraphicsEffect(self.opacity) layout = QHBoxLayout() self.setLayout(layout) self.position_label = QLabel() self.update_position_label() self.setToolTip(trans._("Click and drag to change call order")) self.plugin_name_label = QElidingLabel() self.plugin_name_label.setObjectName('small_text') self.plugin_name_label.setText(item.hook_implementation.plugin_name) plugin_name_size_policy = QSizePolicy( QSizePolicy.MinimumExpanding, QSizePolicy.Preferred ) plugin_name_size_policy.setHorizontalStretch(2) self.plugin_name_label.setSizePolicy(plugin_name_size_policy) self.function_name_label = QLabel( item.hook_implementation.function.__name__ ) self.enabled_checkbox = QCheckBox(self) self.enabled_checkbox.setToolTip( trans._("Uncheck to disable this plugin") ) self.enabled_checkbox.stateChanged.connect(self._set_enabled) self.enabled_checkbox.setChecked( getattr(item.hook_implementation, 'enabled', True) ) layout.addWidget(self.position_label) layout.addWidget(self.enabled_checkbox) layout.addWidget(self.function_name_label) layout.addWidget(self.plugin_name_label) layout.setStretch(2, 1) layout.setContentsMargins(0, 0, 0, 0) def _set_enabled(self, state: Union[bool, int]): """Set the enabled state of this hook implementation to ``state``.""" self.item.hook_implementation.enabled = bool(state) self.opacity.setOpacity(1 if state else 0.5) self.on_changed.emit() def update_position_label(self, order=None): """Update the label showing the position of this item in the list. Parameters ---------- order : list, optional A HookOrderType list ... unused by this function, but here for ease of signal connection, by default None. """ position = self.item.listWidget().indexFromItem(self.item).row() + 1 self.position_label.setText(str(position)) class QtHookImplementationListWidget(QListWidget): """A ListWidget to display & sort the call order of a hook implementation. This class will usually be instantiated by a :class:`~napari._qt.qt_plugin_sorter.QtPluginSorter`. Each item in the list will be rendered as a :class:`ImplementationListItem`. Parameters ---------- parent : QWidget, optional Optional parent widget, by default None hook_caller : HookCaller, optional The ``HookCaller`` for which to show implementations. by default None (i.e. no hooks shown) Attributes ---------- hook_caller : HookCaller or None The current ``HookCaller`` instance being shown in the list. """ order_changed = Signal(list) # emitted when the user changes the order. on_changed = Signal() # when user changes whether plugin is enabled. def __init__( self, parent: Optional[QWidget] = None, hook_caller: Optional[HookCaller] = None, ) -> None: super().__init__(parent) self.setDefaultDropAction(Qt.DropAction.MoveAction) self.setDragEnabled(True) self.setDragDropMode(QListView.InternalMove) self.setSelectionMode(QAbstractItemView.SingleSelection) self.setAcceptDrops(True) self.setSpacing(1) self.setMinimumHeight(1) self.setMaximumHeight(80) self.order_changed.connect(self.permute_hook) self.hook_caller: Optional[HookCaller] = None self.set_hook_caller(hook_caller) def set_hook_caller(self, hook_caller: Optional[HookCaller]): """Set the list widget to show hook implementations for ``hook_caller``. Parameters ---------- hook_caller : HookCaller, optional A ``HookCaller`` for which to show implementations. by default None (i.e. no hooks shown) """ self.clear() self.hook_caller = hook_caller if not hook_caller: return # _nonwrappers returns hook implementations in REVERSE call order # so we reverse them here to show them in the list in the order in # which they get called. for hook_implementation in reversed(hook_caller._nonwrappers): self.append_hook_implementation(hook_implementation) def append_hook_implementation( self, hook_implementation: HookImplementation ): """Add a list item for ``hook_implementation`` with a custom widget. Parameters ---------- hook_implementation : HookImplementation The hook implementation object to add to the list. """ item = QListWidgetItem(self) item.hook_implementation = hook_implementation self.addItem(item) widg = ImplementationListItem(item, parent=self) widg.on_changed.connect(self.on_changed.emit) item.setSizeHint(widg.sizeHint()) self.order_changed.connect(widg.update_position_label) self.setItemWidget(item, widg) def dropEvent(self, event: QEvent): """Triggered when the user moves & drops one of the items in the list. Parameters ---------- event : QEvent The event that triggered the dropEvent. """ super().dropEvent(event) order = [self.item(r).hook_implementation for r in range(self.count())] self.order_changed.emit(order) def startDrag(self, supported_actions): drag = drag_with_pixmap(self) drag.exec_(supported_actions, Qt.DropAction.MoveAction) @Slot(list) def permute_hook(self, order: List[HookImplementation]): """Rearrage the call order of the hooks for the current hook impl. Parameters ---------- order : list A list of str, hook_implementation, or module_or_class, with the desired CALL ORDER of the hook implementations. """ if not self.hook_caller: return self.hook_caller.bring_to_front(order) class QtPluginSorter(QWidget): """Dialog that allows a user to change the call order of plugin hooks. A main QComboBox lets the user pick which hook specification they would like to reorder. Then a :class:`QtHookImplementationListWidget` shows the current call order for all implementations of the current hook specification. The user may then reorder them, or disable them by checking the checkbox next to each hook implementation name. Parameters ---------- plugin_manager : PluginManager, optional An instance of a PluginManager. by default, the main ``napari.plugins.plugin_manager`` instance parent : QWidget, optional Optional parent widget, by default None initial_hook : str, optional If provided the QComboBox at the top of the dialog will be set to this hook, by default None firstresult_only : bool, optional If True, only hook specifications that declare the "firstresult" option will be included. (these are hooks for which only the first non None result is returned). by default True (because it makes less sense to sort hooks where we just collect all results anyway) https://pluggy.readthedocs.io/en/latest/#first-result-only Attributes ---------- hook_combo_box : QComboBox A dropdown menu to select the current hook. hook_list : QtHookImplementationListWidget The list widget that displays (and allows sorting of) all of the hook implementations for the currently selected hook. """ NULL_OPTION = trans._('select hook... ') def __init__( self, plugin_manager: PluginManager = napari_plugin_manager, *, parent: Optional[QWidget] = None, initial_hook: Optional[str] = None, firstresult_only: bool = True, ) -> None: super().__init__(parent) self.plugin_manager = plugin_manager self.hook_combo_box = QComboBox() self.hook_combo_box.addItem(self.NULL_OPTION, None) # populate comboBox with all of the hooks known by the plugin manager for name, hook_caller in plugin_manager.hooks.items(): # only show hooks with specifications if not hook_caller.spec: continue # if the firstresult_only option is set # we only want to include hook_specifications that declare the # "firstresult" option as True. if firstresult_only and not hook_caller.spec.opts.get( 'firstresult', False ): continue self.hook_combo_box.addItem( name.replace("napari_", ""), hook_caller ) self.plugin_manager.events.disabled.connect(self._on_disabled) self.plugin_manager.events.registered.connect(self.refresh) self.hook_combo_box.setToolTip( trans._("select the hook specification to reorder") ) self.hook_combo_box.currentIndexChanged.connect(self._on_hook_change) self.hook_list = QtHookImplementationListWidget(parent=self) self.hook_list.order_changed.connect(self._change_settings_plugins) self.hook_list.on_changed.connect(self._change_settings_plugins) instructions = QLabel( trans._( 'Select a hook to rearrange, then drag and drop plugins into the desired call order.\n\nDisable plugins for a specific hook by unchecking their checkbox.' ) ) instructions.setWordWrap(True) self.docstring = QLabel(self) self.info = QtToolTipLabel(self) self.info.setObjectName("info_icon") doc_lay = QHBoxLayout() doc_lay.addWidget(self.docstring) doc_lay.setStretch(0, 1) doc_lay.addWidget(self.info) self.docstring.setWordWrap(True) self.docstring.setObjectName('small_text') self.info.hide() self.docstring.hide() layout = QVBoxLayout(self) layout.addWidget(instructions) layout.addWidget(self.hook_combo_box) layout.addLayout(doc_lay) layout.addWidget(self.hook_list) if initial_hook is not None: self.set_hookname(initial_hook) def _change_settings_plugins(self): """Update settings if plugin call order changes.""" settings = get_settings() settings.plugins.call_order = self.plugin_manager.call_order() def set_hookname(self, hook: str): """Change the hook specification shown in the list widget. Parameters ---------- hook : str Name of the new hook specification to show. """ self.hook_combo_box.setCurrentText(hook.replace("napari_", '')) def _on_hook_change(self, index): hook_caller = self.hook_combo_box.currentData() self.hook_list.set_hook_caller(hook_caller) if hook_caller: doc = hook_caller.spec.function.__doc__ html = rst2html(doc.split("Parameters")[0].strip()) summary, fulldoc = html.split('
', 1) while fulldoc.startswith('
'): fulldoc = fulldoc[4:] self.docstring.setText(summary.strip()) self.docstring.show() self.info.show() self.info.setToolTip(fulldoc.strip()) else: self.docstring.hide() self.info.hide() self.docstring.setToolTip('') def refresh(self): self._on_hook_change(self.hook_combo_box.currentIndex()) def _on_disabled(self, event): for i in range(self.hook_list.count()): item = self.hook_list.item(i) if item and item.hook_implementation.plugin_name == event.value: self.hook_list.takeItem(i) def value(self): """Returns the call order from the plugin manager. Returns ------- call_order : CallOrderDict """ return napari_plugin_manager.call_order() napari-0.5.0a1/napari/_qt/widgets/qt_progress_bar.py000066400000000000000000000051731437041365600225000ustar00rootroot00000000000000from typing import Optional from qtpy import QtCore from qtpy.QtWidgets import ( QApplication, QFrame, QHBoxLayout, QLabel, QProgressBar, QVBoxLayout, QWidget, ) from napari.utils.progress import progress class QtLabeledProgressBar(QWidget): """QProgressBar with QLabels for description and ETA.""" def __init__( self, parent: Optional[QWidget] = None, prog: progress = None ) -> None: super().__init__(parent) self.setAttribute(QtCore.Qt.WidgetAttribute.WA_DeleteOnClose) self.progress = prog self.qt_progress_bar = QProgressBar() self.description_label = QLabel() self.eta_label = QLabel() base_layout = QVBoxLayout() pbar_layout = QHBoxLayout() pbar_layout.addWidget(self.description_label) pbar_layout.addWidget(self.qt_progress_bar) pbar_layout.addWidget(self.eta_label) base_layout.addLayout(pbar_layout) line = QFrame(self) line.setObjectName("QtCustomTitleBarLine") line.setFixedHeight(1) base_layout.addWidget(line) self.setLayout(base_layout) def setRange(self, min, max): self.qt_progress_bar.setRange(min, max) def setValue(self, value): self.qt_progress_bar.setValue(value) QApplication.processEvents() def setDescription(self, value): if not value.endswith(': '): value = f'{value}: ' self.description_label.setText(value) QApplication.processEvents() def _set_value(self, event): self.setValue(event.value) def _get_value(self): return self.qt_progress_bar.value() def _set_description(self, event): self.setDescription(event.value) def _make_indeterminate(self, event): self.setRange(0, 0) def _set_eta(self, event): self.eta_label.setText(event.value) def _set_total(self, event): self.setRange(0, event.value) class QtProgressBarGroup(QWidget): """One or more QtLabeledProgressBars with a QFrame line separator at the bottom""" def __init__( self, qt_progress_bar: QtLabeledProgressBar, parent: Optional[QWidget] = None, ) -> None: super().__init__(parent) self.setAttribute(QtCore.Qt.WidgetAttribute.WA_DeleteOnClose) pbr_group_layout = QVBoxLayout() pbr_group_layout.addWidget(qt_progress_bar) pbr_group_layout.setContentsMargins(0, 0, 0, 0) line = QFrame(self) line.setObjectName("QtCustomTitleBarLine") line.setFixedHeight(1) pbr_group_layout.addWidget(line) self.setLayout(pbr_group_layout) napari-0.5.0a1/napari/_qt/widgets/qt_range_slider_popup.py000066400000000000000000000032401437041365600236620ustar00rootroot00000000000000from qtpy.QtCore import Qt from qtpy.QtWidgets import QApplication, QHBoxLayout from superqt import QLabeledDoubleRangeSlider from napari._qt.dialogs.qt_modal import QtPopup class QRangeSliderPopup(QtPopup): """A popup window that contains a labeled range slider and buttons. Parameters ---------- parent : QWidget, optional Will like be an instance of QtLayerControls. Note, providing parent can be useful to inherit stylesheets. Attributes ---------- slider : QLabeledRangeSlider Slider widget. """ def __init__(self, parent=None) -> None: super().__init__(parent) # create slider self.slider = QLabeledDoubleRangeSlider( Qt.Orientation.Horizontal, parent ) self.slider.label_shift_x = 2 self.slider.label_shift_y = 2 self.slider.setFocus() # add widgets to layout self._layout = QHBoxLayout() self._layout.setContentsMargins(10, 0, 10, 16) self.frame.setLayout(self._layout) self._layout.addWidget(self.slider) QApplication.processEvents() self.slider._reposition_labels() def keyPressEvent(self, event): """On key press lose focus of the lineEdits. Parameters ---------- event : qtpy.QtCore.QKeyEvent Event from the Qt context. """ # we override the parent keyPressEvent so that hitting enter does not # hide the window... but we do want to lose focus on the lineEdits if event.key() in (Qt.Key.Key_Return, Qt.Key.Key_Enter): self.slider.setFocus() return super().keyPressEvent(event) napari-0.5.0a1/napari/_qt/widgets/qt_scrollbar.py000066400000000000000000000055411437041365600217720ustar00rootroot00000000000000from qtpy.QtCore import Qt from qtpy.QtWidgets import QScrollBar, QStyle, QStyleOptionSlider CC = QStyle.ComplexControl SC = QStyle.SubControl # https://stackoverflow.com/questions/29710327/how-to-override-qscrollbar-onclick-default-behaviour class ModifiedScrollBar(QScrollBar): """Modified QScrollBar that moves fully to the clicked position. When the user clicks on the scroll bar background area (aka, the "page control"), the default behavior of the QScrollBar is to move one "page" towards the click (rather than all the way to the clicked position). See: https://doc.qt.io/qt-5/qscrollbar.html This scroll bar modifies the mousePressEvent to move the slider position fully to the clicked position. """ def _move_to_mouse_position(self, event): opt = QStyleOptionSlider() self.initStyleOption(opt) # pos is for Qt5 e.position().toPoint() is for QT6 # https://doc-snapshots.qt.io/qt6-dev/qmouseevent-obsolete.html#pos point = ( event.position().toPoint() if hasattr(event, "position") else event.pos() ) control = self.style().hitTestComplexControl( CC.CC_ScrollBar, opt, point, self ) if control not in {SC.SC_ScrollBarAddPage, SC.SC_ScrollBarSubPage}: return # scroll here gr = self.style().subControlRect( CC.CC_ScrollBar, opt, SC.SC_ScrollBarGroove, self ) sr = self.style().subControlRect( CC.CC_ScrollBar, opt, SC.SC_ScrollBarSlider, self ) if self.orientation() == Qt.Orientation.Horizontal: pos = point.x() slider_length = sr.width() slider_min = gr.x() slider_max = gr.right() - slider_length + 1 if self.layoutDirection() == Qt.LayoutDirection.RightToLeft: opt.upsideDown = not opt.upsideDown else: pos = point.y() slider_length = sr.height() slider_min = gr.y() slider_max = gr.bottom() - slider_length + 1 self.setValue( QStyle.sliderValueFromPosition( self.minimum(), self.maximum(), pos - slider_min - slider_length // 2, slider_max - slider_min, opt.upsideDown, ) ) def mouseMoveEvent(self, event): if event.buttons() & Qt.MouseButton.LeftButton: # dragging with the mouse button down should move the slider self._move_to_mouse_position(event) return super().mouseMoveEvent(event) def mousePressEvent(self, event): if event.button() == Qt.MouseButton.LeftButton: # clicking the mouse button should move slider to the clicked point self._move_to_mouse_position(event) return super().mousePressEvent(event) napari-0.5.0a1/napari/_qt/widgets/qt_size_preview.py000066400000000000000000000234171437041365600225240ustar00rootroot00000000000000import typing from qtpy.QtCore import QSize, Qt, Signal from qtpy.QtGui import QFont, QIntValidator from qtpy.QtWidgets import ( QFrame, QHBoxLayout, QLabel, QLineEdit, QPlainTextEdit, QSlider, QVBoxLayout, QWidget, ) from napari.utils.translations import trans class QtFontSizePreview(QFrame): """ Widget that displays a preview text. Parameters ---------- parent : QWidget, optional Parent widget. text : str, optional Preview text to display. Default is None. """ def __init__(self, parent: QWidget = None, text: str = None) -> None: super().__init__(parent) self._text = text or "" # Widget self._preview = QPlainTextEdit(self) # Widget setup self._preview.setReadOnly(True) self._preview.setPlainText(self._text) # Layout layout = QHBoxLayout() layout.addWidget(self._preview) layout.setContentsMargins(0, 0, 0, 0) self.setLayout(layout) def sizeHint(self): """Override Qt method.""" return QSize(100, 80) def text(self) -> str: """Return the current preview text. Returns ------- str The current preview text. """ return self._text def setText(self, text: str): """Set the current preview text. Parameters ---------- text : str The current preview text. """ self._text = text self._preview.setPlainText(text) class QtSizeSliderPreviewWidget(QWidget): """ Widget displaying a description, textedit and slider to adjust font size with preview. Parameters ---------- parent : qtpy.QtWidgets.QWidget, optional Default is None. description : str, optional Default is "". preview_text : str, optional Default is "". value : int, optional Default is None. min_value : int, optional Default is 1. max_value : int, optional Default is 50. unit : str, optional Default is "px". """ valueChanged = Signal(int) def __init__( self, parent: QWidget = None, description: str = None, preview_text: str = None, value: int = None, min_value: int = 1, max_value: int = 50, unit: str = "px", ) -> None: super().__init__(parent) description = description or "" preview_text = preview_text or "" self._value = value if value else self.fontMetrics().height() self._min_value = min_value self._max_value = max_value # Widget self._lineedit = QLineEdit() self._description_label = QLabel(self) self._unit_label = QLabel(self) self._slider = QSlider(Qt.Orientation.Horizontal, self) self._slider_min_label = QLabel(self) self._slider_max_label = QLabel(self) self._preview = QtFontSizePreview(self) self._preview_label = QLabel(self) self._validator = None # Widgets setup self._description_label.setText(description) self._description_label.setWordWrap(True) self._unit_label.setText(unit) self._lineedit.setAlignment(Qt.AlignmentFlag.AlignRight) self._slider_min_label.setText(str(min_value)) self._slider_max_label.setText(str(max_value)) self._slider.setMinimum(min_value) self._slider.setMaximum(max_value) self._preview.setText(preview_text) self._preview_label.setText(trans._("preview")) self._preview_label.setAlignment(Qt.AlignmentFlag.AlignHCenter) self.setFocusProxy(self._lineedit) # Layout left_bottom_layout = QHBoxLayout() left_bottom_layout.addWidget(self._lineedit) left_bottom_layout.addWidget(self._unit_label) left_bottom_layout.addWidget(self._slider_min_label) left_bottom_layout.addWidget(self._slider) left_bottom_layout.addWidget(self._slider_max_label) left_layout = QVBoxLayout() left_layout.addWidget(self._description_label) left_layout.addLayout(left_bottom_layout) right_layout = QVBoxLayout() right_layout.addWidget(self._preview) right_layout.addWidget(self._preview_label) layout = QHBoxLayout() layout.addLayout(left_layout, 2) layout.addLayout(right_layout, 1) self.setLayout(layout) # Signals self._slider.valueChanged.connect(self._update_value) self._lineedit.textChanged.connect(self._update_value) self._update_line_width() self._update_validator() self._update_value(self._value) def _update_validator(self): self._validator = QIntValidator(self._min_value, self._max_value, self) self._lineedit.setValidator(self._validator) def _update_line_width(self): """Update width ofg line text edit.""" txt = "m" * (1 + len(str(self._max_value))) fm = self._lineedit.fontMetrics() if hasattr(fm, 'horizontalAdvance'): # Qt >= 5.11 size = fm.horizontalAdvance(txt) else: size = fm.width(txt) self._lineedit.setMaximumWidth(size) self._lineedit.setMinimumWidth(size) def _update_value(self, value: typing.Union[int, str]): """Update internal value and emit if changed.""" if value == "": value = int(self._value) value = int(value) if value > self._max_value: value = self._max_value elif value < self._min_value: value = self._min_value if value != self._value: self.valueChanged.emit(value) self._value = value self._refresh(self._value) def _refresh(self, value: int = None): """Refresh the value on all subwidgets.""" value = value or self._value self.blockSignals(True) self._lineedit.setText(str(value)) self._slider.setValue(value) font = QFont() font.setPixelSize(value) self._preview.setFont(font) font = QFont() font.setPixelSize(self.fontMetrics().height() - 4) self._preview_label.setFont(font) self.blockSignals(False) def description(self) -> str: """Return the current widget description. Returns ------- str The description text. """ return self._description_label.text() def setDescription(self, text: str): """Set the current widget description. Parameters ---------- text : str The description text. """ self._description_label.setText(text) def previewText(self) -> str: """Return the current preview text. Returns ------- str The current preview text. """ return self._preview.text() def setPreviewText(self, text: str): """Set the current preview text. Parameters ---------- text : str The current preview text. """ self._preview.setText(text) def unit(self) -> str: """Return the current unit text. Returns ------- str The current unit text. """ return self._unit_label.text() def setUnit(self, text: str): """Set the current unit text. Parameters ---------- text : str The current preview text. """ self._unit_label.setText(text) def minimum(self) -> int: """Return the current minimum value for the slider and value in textbox. Returns ------- int The minimum value for the slider. """ return self._min_value def setMinimum(self, value: int): """Set the current minimum value for the slider and value in textbox. Parameters ---------- value : int The minimum value for the slider. """ if value >= self._max_value: raise ValueError( trans._( "Minimum value must be smaller than {max_value}", max_value=self._max_value, ) ) self._min_value = value self._value = max(self._value, self._min_value) self._slider_min_label.setText(str(value)) self._slider.setMinimum(value) self._update_validator() self._refresh() def maximum(self) -> int: """Return the maximum value for the slider and value in textbox. Returns ------- int The maximum value for the slider. """ return self._max_value def setMaximum(self, value: int): """Set the maximum value for the slider and value in textbox. Parameters ---------- value : int The maximum value for the slider. """ if value <= self._min_value: raise ValueError( trans._( "Maximum value must be larger than {min_value}", min_value=self._min_value, ) ) self._max_value = value self._value = min(self._value, self._max_value) self._slider_max_label.setText(str(value)) self._slider.setMaximum(value) self._update_validator() self._update_line_width() self._refresh() def value(self) -> int: """Return the current widget value. Returns ------- int The current value. """ return self._value def setValue(self, value: int): """Set the current widget value. Parameters ---------- value : int The current value. """ self._update_value(value) napari-0.5.0a1/napari/_qt/widgets/qt_spinbox.py000066400000000000000000000014421437041365600214650ustar00rootroot00000000000000from qtpy.QtGui import QValidator from qtpy.QtWidgets import QSpinBox class QtSpinBox(QSpinBox): """Extends QSpinBox validate and stepBy methods in order to skip values in spin box.""" prohibit = None def setProhibitValue(self, value: int): """Set value that should not be used in QSpinBox. Parameters ---------- value : int Value to be excluded from QSpinBox. """ self.prohibit = value def validate(self, value: str, pos: int): if value == str(self.prohibit): return QValidator.Invalid, value, pos return super().validate(value, pos) def stepBy(self, steps: int) -> None: if self.value() + steps == self.prohibit: steps *= 2 return super().stepBy(steps) napari-0.5.0a1/napari/_qt/widgets/qt_splash_screen.py000066400000000000000000000010031437041365600226250ustar00rootroot00000000000000from qtpy.QtCore import Qt from qtpy.QtGui import QPixmap from qtpy.QtWidgets import QSplashScreen from napari._qt.qt_event_loop import NAPARI_ICON_PATH, get_app class NapariSplashScreen(QSplashScreen): def __init__(self, width=360) -> None: get_app() pm = QPixmap(NAPARI_ICON_PATH).scaled( width, width, Qt.AspectRatioMode.KeepAspectRatio, Qt.TransformationMode.SmoothTransformation, ) super().__init__(pm) self.show() napari-0.5.0a1/napari/_qt/widgets/qt_theme_sample.py000066400000000000000000000122421437041365600224460ustar00rootroot00000000000000"""SampleWidget that contains many types of QWidgets. This file and SampleWidget is useful for testing out themes from the command line or for generating screenshots of a sample widget to demonstrate a theme. Examples -------- To use from the command line: $ python -m napari._qt.theme_sample To generate a screenshot within python: >>> from napari._qt.widgets.qt_theme_sample import SampleWidget >>> widg = SampleWidget(theme='dark') >>> screenshot = widg.screenshot() """ from qtpy.QtCore import Qt from qtpy.QtWidgets import ( QCheckBox, QComboBox, QDoubleSpinBox, QFontComboBox, QFormLayout, QGroupBox, QHBoxLayout, QLabel, QLineEdit, QProgressBar, QPushButton, QRadioButton, QScrollBar, QSlider, QSpinBox, QTabWidget, QTextEdit, QTimeEdit, QVBoxLayout, QWidget, ) from superqt import QRangeSlider from napari._qt.qt_resources import get_stylesheet from napari._qt.utils import QImg2array from napari.utils.io import imsave blurb = """

Heading

Lorem ipsum dolor sit amet, consectetur adipiscing elit, sed do eiusmod tempor incididunt ut labore et dolore magna aliqua. Ut enim ad minim veniam, quis nostrud exercitation ullamco laboris nisi ut aliquip ex ea commodo consequat. Duis aute irure dolor in reprehenderit in voluptate velit esse cillum dolore eu fugiat nulla pariatur. Excepteur sint occaecat cupidatat non proident, sunt in culpa qui officia deserunt mollit anim id est laborum.

""" class TabDemo(QTabWidget): def __init__(self, parent=None, emphasized=False) -> None: super().__init__(parent) self.setProperty('emphasized', emphasized) self.tab1 = QWidget() self.tab1.setProperty('emphasized', emphasized) self.tab2 = QWidget() self.tab2.setProperty('emphasized', emphasized) self.addTab(self.tab1, "Tab 1") self.addTab(self.tab2, "Tab 2") layout = QFormLayout() layout.addRow("Height", QSpinBox()) layout.addRow("Weight", QDoubleSpinBox()) self.setTabText(0, "Tab 1") self.tab1.setLayout(layout) layout2 = QFormLayout() sex = QHBoxLayout() sex.addWidget(QRadioButton("Male")) sex.addWidget(QRadioButton("Female")) layout2.addRow(QLabel("Sex"), sex) layout2.addRow("Date of Birth", QLineEdit()) self.setTabText(1, "Tab 2") self.tab2.setLayout(layout2) self.setWindowTitle("tab demo") class SampleWidget(QWidget): def __init__(self, theme='dark', emphasized=False) -> None: super().__init__(None) self.setProperty('emphasized', emphasized) self.setStyleSheet(get_stylesheet(theme)) lay = QVBoxLayout() self.setLayout(lay) lay.addWidget(QPushButton('push button')) box = QComboBox() box.addItems(['a', 'b', 'c', 'cd']) lay.addWidget(box) lay.addWidget(QFontComboBox()) hbox = QHBoxLayout() chk = QCheckBox('tristate') chk.setToolTip('I am a tooltip') chk.setTristate(True) chk.setCheckState(Qt.CheckState.PartiallyChecked) chk3 = QCheckBox('checked') chk3.setChecked(True) hbox.addWidget(QCheckBox('unchecked')) hbox.addWidget(chk) hbox.addWidget(chk3) lay.addLayout(hbox) lay.addWidget(TabDemo(emphasized=emphasized)) sld = QSlider(Qt.Orientation.Horizontal) sld.setValue(50) lay.addWidget(sld) scroll = QScrollBar(Qt.Orientation.Horizontal) scroll.setValue(50) lay.addWidget(scroll) lay.addWidget(QRangeSlider(Qt.Orientation.Horizontal, self)) text = QTextEdit() text.setMaximumHeight(100) text.setHtml(blurb) lay.addWidget(text) lay.addWidget(QTimeEdit()) edit = QLineEdit() edit.setPlaceholderText('LineEdit placeholder...') lay.addWidget(edit) lay.addWidget(QLabel('label')) prog = QProgressBar() prog.setValue(50) lay.addWidget(prog) group_box = QGroupBox("Exclusive Radio Buttons") radio1 = QRadioButton("&Radio button 1") radio2 = QRadioButton("R&adio button 2") radio3 = QRadioButton("Ra&dio button 3") radio1.setChecked(True) hbox = QHBoxLayout() hbox.addWidget(radio1) hbox.addWidget(radio2) hbox.addWidget(radio3) hbox.addStretch(1) group_box.setLayout(hbox) lay.addWidget(group_box) def screenshot(self, path=None): img = self.grab().toImage() if path is not None: imsave(path, QImg2array(img)) return QImg2array(img) if __name__ == "__main__": import sys from napari._qt.qt_event_loop import get_app from napari.utils.theme import available_themes themes = [sys.argv[1]] if len(sys.argv) > 1 else available_themes() app = get_app() widgets = [] for n, theme in enumerate(themes): try: w = SampleWidget(theme) except KeyError: print(f"{theme} is not a recognized theme") continue w.setGeometry(10 + 430 * n, 0, 425, 600) w.show() widgets.append(w) if widgets: app.exec_() napari-0.5.0a1/napari/_qt/widgets/qt_tooltip.py000066400000000000000000000007001437041365600214710ustar00rootroot00000000000000from __future__ import annotations from qtpy.QtWidgets import QLabel, QToolTip class QtToolTipLabel(QLabel): """A QLabel that provides instant tooltips on mouser hover.""" def enterEvent(self, event): """Override to show tooltips instantly.""" if self.toolTip(): pos = self.mapToGlobal(self.contentsRect().center()) QToolTip.showText(pos, self.toolTip(), self) super().enterEvent(event) napari-0.5.0a1/napari/_qt/widgets/qt_viewer_buttons.py000066400000000000000000000365761437041365600231020ustar00rootroot00000000000000import warnings from functools import wraps from typing import TYPE_CHECKING from qtpy.QtCore import QPoint, Qt from qtpy.QtWidgets import ( QFormLayout, QFrame, QHBoxLayout, QLabel, QPushButton, QSlider, QVBoxLayout, ) from napari._qt.dialogs.qt_modal import QtPopup from napari._qt.widgets.qt_dims_sorter import QtDimsSorter from napari._qt.widgets.qt_spinbox import QtSpinBox from napari._qt.widgets.qt_tooltip import QtToolTipLabel from napari.utils.action_manager import action_manager from napari.utils.interactions import Shortcut from napari.utils.misc import in_ipython, in_jupyter, in_python_repl from napari.utils.translations import trans if TYPE_CHECKING: from napari.viewer import ViewerModel class QtLayerButtons(QFrame): """Button controls for napari layers. Parameters ---------- viewer : napari.components.ViewerModel Napari viewer containing the rendered scene, layers, and controls. Attributes ---------- deleteButton : QtDeleteButton Button to delete selected layers. newLabelsButton : QtViewerPushButton Button to add new Label layer. newPointsButton : QtViewerPushButton Button to add new Points layer. newShapesButton : QtViewerPushButton Button to add new Shapes layer. viewer : napari.components.ViewerModel Napari viewer containing the rendered scene, layers, and controls. """ def __init__(self, viewer: 'ViewerModel') -> None: super().__init__() self.viewer = viewer self.deleteButton = QtDeleteButton(self.viewer) self.newPointsButton = QtViewerPushButton( 'new_points', trans._('New points layer'), lambda: self.viewer.add_points( ndim=max(self.viewer.dims.ndim, 2), scale=self.viewer.layers.extent.step, ), ) self.newShapesButton = QtViewerPushButton( 'new_shapes', trans._('New shapes layer'), lambda: self.viewer.add_shapes( ndim=max(self.viewer.dims.ndim, 2), scale=self.viewer.layers.extent.step, ), ) self.newLabelsButton = QtViewerPushButton( 'new_labels', trans._('New labels layer'), lambda: self.viewer._new_labels(), ) layout = QHBoxLayout() layout.setContentsMargins(0, 0, 0, 0) layout.addWidget(self.newPointsButton) layout.addWidget(self.newShapesButton) layout.addWidget(self.newLabelsButton) layout.addStretch(0) layout.addWidget(self.deleteButton) self.setLayout(layout) class QtViewerButtons(QFrame): """Button controls for the napari viewer. Parameters ---------- viewer : napari.components.ViewerModel Napari viewer containing the rendered scene, layers, and controls. Attributes ---------- consoleButton : QtViewerPushButton Button to open iPython console within napari. rollDimsButton : QtViewerPushButton Button to roll orientation of spatial dimensions in the napari viewer. transposeDimsButton : QtViewerPushButton Button to transpose dimensions in the napari viewer. resetViewButton : QtViewerPushButton Button resetting the view of the rendered scene. gridViewButton : QtViewerPushButton Button to toggle grid view mode of layers on and off. ndisplayButton : QtViewerPushButton Button to toggle number of displayed dimensions. viewer : napari.components.ViewerModel Napari viewer containing the rendered scene, layers, and controls. """ def __init__(self, viewer: 'ViewerModel') -> None: super().__init__() self.viewer = viewer self.consoleButton = QtViewerPushButton( 'console', action='napari:toggle_console_visibility' ) self.consoleButton.setProperty('expanded', False) if in_ipython() or in_jupyter() or in_python_repl(): self.consoleButton.setEnabled(False) rdb = QtViewerPushButton('roll', action='napari:roll_axes') self.rollDimsButton = rdb rdb.setContextMenuPolicy(Qt.ContextMenuPolicy.CustomContextMenu) rdb.customContextMenuRequested.connect(self._open_roll_popup) self.transposeDimsButton = QtViewerPushButton( 'transpose', action='napari:transpose_axes' ) self.resetViewButton = QtViewerPushButton( 'home', action='napari:reset_view' ) gvb = QtViewerPushButton( 'grid_view_button', action='napari:toggle_grid' ) self.gridViewButton = gvb gvb.setCheckable(True) gvb.setChecked(viewer.grid.enabled) gvb.setContextMenuPolicy(Qt.ContextMenuPolicy.CustomContextMenu) gvb.customContextMenuRequested.connect(self._open_grid_popup) @self.viewer.grid.events.enabled.connect def _set_grid_mode_checkstate(event): gvb.setChecked(event.value) ndb = QtViewerPushButton( 'ndisplay_button', action='napari:toggle_ndisplay' ) self.ndisplayButton = ndb ndb.setCheckable(True) ndb.setChecked(self.viewer.dims.ndisplay == 3) ndb.setContextMenuPolicy(Qt.ContextMenuPolicy.CustomContextMenu) ndb.customContextMenuRequested.connect(self.open_perspective_popup) @self.viewer.dims.events.ndisplay.connect def _set_ndisplay_mode_checkstate(event): ndb.setChecked(event.value == 3) layout = QHBoxLayout() layout.setContentsMargins(0, 0, 0, 0) layout.addWidget(self.consoleButton) layout.addWidget(self.ndisplayButton) layout.addWidget(self.rollDimsButton) layout.addWidget(self.transposeDimsButton) layout.addWidget(self.gridViewButton) layout.addWidget(self.resetViewButton) layout.addStretch(0) self.setLayout(layout) def open_perspective_popup(self): """Show a slider to control the viewer `camera.perspective`.""" if self.viewer.dims.ndisplay != 3: return # make slider connected to perspective parameter sld = QSlider(Qt.Orientation.Horizontal, self) sld.setRange(0, max(90, int(self.viewer.camera.perspective))) sld.setValue(self.viewer.camera.perspective) sld.valueChanged.connect( lambda v: setattr(self.viewer.camera, 'perspective', v) ) # make layout layout = QHBoxLayout() layout.addWidget(QLabel(trans._('Perspective'), self)) layout.addWidget(sld) # popup and show pop = QtPopup(self) pop.frame.setLayout(layout) pop.show_above_mouse() def _open_roll_popup(self): """Open a grid popup to manually order the dimensions""" if self.viewer.dims.ndisplay != 2: return dim_sorter = QtDimsSorter(self.viewer, self) dim_sorter.setObjectName('dim_sorter') # make layout layout = QHBoxLayout() layout.addWidget(dim_sorter) # popup and show pop = QtPopup(self) pop.frame.setLayout(layout) pop.show_above_mouse() def _open_grid_popup(self): """Open grid options pop up widget.""" # widgets popup = QtPopup(self) grid_stride = QtSpinBox(popup) grid_width = QtSpinBox(popup) grid_height = QtSpinBox(popup) shape_help_symbol = QtToolTipLabel(self) stride_help_symbol = QtToolTipLabel(self) blank = QLabel(self) # helps with placing help symbols. shape_help_msg = trans._( 'Number of rows and columns in the grid. A value of -1 for either or both of width and height will trigger an auto calculation of the necessary grid shape to appropriately fill all the layers at the appropriate stride. 0 is not a valid entry.' ) stride_help_msg = trans._( 'Number of layers to place in each grid square before moving on to the next square. The default ordering is to place the most visible layer in the top left corner of the grid. A negative stride will cause the order in which the layers are placed in the grid to be reversed. 0 is not a valid entry.' ) # set up stride_min = self.viewer.grid.__fields__['stride'].type_.ge stride_max = self.viewer.grid.__fields__['stride'].type_.le stride_not = self.viewer.grid.__fields__['stride'].type_.ne grid_stride.setObjectName("gridStrideBox") grid_stride.setAlignment(Qt.AlignmentFlag.AlignCenter) grid_stride.setRange(stride_min, stride_max) grid_stride.setProhibitValue(stride_not) grid_stride.setValue(self.viewer.grid.stride) grid_stride.valueChanged.connect(self._update_grid_stride) self.grid_stride_box = grid_stride width_min = self.viewer.grid.__fields__['shape'].sub_fields[1].type_.ge width_not = self.viewer.grid.__fields__['shape'].sub_fields[1].type_.ne grid_width.setObjectName("gridWidthBox") grid_width.setAlignment(Qt.AlignmentFlag.AlignCenter) grid_width.setMinimum(width_min) grid_width.setProhibitValue(width_not) grid_width.setValue(self.viewer.grid.shape[1]) grid_width.valueChanged.connect(self._update_grid_width) self.grid_width_box = grid_width height_min = ( self.viewer.grid.__fields__['shape'].sub_fields[0].type_.ge ) height_not = ( self.viewer.grid.__fields__['shape'].sub_fields[0].type_.ne ) grid_height.setObjectName("gridStrideBox") grid_height.setAlignment(Qt.AlignmentFlag.AlignCenter) grid_height.setMinimum(height_min) grid_height.setProhibitValue(height_not) grid_height.setValue(self.viewer.grid.shape[0]) grid_height.valueChanged.connect(self._update_grid_height) self.grid_height_box = grid_height shape_help_symbol.setObjectName("help_label") shape_help_symbol.setToolTip(shape_help_msg) stride_help_symbol.setObjectName("help_label") stride_help_symbol.setToolTip(stride_help_msg) # layout form_layout = QFormLayout() form_layout.insertRow(0, QLabel(trans._('Grid stride:')), grid_stride) form_layout.insertRow(1, QLabel(trans._('Grid width:')), grid_width) form_layout.insertRow(2, QLabel(trans._('Grid height:')), grid_height) help_layout = QVBoxLayout() help_layout.addWidget(stride_help_symbol) help_layout.addWidget(blank) help_layout.addWidget(shape_help_symbol) layout = QHBoxLayout() layout.addLayout(form_layout) layout.addLayout(help_layout) popup.frame.setLayout(layout) popup.show_above_mouse() # adjust placement of shape help symbol. Must be done last # in order for this movement to happen. delta_x = 0 delta_y = -15 shape_pos = ( shape_help_symbol.x() + delta_x, shape_help_symbol.y() + delta_y, ) shape_help_symbol.move(QPoint(*shape_pos)) def _update_grid_width(self, value): """Update the width value in grid shape. Parameters ---------- value : int New grid width value. """ self.viewer.grid.shape = (self.viewer.grid.shape[0], value) def _update_grid_stride(self, value): """Update stride in grid settings. Parameters ---------- value : int New grid stride value. """ self.viewer.grid.stride = value def _update_grid_height(self, value): """Update height value in grid shape. Parameters ---------- value : int New grid height value. """ self.viewer.grid.shape = (value, self.viewer.grid.shape[1]) class QtDeleteButton(QPushButton): """Delete button to remove selected layers. Parameters ---------- viewer : napari.components.ViewerModel Napari viewer containing the rendered scene, layers, and controls. Attributes ---------- hover : bool Hover is true while mouse cursor is on the button widget. viewer : napari.components.ViewerModel Napari viewer containing the rendered scene, layers, and controls. """ def __init__(self, viewer) -> None: super().__init__() self.viewer = viewer self.setToolTip( trans._( "Delete selected layers ({shortcut})", shortcut=Shortcut("Control-Backspace"), ) ) self.setAcceptDrops(True) self.clicked.connect(lambda: self.viewer.layers.remove_selected()) def dragEnterEvent(self, event): """The cursor enters the widget during a drag and drop operation. Parameters ---------- event : qtpy.QtCore.QEvent Event from the Qt context. """ event.accept() self.hover = True self.update() def dragLeaveEvent(self, event): """The cursor leaves the widget during a drag and drop operation. Using event.ignore() here allows the event to pass through the parent widget to its child widget, otherwise the parent widget would catch the event and not pass it on to the child widget. Parameters ---------- event : qtpy.QtCore.QEvent Event from the Qt context. """ event.ignore() self.hover = False self.update() def dropEvent(self, event): """The drag and drop mouse event is completed. Parameters ---------- event : qtpy.QtCore.QDropEvent Event from the Qt context. """ event.accept() layer_name = event.mimeData().text() layer = self.viewer.layers[layer_name] if not layer.selected: self.viewer.layers.remove(layer) else: self.viewer.layers.remove_selected() def _omit_viewer_args(constructor): @wraps(constructor) def _func(*args, **kwargs): if len(args) > 1 and not isinstance(args[1], str): warnings.warn( trans._( "viewer argument is deprecated since 0.4.14 and should not be used" ), category=FutureWarning, stacklevel=2, ) args = args[:1] + args[2:] if "viewer" in kwargs: warnings.warn( trans._( "viewer argument is deprecated since 0.4.14 and should not be used" ), category=FutureWarning, stacklevel=2, ) del kwargs["viewer"] return constructor(*args, **kwargs) return _func class QtViewerPushButton(QPushButton): """Push button. Parameters ---------- button_name : str Name of button. tooltip : str Tooltip for button. If empty then `button_name` is used slot : Callable, optional callable to be triggered on button click action : str action name to be triggered on button click """ @_omit_viewer_args def __init__( self, button_name: str, tooltip: str = '', slot=None, action: str = '' ) -> None: super().__init__() self.setToolTip(tooltip or button_name) self.setProperty('mode', button_name) if slot is not None: self.clicked.connect(slot) if action: action_manager.bind_button(action, self) napari-0.5.0a1/napari/_qt/widgets/qt_viewer_dock_widget.py000066400000000000000000000324651437041365600236600ustar00rootroot00000000000000import contextlib import warnings from functools import reduce from itertools import count from operator import ior from typing import TYPE_CHECKING, List, Optional, Union from weakref import ReferenceType, ref from qtpy.QtCore import Qt from qtpy.QtWidgets import ( QDockWidget, QFrame, QHBoxLayout, QLabel, QPushButton, QSizePolicy, QVBoxLayout, QWidget, ) from napari._qt.utils import combine_widgets, qt_signals_blocked from napari.utils.translations import trans if TYPE_CHECKING: from magicgui.widgets import Widget from napari._qt.qt_viewer import QtViewer counter = count() _sentinel = object() _SHORTCUT_DEPRECATION_STRING = trans._( 'The shortcut parameter is deprecated since version 0.4.8, please use the action and shortcut manager APIs. The new action manager and shortcut API allow user configuration and localisation. (got {shortcut})', shortcut="{shortcut}", ) class QtViewerDockWidget(QDockWidget): """Wrap a QWidget in a QDockWidget and forward viewer events Parameters ---------- qt_viewer : QtViewer The QtViewer instance that this dock widget will belong to. widget : QWidget or magicgui.widgets.Widget `widget` that will be added as QDockWidget's main widget. name : str Name of dock widget. area : str Side of the main window to which the new dock widget will be added. Must be in {'left', 'right', 'top', 'bottom'} allowed_areas : list[str], optional Areas, relative to main window, that the widget is allowed dock. Each item in list must be in {'left', 'right', 'top', 'bottom'} By default, all areas are allowed. shortcut : str, optional Keyboard shortcut to appear in dropdown menu. .. deprecated:: 0.4.8 The shortcut parameter is deprecated since version 0.4.8, please use the action and shortcut manager APIs. The new action manager and shortcut API allow user configuration and localisation. add_vertical_stretch : bool, optional Whether to add stretch to the bottom of vertical widgets (pushing widgets up towards the top of the allotted area, instead of letting them distribute across the vertical space). By default, True. """ def __init__( self, qt_viewer, widget: Union[QWidget, 'Widget'], *, name: str = '', area: str = 'right', allowed_areas: Optional[List[str]] = None, shortcut=_sentinel, object_name: str = '', add_vertical_stretch=True, close_btn=True, ) -> None: self._ref_qt_viewer: 'ReferenceType[QtViewer]' = ref(qt_viewer) super().__init__(name) self._parent = qt_viewer self.name = name self._close_btn = close_btn areas = { 'left': Qt.DockWidgetArea.LeftDockWidgetArea, 'right': Qt.DockWidgetArea.RightDockWidgetArea, 'top': Qt.DockWidgetArea.TopDockWidgetArea, 'bottom': Qt.DockWidgetArea.BottomDockWidgetArea, } if area not in areas: raise ValueError( trans._( 'area argument must be in {areas}', deferred=True, areas=list(areas.keys()), ) ) self.area = area self.qt_area = areas[area] if shortcut is not _sentinel: warnings.warn( _SHORTCUT_DEPRECATION_STRING.format(shortcut=shortcut), FutureWarning, stacklevel=2, ) else: shortcut = None self._shortcut = shortcut if allowed_areas: if not isinstance(allowed_areas, (list, tuple)): raise TypeError( trans._( '`allowed_areas` must be a list or tuple', deferred=True, ) ) if any(area not in areas for area in allowed_areas): raise ValueError( trans._( 'all allowed_areas argument must be in {areas}', deferred=True, areas=list(areas.keys()), ) ) allowed_areas = reduce(ior, [areas[a] for a in allowed_areas]) else: allowed_areas = Qt.DockWidgetArea.AllDockWidgetAreas self.setAllowedAreas(allowed_areas) self.setMinimumHeight(50) self.setMinimumWidth(50) # FIXME: self.setObjectName(object_name or name) is_vertical = area in {'left', 'right'} widget = combine_widgets(widget, vertical=is_vertical) self.setWidget(widget) if is_vertical and add_vertical_stretch: self._maybe_add_vertical_stretch(widget) self._features = self.features() self.dockLocationChanged.connect(self._set_title_orientation) # custom title bar self.title = QtCustomTitleBar( self, title=self.name, close_btn=close_btn ) self.setTitleBarWidget(self.title) self.visibilityChanged.connect(self._on_visibility_changed) @property def _parent(self): """ Let's make sure parent always a weakref: 1) parent is likely to always exists after child 2) even if not strictly necessary it make it easier to view reference cycles. """ return self._ref_parent() @_parent.setter def _parent(self, obj): self._ref_parent = ref(obj) def destroyOnClose(self): """Destroys dock plugin dock widget when 'x' is clicked.""" from napari.viewer import Viewer viewer = self._ref_qt_viewer().viewer if isinstance(viewer, Viewer): viewer.window.remove_dock_widget(self) def _maybe_add_vertical_stretch(self, widget): """Add vertical stretch to the bottom of a vertical layout only ...if there is not already a widget that wants vertical space (like a textedit or listwidget or something). """ exempt_policies = { QSizePolicy.Expanding, QSizePolicy.MinimumExpanding, QSizePolicy.Ignored, } if widget.sizePolicy().verticalPolicy() in exempt_policies: return # not uncommon to see people shadow the builtin layout() method # which breaks our ability to add vertical stretch... try: wlayout = widget.layout() if wlayout is None: return except TypeError: return for i in range(wlayout.count()): wdg = wlayout.itemAt(i).widget() if ( wdg is not None and wdg.sizePolicy().verticalPolicy() in exempt_policies ): return # not all widgets have addStretch... if hasattr(wlayout, 'addStretch'): wlayout.addStretch(next(counter)) @property def shortcut(self): warnings.warn( _SHORTCUT_DEPRECATION_STRING, FutureWarning, stacklevel=2, ) return self._shortcut def setFeatures(self, features): super().setFeatures(features) self._features = self.features() def keyPressEvent(self, event): # if you subclass QtViewerDockWidget and override the keyPressEvent # method, be sure to call super().keyPressEvent(event) at the end of # your method to pass uncaught key-combinations to the viewer. return self._ref_qt_viewer().keyPressEvent(event) def _set_title_orientation(self, area): if area in ( Qt.DockWidgetArea.LeftDockWidgetArea, Qt.DockWidgetArea.RightDockWidgetArea, ): features = self._features if features & self.DockWidgetFeature.DockWidgetVerticalTitleBar: features = ( features ^ self.DockWidgetFeature.DockWidgetVerticalTitleBar ) else: features = ( self._features | self.DockWidgetFeature.DockWidgetVerticalTitleBar ) self.setFeatures(features) @property def is_vertical(self): if not self.isFloating(): par = self.parent() if par and hasattr(par, 'dockWidgetArea'): return par.dockWidgetArea(self) in ( Qt.DockWidgetArea.LeftDockWidgetArea, Qt.DockWidgetArea.RightDockWidgetArea, ) return self.size().height() > self.size().width() def _on_visibility_changed(self, visible): from napari.viewer import Viewer with contextlib.suppress(AttributeError, ValueError): viewer = self._ref_qt_viewer().viewer if isinstance(viewer, Viewer): actions = [ action.text() for action in viewer.window.plugins_menu.actions() ] idx = actions.index(self.name) viewer.window.plugins_menu.actions()[idx].setChecked(visible) self.setVisible(visible) # AttributeError: This error happens when the plugins menu is not yet built. # ValueError: This error is when the action is from the windows menu. if not visible: return with qt_signals_blocked(self): self.setTitleBarWidget(None) if not self.isFloating(): self.title = QtCustomTitleBar( self, title=self.name, vertical=not self.is_vertical, close_btn=self._close_btn, ) self.setTitleBarWidget(self.title) def setWidget(self, widget): widget._parent = self self.setFocusProxy(widget) super().setWidget(widget) class QtCustomTitleBar(QLabel): """A widget to be used as the titleBar in the QtViewerDockWidget. Keeps vertical size minimal, has a hand cursor and styles (in stylesheet) for hover. Close and float buttons. Parameters ---------- parent : QDockWidget The QtViewerDockWidget to which this titlebar belongs title : str A string to put in the titlebar. vertical : bool Whether this titlebar is oriented vertically or not. """ def __init__( self, parent, title: str = '', vertical=False, close_btn=True ) -> None: super().__init__(parent) self.setObjectName("QtCustomTitleBar") self.setProperty('vertical', str(vertical)) self.vertical = vertical self.setToolTip(trans._('drag to move. double-click to float')) line = QFrame(self) line.setObjectName("QtCustomTitleBarLine") self.hide_button = QPushButton(self) self.hide_button.setToolTip(trans._('hide this panel')) self.hide_button.setObjectName("QTitleBarHideButton") self.hide_button.setCursor(Qt.CursorShape.ArrowCursor) self.hide_button.clicked.connect(lambda: self.parent().close()) self.float_button = QPushButton(self) self.float_button.setToolTip(trans._('float this panel')) self.float_button.setObjectName("QTitleBarFloatButton") self.float_button.setCursor(Qt.CursorShape.ArrowCursor) self.float_button.clicked.connect( lambda: self.parent().setFloating(not self.parent().isFloating()) ) self.title: QLabel = QLabel(title, self) self.title.setSizePolicy( QSizePolicy(QSizePolicy.Policy.Maximum, QSizePolicy.Policy.Maximum) ) if close_btn: self.close_button = QPushButton(self) self.close_button.setToolTip(trans._('close this panel')) self.close_button.setObjectName("QTitleBarCloseButton") self.close_button.setCursor(Qt.CursorShape.ArrowCursor) self.close_button.clicked.connect( lambda: self.parent().destroyOnClose() ) if vertical: layout = QVBoxLayout() layout.setSpacing(4) layout.setContentsMargins(0, 8, 0, 8) line.setFixedWidth(1) if close_btn: layout.addWidget( self.close_button, 0, Qt.AlignmentFlag.AlignHCenter ) layout.addWidget( self.hide_button, 0, Qt.AlignmentFlag.AlignHCenter ) layout.addWidget( self.float_button, 0, Qt.AlignmentFlag.AlignHCenter ) layout.addWidget(line, 0, Qt.AlignmentFlag.AlignHCenter) self.title.hide() else: layout = QHBoxLayout() layout.setSpacing(4) layout.setContentsMargins(8, 1, 8, 0) line.setFixedHeight(1) if close_btn: layout.addWidget(self.close_button) layout.addWidget(self.hide_button) layout.addWidget(self.float_button) layout.addWidget(line) layout.addWidget(self.title) self.setLayout(layout) self.setCursor(Qt.CursorShape.OpenHandCursor) def sizeHint(self): # this seems to be the correct way to set the height of the titlebar szh = super().sizeHint() if self.vertical: szh.setWidth(20) else: szh.setHeight(20) return szh napari-0.5.0a1/napari/_qt/widgets/qt_viewer_status_bar.py000066400000000000000000000171221437041365600235350ustar00rootroot00000000000000"""Status bar widget on the viewer MainWindow""" from typing import TYPE_CHECKING, Optional, cast from qtpy.QtCore import QEvent, Qt from qtpy.QtGui import QFontMetrics, QResizeEvent from qtpy.QtWidgets import QLabel, QStatusBar, QWidget from superqt import QElidingLabel from napari._qt.dialogs.qt_activity_dialog import ActivityToggleItem from napari.utils.translations import trans if TYPE_CHECKING: from napari._qt.qt_main_window import _QtMainWindow class ViewerStatusBar(QStatusBar): def __init__(self, parent: '_QtMainWindow') -> None: super().__init__(parent=parent) self._status = QLabel(trans._('Ready')) self._status.setContentsMargins(0, 0, 0, 0) self._layer_base = QElidingLabel(trans._('')) self._layer_base.setObjectName('layer_base status') self._layer_base.setElideMode(Qt.TextElideMode.ElideMiddle) self._layer_base.setMinimumSize(100, 16) self._layer_base.setContentsMargins(0, 0, 0, 0) self._plugin_reader = QElidingLabel(trans._('')) self._plugin_reader.setObjectName('plugin-reader status') self._plugin_reader.setMinimumSize(80, 16) self._plugin_reader.setContentsMargins(0, 0, 0, 0) self._plugin_reader.setElideMode(Qt.TextElideMode.ElideMiddle) self._source_type = QLabel('') self._source_type.setObjectName('source-type status') self._source_type.setContentsMargins(0, 0, 0, 0) self._coordinates = QElidingLabel('') self._coordinates.setObjectName('coordinates status') self._coordinates.setMinimumSize(100, 16) self._coordinates.setContentsMargins(0, 0, 0, 0) self._help = QElidingLabel('') self._help.setObjectName('help status') self._help.setAlignment( Qt.AlignmentFlag.AlignRight | Qt.AlignmentFlag.AlignVCenter ) main_widget = StatusBarWidget( self._status, self._layer_base, self._source_type, self._plugin_reader, self._coordinates, self._help, ) self.addWidget(main_widget, 1) self._activity_item = ActivityToggleItem() self._activity_item._activityBtn.clicked.connect( self._toggle_activity_dock ) # FIXME: feels weird to set this here. parent._activity_dialog._toggleButton = self._activity_item self.addPermanentWidget(self._activity_item) def setHelpText(self, text: str) -> None: self._help.setText(text) def setStatusText( self, text: str = "", layer_base: str = "", source_type=None, plugin: str = "", coordinates: str = "", ) -> None: # The method used to set a single value as the status and not # all the layer information. self._status.setText(text) self._layer_base.setVisible(bool(layer_base)) self._layer_base.setText(layer_base) self._source_type.setVisible(bool(source_type)) if source_type: self._source_type.setText(f'{source_type}: ') self._plugin_reader.setVisible(bool(plugin)) self._plugin_reader.setText(plugin) self._coordinates.setVisible(bool(coordinates)) self._coordinates.setText(coordinates) def _toggle_activity_dock(self, visible: Optional[bool] = None): par = cast('_QtMainWindow', self.parent()) if visible is None: visible = not par._activity_dialog.isVisible() if visible: par._activity_dialog.show() par._activity_dialog.raise_() self._activity_item._activityBtn.setArrowType( Qt.ArrowType.DownArrow ) else: par._activity_dialog.hide() self._activity_item._activityBtn.setArrowType(Qt.ArrowType.UpArrow) class StatusBarWidget(QWidget): def __init__( self, status_label: QLabel, layer_label: QLabel, source_label: QLabel, plugin_label: QLabel, coordinates_label: QLabel, help_label: QLabel, parent: QWidget = None, ) -> None: super().__init__(parent=parent) self._status_label = status_label self._layer_label = layer_label self._source_label = source_label self._plugin_label = plugin_label self._coordinates_label = coordinates_label self._help_label = help_label self._status_label.setParent(self) self._layer_label.setParent(self) self._source_label.setParent(self) self._plugin_label.setParent(self) self._coordinates_label.setParent(self) self._help_label.setParent(self) def resizeEvent(self, event: QResizeEvent) -> None: super().resizeEvent(event) self.do_layout() def event(self, event: QEvent) -> bool: if event.type() == QEvent.Type.LayoutRequest: self.do_layout() return super().event(event) @staticmethod def _calc_width(fm: QFontMetrics, label: QLabel) -> int: # magical nuber +2 is from superqt code # magical number +12 is from experiments # Adding this values is required to avoid the text to be elided # if there is enough space to show it. return ( ( fm.boundingRect(label.text()).width() + label.margin() * 2 + 2 + 12 ) if label.isVisible() else 0 ) def do_layout(self): width = self.width() height = self.height() fm = QFontMetrics(self._status_label.font()) status_width = self._calc_width(fm, self._status_label) layer_width = self._calc_width(fm, self._layer_label) source_width = self._calc_width(fm, self._source_label) plugin_width = self._calc_width(fm, self._plugin_label) coordinates_width = self._calc_width(fm, self._coordinates_label) base_width = ( status_width + layer_width + source_width + plugin_width + coordinates_width ) help_width = max(0, width - base_width) if coordinates_width: help_width = 0 if base_width > width: self._help_label.setVisible(False) layer_width = max( int((layer_width / base_width) * layer_width), min(self._layer_label.minimumWidth(), layer_width), ) source_width = max( int((source_width / base_width) * source_width), min(self._source_label.minimumWidth(), source_width), ) plugin_width = max( int((plugin_width / base_width) * plugin_width), min(self._plugin_label.minimumWidth(), plugin_width), ) coordinates_width = ( base_width - status_width - layer_width - source_width - plugin_width ) else: self._help_label.setVisible(True) self._status_label.setGeometry(0, 0, status_width, height) shift = status_width self._layer_label.setGeometry(shift, 0, layer_width, height) shift += layer_width self._source_label.setGeometry(shift, 0, source_width, height) shift += source_width self._plugin_label.setGeometry(shift, 0, plugin_width, height) shift += plugin_width self._coordinates_label.setGeometry( shift, 0, coordinates_width, height ) shift += coordinates_width self._help_label.setGeometry(shift, 0, help_width, height) napari-0.5.0a1/napari/_qt/widgets/qt_welcome.py000066400000000000000000000135061437041365600214420ustar00rootroot00000000000000from __future__ import annotations from qtpy.QtCore import QSize, Qt, Signal from qtpy.QtGui import QKeySequence, QPainter from qtpy.QtWidgets import ( QFormLayout, QLabel, QStackedWidget, QStyle, QStyleOption, QVBoxLayout, QWidget, ) from napari.utils.action_manager import action_manager from napari.utils.interactions import Shortcut from napari.utils.translations import trans class QtWelcomeLabel(QLabel): """Labels used for main message in welcome page.""" class QtShortcutLabel(QLabel): """Labels used for displaying shortcu information in welcome page.""" class QtWelcomeWidget(QWidget): """Welcome widget to display initial information and shortcuts to user.""" sig_dropped = Signal("QEvent") def __init__(self, parent) -> None: super().__init__(parent) # Create colored icon using theme self._image = QLabel() self._image.setObjectName("logo_silhouette") self._image.setMinimumSize(300, 300) self._label = QtWelcomeLabel( trans._( "Drag image(s) here to open\nor\nUse the menu shortcuts below:" ) ) # Widget setup self.setAutoFillBackground(True) self.setAcceptDrops(True) self._image.setAlignment(Qt.AlignmentFlag.AlignCenter) self._label.setAlignment(Qt.AlignmentFlag.AlignCenter) # Layout text_layout = QVBoxLayout() text_layout.addWidget(self._label) # TODO: Use action manager for shortcut query and handling shortcut_layout = QFormLayout() sc = QKeySequence('Ctrl+O', QKeySequence.PortableText).toString( QKeySequence.NativeText ) shortcut_layout.addRow( QtShortcutLabel(sc), QtShortcutLabel(trans._("open image(s)")), ) self._shortcut_label = QtShortcutLabel("") shortcut_layout.addRow( self._shortcut_label, QtShortcutLabel(trans._("show all key bindings")), ) shortcut_layout.setSpacing(0) layout = QVBoxLayout() layout.addStretch() layout.setSpacing(30) layout.addWidget(self._image) layout.addLayout(text_layout) layout.addLayout(shortcut_layout) layout.addStretch() self.setLayout(layout) self._show_shortcuts_updated() action_manager.events.shorcut_changed.connect( self._show_shortcuts_updated ) def minimumSizeHint(self): """ Overwrite minimum size to allow creating small viewer instance """ return QSize(100, 100) def _show_shortcuts_updated(self): shortcut_list = list( action_manager._shortcuts["napari:show_shortcuts"] ) if not shortcut_list: return self._shortcut_label.setText(Shortcut(shortcut_list[0]).platform) def paintEvent(self, event): """Override Qt method. Parameters ---------- event : qtpy.QtCore.QEvent Event from the Qt context. """ option = QStyleOption() option.initFrom(self) p = QPainter(self) self.style().drawPrimitive(QStyle.PE_Widget, option, p, self) def _update_property(self, prop, value): """Update properties of widget to update style. Parameters ---------- prop : str Property name to update. value : bool Property value to update. """ self.setProperty(prop, value) self.style().unpolish(self) self.style().polish(self) def dragEnterEvent(self, event): """Override Qt method. Provide style updates on event. Parameters ---------- event : qtpy.QtCore.QDragEnterEvent Event from the Qt context. """ self._update_property("drag", True) if event.mimeData().hasUrls(): viewer = self.parentWidget().nativeParentWidget()._qt_viewer viewer._set_drag_status() event.accept() else: event.ignore() def dragLeaveEvent(self, event): """Override Qt method. Provide style updates on event. Parameters ---------- event : qtpy.QtCore.QDragLeaveEvent Event from the Qt context. """ self._update_property("drag", False) def dropEvent(self, event): """Override Qt method. Provide style updates on event and emit the drop event. Parameters ---------- event : qtpy.QtCore.QDropEvent Event from the Qt context. """ self._update_property("drag", False) self.sig_dropped.emit(event) class QtWidgetOverlay(QStackedWidget): """ Stacked widget providing switching between the widget and a welcome page. """ sig_dropped = Signal("QEvent") resized = Signal() leave = Signal() enter = Signal() def __init__(self, parent, widget) -> None: super().__init__(parent) self._overlay = QtWelcomeWidget(self) # Widget setup self.addWidget(widget) self.addWidget(self._overlay) self.setCurrentIndex(0) # Signals self._overlay.sig_dropped.connect(self.sig_dropped) def set_welcome_visible(self, visible=True): """Show welcome screen widget on stack.""" self.setCurrentIndex(int(visible)) def resizeEvent(self, event): """Emit our own event when canvas was resized.""" self.resized.emit() return super().resizeEvent(event) def enterEvent(self, event): """Emit our own event when mouse enters the canvas.""" self.enter.emit() super().enterEvent(event) def leaveEvent(self, event): """Emit our own event when mouse leaves the canvas.""" self.leave.emit() super().leaveEvent(event) napari-0.5.0a1/napari/_tests/000077500000000000000000000000001437041365600157745ustar00rootroot00000000000000napari-0.5.0a1/napari/_tests/__init__.py000066400000000000000000000000001437041365600200730ustar00rootroot00000000000000napari-0.5.0a1/napari/_tests/test_adding_removing.py000066400000000000000000000102361437041365600225430ustar00rootroot00000000000000import numpy as np import pytest from napari._tests.utils import ( layer_test_data, skip_local_popups, skip_on_win_ci, ) from napari.layers import Image from napari.utils.events.event import WarningEmitter @skip_on_win_ci @skip_local_popups @pytest.mark.parametrize('Layer, data, _', layer_test_data) def test_add_all_layers(make_napari_viewer, Layer, data, _): """Make sure that all layers can show in the viewer.""" viewer = make_napari_viewer(show=True) viewer.layers.append(Layer(data)) def test_layers_removed_on_close(make_napari_viewer): """Test layers removed on close.""" viewer = make_napari_viewer() # add layers viewer.add_image(np.random.random((30, 40))) viewer.add_image(np.random.random((50, 20))) assert len(viewer.layers) == 2 viewer.close() # check layers have been removed assert len(viewer.layers) == 0 def test_layer_multiple_viewers(make_napari_viewer): """Test layer on multiple viewers.""" # Check that a layer can be added and removed from # mutliple viewers. See https://github.com/napari/napari/issues/1503 # for more detail. viewer_a = make_napari_viewer() viewer_b = make_napari_viewer() # create layer layer = Image(np.random.random((30, 40))) # add layer viewer_a.layers.append(layer) viewer_b.layers.append(layer) # Change property layer.opacity = 0.8 assert layer.opacity == 0.8 # Remove layer from one viewer viewer_b.layers.remove(layer) # Change property layer.opacity = 0.6 assert layer.opacity == 0.6 def test_adding_removing_layer(make_napari_viewer): """Test adding and removing a layer.""" np.random.seed(0) viewer = make_napari_viewer() # Create layer data = np.random.random((2, 6, 30, 40)) layer = Image(data) # Check that no internal callbacks have been registered assert len(layer.events.callbacks) == 0 for em in layer.events.emitters.values(): assert len(em.callbacks) == 0 # Add layer viewer.layers.append(layer) assert np.all(viewer.layers[0].data == data) assert len(viewer.layers) == 1 assert viewer.dims.ndim == 4 # check that adding a layer created new callbacks assert any(len(em.callbacks) > 0 for em in layer.events.emitters.values()) # Remove layer, viewer resets layer = viewer.layers[0] viewer.layers.remove(layer) assert len(viewer.layers) == 0 assert viewer.dims.ndim == 2 # Check that no other internal callbacks have been registered assert len(layer.events.callbacks) == 0 for em in layer.events.emitters.values(): assert len(em.callbacks) == 0 # re-add layer viewer.layers.append(layer) assert len(viewer.layers) == 1 assert viewer.dims.ndim == 4 @pytest.mark.parametrize('Layer, data, ndim', layer_test_data) def test_add_remove_layer_external_callbacks( make_napari_viewer, Layer, data, ndim ): """Test external callbacks for layer emmitters preserved.""" viewer = make_napari_viewer() layer = Layer(data) # Check layer has been correctly created assert layer.ndim == ndim # Connect a custom callback def my_custom_callback(): return layer.events.connect(my_custom_callback) # Check that no internal callbacks have been registered assert len(layer.events.callbacks) == 1 for em in layer.events.emitters.values(): # warningEmitters are not connected when connecting to the emitterGroup if not isinstance(em, WarningEmitter): assert len(em.callbacks) == 1 viewer.layers.append(layer) # Check layer added correctly assert len(viewer.layers) == 1 # check that adding a layer created new callbacks assert any(len(em.callbacks) > 0 for em in layer.events.emitters.values()) viewer.layers.remove(layer) # Check layer removed correctly assert len(viewer.layers) == 0 # Check that all internal callbacks have been removed assert len(layer.events.callbacks) == 1 for em in layer.events.emitters.values(): # warningEmitters are not connected when connecting to the emitterGroup if not isinstance(em, WarningEmitter): assert len(em.callbacks) == 1 napari-0.5.0a1/napari/_tests/test_advanced.py000066400000000000000000000213541437041365600211570ustar00rootroot00000000000000import numpy as np import pytest def test_4D_5D_images(make_napari_viewer): """Test adding 4D followed by 5D image layers to the viewer. Initially only 2 sliders should be present, then a third slider should be created. """ np.random.seed(0) viewer = make_napari_viewer() view = viewer.window._qt_viewer # add 4D image data data = np.random.random((2, 6, 30, 40)) viewer.add_image(data) assert np.all(viewer.layers[0].data == data) assert len(viewer.layers) == 1 assert viewer.dims.ndim == 4 assert view.dims.nsliders == viewer.dims.ndim assert np.sum(view.dims._displayed_sliders) == 2 # now add 5D image data - check an extra slider has been created data = np.random.random((4, 4, 5, 30, 40)) viewer.add_image(data) assert np.all(viewer.layers[1].data == data) assert len(viewer.layers) == 2 assert viewer.dims.ndim == 5 assert view.dims.nsliders == viewer.dims.ndim assert np.sum(view.dims._displayed_sliders) == 3 def test_5D_image_3D_rendering(make_napari_viewer): """Test 3D rendering of a 5D image.""" np.random.seed(0) viewer = make_napari_viewer() view = viewer.window._qt_viewer # add 4D image data data = np.random.random((2, 10, 12, 13, 14)) viewer.add_image(data) assert np.all(viewer.layers[0].data == data) assert len(viewer.layers) == 1 assert viewer.dims.ndim == 5 assert viewer.dims.ndisplay == 2 assert viewer.layers[0]._data_view.ndim == 2 assert view.dims.nsliders == viewer.dims.ndim assert np.sum(view.dims._displayed_sliders) == 3 # switch to 3D rendering viewer.dims.ndisplay = 3 assert viewer.dims.ndisplay == 3 assert viewer.layers[0]._data_view.ndim == 3 assert np.sum(view.dims._displayed_sliders) == 2 def test_change_image_dims(make_napari_viewer): """Test changing the dims and shape of an image layer in place and checking the numbers of sliders and their ranges changes appropriately. """ np.random.seed(0) viewer = make_napari_viewer() view = viewer.window._qt_viewer # add 3D image data data = np.random.random((10, 30, 40)) viewer.add_image(data) assert np.all(viewer.layers[0].data == data) assert len(viewer.layers) == 1 assert viewer.dims.ndim == 3 assert view.dims.nsliders == viewer.dims.ndim assert np.sum(view.dims._displayed_sliders) == 1 # switch number of displayed dimensions viewer.layers[0].data = data[0] assert np.all(viewer.layers[0].data == data[0]) assert len(viewer.layers) == 1 assert viewer.dims.ndim == 2 assert view.dims.nsliders == viewer.dims.ndim assert np.sum(view.dims._displayed_sliders) == 0 # switch number of displayed dimensions viewer.layers[0].data = data[:6] assert np.all(viewer.layers[0].data == data[:6]) assert len(viewer.layers) == 1 assert viewer.dims.ndim == 3 assert view.dims.nsliders == viewer.dims.ndim assert np.sum(view.dims._displayed_sliders) == 1 # change the shape of the data viewer.layers[0].data = data[:3] assert np.all(viewer.layers[0].data == data[:3]) assert len(viewer.layers) == 1 assert viewer.dims.ndim == 3 assert view.dims.nsliders == viewer.dims.ndim assert np.sum(view.dims._displayed_sliders) == 1 def test_range_one_image(make_napari_viewer): """Test adding an image with a range one dimensions. There should be no slider shown for the axis corresponding to the range one dimension. """ np.random.seed(0) viewer = make_napari_viewer() view = viewer.window._qt_viewer # add 5D image data with range one dimensions data = np.random.random((1, 1, 1, 100, 200)) viewer.add_image(data) assert np.all(viewer.layers[0].data == data) assert len(viewer.layers) == 1 assert viewer.dims.ndim == 5 assert view.dims.nsliders == viewer.dims.ndim assert np.sum(view.dims._displayed_sliders) == 0 # now add 5D points data - check extra sliders have been created points = np.floor(5 * np.random.random((1000, 5))).astype(int) points[:, -2:] = 20 * points[:, -2:] viewer.add_points(points) assert np.all(viewer.layers[1].data == points) assert len(viewer.layers) == 2 assert viewer.dims.ndim == 5 assert view.dims.nsliders == viewer.dims.ndim assert np.sum(view.dims._displayed_sliders) == 3 def test_range_one_images_and_points(make_napari_viewer): """Test adding images with range one dimensions and points. Initially no sliders should be present as the images have range one dimensions. On adding the points the sliders should be displayed. """ np.random.seed(0) viewer = make_napari_viewer() view = viewer.window._qt_viewer # add 5D image data with range one dimensions data = np.random.random((1, 1, 1, 100, 200)) viewer.add_image(data) assert np.all(viewer.layers[0].data == data) assert len(viewer.layers) == 1 assert viewer.dims.ndim == 5 assert view.dims.nsliders == viewer.dims.ndim assert np.sum(view.dims._displayed_sliders) == 0 # now add 5D points data - check extra sliders have been created points = np.floor(5 * np.random.random((1000, 5))).astype(int) points[:, -2:] = 20 * points[:, -2:] viewer.add_points(points) assert np.all(viewer.layers[1].data == points) assert len(viewer.layers) == 2 assert viewer.dims.ndim == 5 assert view.dims.nsliders == viewer.dims.ndim assert np.sum(view.dims._displayed_sliders) == 3 @pytest.mark.filterwarnings("ignore::DeprecationWarning:jupyter_client") def test_update_console(make_napari_viewer): """Test updating the console with local variables.""" viewer = make_napari_viewer() view = viewer.window._qt_viewer # Check viewer in console assert view.console.kernel_client is not None assert 'viewer' in view.console.shell.user_ns assert view.console.shell.user_ns['viewer'] == viewer a = 4 b = 5 locs = locals() viewer.update_console(locs) assert 'a' in view.console.shell.user_ns assert view.console.shell.user_ns['a'] == a assert 'b' in view.console.shell.user_ns assert view.console.shell.user_ns['b'] == b for k in locs.keys(): del viewer.window._qt_viewer.console.shell.user_ns[k] def test_changing_display_surface(make_napari_viewer): """Test adding 3D surface and changing its display.""" viewer = make_napari_viewer() view = viewer.window._qt_viewer np.random.seed(0) vertices = 20 * np.random.random((10, 3)) faces = np.random.randint(10, size=(6, 3)) values = np.random.random(10) data = (vertices, faces, values) viewer.add_surface(data) assert np.all( [np.all(vd == d) for vd, d in zip(viewer.layers[0].data, data)] ) assert len(viewer.layers) == 1 assert view.layers.model().rowCount() == len(viewer.layers) assert viewer.dims.ndim == 3 assert view.dims.nsliders == viewer.dims.ndim # Check display is currently 2D with one slider assert viewer.layers[0]._data_view.shape[1] == 2 assert np.sum(view.dims._displayed_sliders) == 1 # Make display 3D viewer.dims.ndisplay = 3 assert viewer.layers[0]._data_view.shape[1] == 3 assert np.sum(view.dims._displayed_sliders) == 0 # Make display 2D again viewer.dims.ndisplay = 2 assert viewer.layers[0]._data_view.shape[1] == 2 assert np.sum(view.dims._displayed_sliders) == 1 # Iterate over all values in first dimension len_slider = viewer.dims.range[0] for s in len_slider: viewer.dims.set_point(0, s) def test_labels_undo_redo(make_napari_viewer): """Test undoing/redoing on the labels layer.""" viewer = make_napari_viewer() data = np.zeros((50, 50), dtype=np.uint8) data[:5, :5] = 1 data[5:10, 5:10] = 2 data[25:, 25:] = 3 labels = viewer.add_labels(data) l1 = labels.data.copy() # fill labels.fill((30, 30), 42) l2 = labels.data.copy() assert not np.array_equal(l1, l2) # undo labels.undo() assert np.array_equal(l1, labels.data) # redo labels.redo() assert np.array_equal(l2, labels.data) # history limit labels._history_limit = 1 labels._reset_history() labels.fill((0, 0), 3) l3 = labels.data.copy() assert not np.array_equal(l3, l2) labels.undo() assert np.array_equal(l2, labels.data) # cannot undo as limit exceeded labels.undo() assert np.array_equal(l2, labels.data) def test_labels_brush_size(make_napari_viewer): """Test changing labels brush size.""" viewer = make_napari_viewer() data = np.zeros((50, 50), dtype=np.uint8) labels = viewer.add_labels(data) # Make small change labels.brush_size = 20 assert labels.brush_size == 20 # Make large change labels.brush_size = 100 assert labels.brush_size == 100 napari-0.5.0a1/napari/_tests/test_cli.py000066400000000000000000000141141437041365600201550ustar00rootroot00000000000000import gc import sys from unittest import mock import pytest import napari from napari import __main__ @pytest.fixture def mock_run(): """mock to prevent starting the event loop.""" with mock.patch('napari._qt.widgets.qt_splash_screen.NapariSplashScreen'): with mock.patch('napari.run'): yield napari.run def test_cli_works(monkeypatch, capsys): """Test the cli runs and shows help""" monkeypatch.setattr(sys, 'argv', ['napari', '-h']) with pytest.raises(SystemExit): __main__._run() assert 'napari command line viewer.' in str(capsys.readouterr()) def test_cli_shows_plugins(monkeypatch, capsys, tmp_plugin): """Test the cli --info runs and shows plugins""" monkeypatch.setattr(sys, 'argv', ['napari', '--info']) with pytest.raises(SystemExit): __main__._run() assert tmp_plugin.name in str(capsys.readouterr()) def test_cli_parses_unknowns(mock_run, monkeypatch, make_napari_viewer): """test that we can parse layer keyword arg variants""" v = make_napari_viewer() # our mock view_path will return this object def assert_kwargs(*args, **kwargs): assert ["file"] in args assert kwargs['contrast_limits'] == (0, 1) # testing all the variants of literal_evals with mock.patch('napari.Viewer', return_value=v): monkeypatch.setattr( napari.components.viewer_model.ViewerModel, 'open', assert_kwargs ) with monkeypatch.context() as m: m.setattr( sys, 'argv', ['n', 'file', '--contrast-limits', '(0, 1)'] ) __main__._run() with monkeypatch.context() as m: m.setattr(sys, 'argv', ['n', 'file', '--contrast-limits', '(0,1)']) __main__._run() with monkeypatch.context() as m: m.setattr(sys, 'argv', ['n', 'file', '--contrast-limits=(0, 1)']) __main__._run() with monkeypatch.context() as m: m.setattr(sys, 'argv', ['n', 'file', '--contrast-limits=(0,1)']) __main__._run() def test_cli_raises(monkeypatch): """test that unknown kwargs raise the correct errors.""" with monkeypatch.context() as m: m.setattr(sys, 'argv', ['napari', 'path/to/file', '--nonsense']) with pytest.raises(SystemExit) as e: __main__._run() assert str(e.value) == 'error: unrecognized arguments: --nonsense' with monkeypatch.context() as m: m.setattr(sys, 'argv', ['napari', 'path/to/file', '--gamma']) with pytest.raises(SystemExit) as e: __main__._run() assert str(e.value) == 'error: argument --gamma expected one argument' @mock.patch('runpy.run_path') def test_cli_runscript(run_path, monkeypatch, tmp_path): """Test that running napari script.py runs a script""" script = tmp_path / 'test.py' script.write_text('import napari; v = napari.Viewer(show=False)') with monkeypatch.context() as m: m.setattr(sys, 'argv', ['napari', str(script)]) __main__._run() run_path.assert_called_once_with(str(script)) @mock.patch('napari._qt.qt_viewer.QtViewer._qt_open') def test_cli_passes_kwargs(qt_open, mock_run, monkeypatch, make_napari_viewer): """test that we can parse layer keyword arg variants""" v = make_napari_viewer() with mock.patch('napari.Viewer', return_value=v): with monkeypatch.context() as m: m.setattr(sys, 'argv', ['n', 'file', '--name', 'some name']) __main__._run() qt_open.assert_called_once_with( ['file'], stack=[], plugin=None, layer_type=None, name='some name', ) mock_run.assert_called_once_with(gui_exceptions=True) @mock.patch('napari._qt.qt_viewer.QtViewer._qt_open') def test_cli_passes_kwargs_stack( qt_open, mock_run, monkeypatch, make_napari_viewer ): """test that we can parse layer keyword arg variants""" v = make_napari_viewer() with mock.patch('napari.Viewer', return_value=v): with monkeypatch.context() as m: m.setattr( sys, 'argv', [ 'n', 'file', '--stack', 'file1', 'file2', '--stack', 'file3', 'file4', '--name', 'some name', ], ) __main__._run() qt_open.assert_called_once_with( ['file'], stack=[['file1', 'file2'], ['file3', 'file4']], plugin=None, layer_type=None, name='some name', ) mock_run.assert_called_once_with(gui_exceptions=True) def test_cli_retains_viewer_ref(mock_run, monkeypatch, make_napari_viewer): """Test that napari.__main__ is retaining a reference to the viewer.""" v = make_napari_viewer() # our mock view_path will return this object ref_count = None # counter that will be updated before __main__._run() def _check_refs(**kwargs): # when run() is called in napari.__main__, we will call this function # it forces garbage collection, and then makes sure that at least one # additional reference to our viewer exists. gc.collect() if sys.getrefcount(v) <= ref_count: raise AssertionError( "Reference to napari.viewer has been lost by " "the time the event loop started in napari.__main__" ) mock_run.side_effect = _check_refs with monkeypatch.context() as m: m.setattr(sys, 'argv', ['napari', 'path/to/file.tif']) # return our local v with mock.patch('napari.Viewer', return_value=v) as mock_viewer: ref_count = sys.getrefcount(v) # count current references # mock gui open so we're not opening dialogs/throwing errors on fake path with mock.patch( 'napari._qt.qt_viewer.QtViewer._qt_open', return_value=None ) as mock_viewer_open: __main__._run() mock_viewer.assert_called_once() mock_viewer_open.assert_called_once() napari-0.5.0a1/napari/_tests/test_conftest_fixtures.py000066400000000000000000000017641437041365600231730ustar00rootroot00000000000000import pytest from qtpy.QtCore import QMutex, QThread, QTimer class _TestThread(QThread): def __init__(self) -> None: super().__init__() self.mutex = QMutex() def run(self): self.mutex.lock() @pytest.mark.disable_qthread_start def test_disable_qthread(qapp): t = _TestThread() t.mutex.lock() t.start() assert not t.isRunning() t.mutex.unlock() def test_qthread_running(qtbot): t = _TestThread() t.mutex.lock() t.start() assert t.isRunning() t.mutex.unlock() qtbot.waitUntil(t.isFinished, timeout=2000) @pytest.mark.disable_qtimer_start def test_disable_qtimer(qtbot): t = QTimer() t.setInterval(100) t.start() assert not t.isActive() # As qtbot uses a QTimer in waitUntil, we also test if timer disable does not break it th = _TestThread() th.mutex.lock() th.start() assert th.isRunning() th.mutex.unlock() qtbot.waitUntil(th.isFinished, timeout=2000) assert not th.isRunning() napari-0.5.0a1/napari/_tests/test_draw.py000066400000000000000000000017351437041365600203500ustar00rootroot00000000000000import sys import numpy as np import pytest from napari._tests.utils import skip_local_popups @skip_local_popups @pytest.mark.skipif( sys.platform.startswith('win') or sys.platform.startswith('linux'), reason='Currently fails on certain CI due to error on canvas draw.', ) def test_canvas_drawing(make_napari_viewer): """Test drawing before and after adding and then deleting a layer.""" viewer = make_napari_viewer(show=True) view = viewer.window._qt_viewer view.set_welcome_visible(False) assert len(viewer.layers) == 0 # Check canvas context is not none before drawing, as currently on # some of our CI a proper canvas context is not made view.canvas.events.draw() # Add layer data = np.random.random((15, 10, 5)) layer = viewer.add_image(data) assert len(viewer.layers) == 1 view.canvas.events.draw() # Remove layer viewer.layers.remove(layer) assert len(viewer.layers) == 0 view.canvas.events.draw() napari-0.5.0a1/napari/_tests/test_dtypes.py000066400000000000000000000017341437041365600207220ustar00rootroot00000000000000import numpy as np import pytest from napari.components.viewer_model import ViewerModel dtypes = [ np.dtype(bool), np.dtype(np.int8), np.dtype(np.uint8), np.dtype(np.int16), np.dtype(np.uint16), np.dtype(np.int32), np.dtype(np.uint32), np.dtype(np.int64), np.dtype(np.uint64), np.dtype(np.float16), np.dtype(np.float32), np.dtype(np.float64), ] @pytest.mark.parametrize('dtype', dtypes) def test_image_dytpes(dtype): """Test different dtype images.""" np.random.seed(0) viewer = ViewerModel() # add dtype image data data = np.random.randint(20, size=(30, 40)).astype(dtype) viewer.add_image(data) assert np.all(viewer.layers[0].data == data) # add dtype multiscale data data = [ np.random.randint(20, size=(30, 40)).astype(dtype), np.random.randint(20, size=(15, 20)).astype(dtype), ] viewer.add_image(data, multiscale=True) assert np.all(viewer.layers[1].data == data) napari-0.5.0a1/napari/_tests/test_examples.py000066400000000000000000000052361437041365600212310ustar00rootroot00000000000000import os import runpy import sys from pathlib import Path import numpy as np import pytest import skimage.data from qtpy import API_NAME import napari from napari._qt.qt_main_window import Window from napari.utils.notifications import notification_manager # check if this module has been explicitly requested or `--test-examples` is included fpath = os.path.join(*__file__.split(os.path.sep)[-3:]) if '--test-examples' not in sys.argv and fpath not in sys.argv: pytest.skip( 'Use `--test-examples` to test examples', allow_module_level=True ) # not testing these examples skip = [ 'surface_timeseries_.py', # needs nilearn '3d_kymograph_.py', # needs tqdm 'live_tiffs_.py', # requires files 'tiled-rendering-2d_.py', # too slow 'live_tiffs_generator_.py', 'points-over-time.py', # too resource hungry 'embed_ipython_.py', # fails without monkeypatch 'new_theme.py', # testing theme is extremely slow on CI 'dynamic-projections-dask.py', # extremely slow / does not finish ] EXAMPLE_DIR = Path(napari.__file__).parent.parent / 'examples' # using f.name here and re-joining at `run_path()` for test key presentation # (works even if the examples list is empty, as opposed to using an ids lambda) examples = [f.name for f in EXAMPLE_DIR.glob("*.py") if f.name not in skip] # still some CI segfaults, but only on windows with pyqt5 if os.getenv("CI") and os.name == 'nt' and API_NAME == 'PyQt5': examples = [] if os.getenv("CI") and os.name == 'nt' and 'to_screenshot.py' in examples: examples.remove('to_screenshot.py') @pytest.mark.filterwarnings("ignore") @pytest.mark.skipif(not examples, reason="No examples were found.") @pytest.mark.parametrize("fname", examples) def test_examples(builtins, fname, monkeypatch): """Test that all of our examples are still working without warnings.""" # hide viewer window monkeypatch.setattr(Window, 'show', lambda *a: None) # prevent running the event loop monkeypatch.setattr(napari, 'run', lambda *a, **k: None) # Prevent downloading example data because this sometimes fails. monkeypatch.setattr( skimage.data, 'cells3d', lambda: np.zeros((60, 2, 256, 256), dtype=np.uint16), ) # make sure our sys.excepthook override doesn't hide errors def raise_errors(etype, value, tb): raise value monkeypatch.setattr(notification_manager, 'receive_error', raise_errors) # run the example! try: runpy.run_path(str(EXAMPLE_DIR / fname)) except SystemExit as e: # we use sys.exit(0) to gracefully exit from examples if e.code != 0: raise finally: napari.Viewer.close_all() napari-0.5.0a1/napari/_tests/test_function_widgets.py000066400000000000000000000016741437041365600227700ustar00rootroot00000000000000import numpy as np import napari.layers def test_add_function_widget(make_napari_viewer): """Test basic add_function_widget functionality""" from qtpy.QtWidgets import QDockWidget viewer = make_napari_viewer() # Define a function. def image_sum( layerA: napari.layers.Image, layerB: napari.layers.Image ) -> napari.layers.Image: """Add two layers.""" if layerA is not None and layerB is not None: return napari.layers.Image(layerA.data + layerB.data) dwidg = viewer.window.add_function_widget(image_sum) assert dwidg.name == 'image sum' assert viewer.window._qt_window.findChild(QDockWidget, 'image sum') # make sure that the choice of layers stays in sync with viewer.layers _magic_widget = dwidg.widget()._magic_widget assert _magic_widget.layerA.choices == () layer = viewer.add_image(np.random.rand(10, 10)) assert layer in _magic_widget.layerA.choices napari-0.5.0a1/napari/_tests/test_import_time.py000066400000000000000000000014611437041365600217370ustar00rootroot00000000000000import os import subprocess import sys import pytest @pytest.mark.skipif( bool(os.environ.get('MIN_REQ')), reason='skip import time test on MIN_REQ' ) def test_import_time(tmp_path): cmd = [sys.executable, '-X', 'importtime', '-c', 'import napari'] proc = subprocess.run(cmd, capture_output=True, check=True) log = proc.stderr.decode() last_line = log.splitlines()[-1] time, name = (i.strip() for i in last_line.split("|")[-2:]) # # This is too hard to do on CI... but we have the time here if we can # # figure out how to use it # assert name == 'napari' # assert int(time) < 1_000_000, "napari import taking longer than 1 sec!" print(f"\nnapari took {int(time)/1e6:0.3f} seconds to import") # common culprit of slow imports assert 'pkg_resources' not in log napari-0.5.0a1/napari/_tests/test_key_bindings.py000066400000000000000000000107111437041365600220520ustar00rootroot00000000000000from unittest.mock import Mock import numpy as np from vispy import keys def test_viewer_key_bindings(make_napari_viewer): """Test adding key bindings to the viewer""" np.random.seed(0) viewer = make_napari_viewer() view = viewer.window._qt_viewer mock_press = Mock() mock_release = Mock() mock_shift_press = Mock() mock_shift_release = Mock() @viewer.bind_key('F') def key_callback(v): assert viewer == v # on press mock_press.method() yield # on release mock_release.method() @viewer.bind_key('Shift-F') def key_shift_callback(v): assert viewer == v # on press mock_shift_press.method() yield # on release mock_shift_release.method() # Simulate press only view.canvas.events.key_press(key=keys.Key('F')) mock_press.method.assert_called_once() mock_press.reset_mock() mock_release.method.assert_not_called() mock_shift_press.method.assert_not_called() mock_shift_release.method.assert_not_called() # Simulate release only view.canvas.events.key_release(key=keys.Key('F')) mock_press.method.assert_not_called() mock_release.method.assert_called_once() mock_release.reset_mock() mock_shift_press.method.assert_not_called() mock_shift_release.method.assert_not_called() # Simulate press only view.canvas.events.key_press(key=keys.Key('F'), modifiers=[keys.SHIFT]) mock_press.method.assert_not_called() mock_release.method.assert_not_called() mock_shift_press.method.assert_called_once() mock_shift_press.reset_mock() mock_shift_release.method.assert_not_called() # Simulate release only view.canvas.events.key_release(key=keys.Key('F'), modifiers=[keys.SHIFT]) mock_press.method.assert_not_called() mock_release.method.assert_not_called() mock_shift_press.method.assert_not_called() mock_shift_release.method.assert_called_once() mock_shift_release.reset_mock() def test_layer_key_bindings(make_napari_viewer): """Test adding key bindings to a layer""" np.random.seed(0) viewer = make_napari_viewer() view = viewer.window._qt_viewer layer = viewer.add_image(np.random.random((10, 20))) viewer.layers.selection.add(layer) mock_press = Mock() mock_release = Mock() mock_shift_press = Mock() mock_shift_release = Mock() @layer.bind_key('F') def key_callback(_layer): assert layer == _layer # on press mock_press.method() yield # on release mock_release.method() @layer.bind_key('Shift-F') def key_shift_callback(_layer): assert layer == _layer # on press mock_shift_press.method() yield # on release mock_shift_release.method() # Simulate press only view.canvas.events.key_press(key=keys.Key('F')) mock_press.method.assert_called_once() mock_press.reset_mock() mock_release.method.assert_not_called() mock_shift_press.method.assert_not_called() mock_shift_release.method.assert_not_called() # Simulate release only view.canvas.events.key_release(key=keys.Key('F')) mock_press.method.assert_not_called() mock_release.method.assert_called_once() mock_release.reset_mock() mock_shift_press.method.assert_not_called() mock_shift_release.method.assert_not_called() # Simulate press only view.canvas.events.key_press(key=keys.Key('F'), modifiers=[keys.SHIFT]) mock_press.method.assert_not_called() mock_release.method.assert_not_called() mock_shift_press.method.assert_called_once() mock_shift_press.reset_mock() mock_shift_release.method.assert_not_called() # Simulate release only view.canvas.events.key_release(key=keys.Key('F'), modifiers=[keys.SHIFT]) mock_press.method.assert_not_called() mock_release.method.assert_not_called() mock_shift_press.method.assert_not_called() mock_shift_release.method.assert_called_once() mock_shift_release.reset_mock() def test_reset_scroll_progress(make_napari_viewer): """Test select all key binding.""" viewer = make_napari_viewer() view = viewer.window._qt_viewer assert viewer.dims._scroll_progress == 0 view.canvas.events.key_press(key=keys.Key('Control')) viewer.dims._scroll_progress = 10 assert viewer.dims._scroll_progress == 10 view.canvas.events.key_release(key=keys.Key('Control')) assert viewer.dims._scroll_progress == 0 napari-0.5.0a1/napari/_tests/test_layer_utils_with_qt.py000066400000000000000000000020421437041365600234760ustar00rootroot00000000000000import numpy as np import pytest from napari.layers import Image from napari.layers.utils.interactivity_utils import ( orient_plane_normal_around_cursor, ) @pytest.mark.parametrize( 'layer', [ Image(np.zeros(shape=(28, 28, 28))), Image(np.zeros(shape=(2, 28, 28, 28))), ], ) def test_orient_plane_normal_around_cursor(make_napari_viewer, layer): viewer = make_napari_viewer() viewer.dims.ndisplay = 3 viewer.camera.angles = (0, 0, 90) viewer.cursor.position = [14] * layer._ndim viewer.add_layer(layer) layer.depiction = 'plane' layer.plane.normal = (1, 0, 0) layer.plane.position = (14, 14, 14) # apply simple transformation on the volume layer.translate = [1] * layer._ndim # orient plane normal orient_plane_normal_around_cursor(layer=layer, plane_normal=(1, 0, 1)) # check that plane normal has been updated assert np.allclose( layer.plane.normal, [1, 0, 1] / np.linalg.norm([1, 0, 1]) ) assert np.allclose(layer.plane.position, (14, 13, 13)) napari-0.5.0a1/napari/_tests/test_magicgui.py000066400000000000000000000250661437041365600212030ustar00rootroot00000000000000import contextlib import sys import time from typing import List import numpy as np import pytest from magicgui import magicgui from napari import Viewer, layers, types from napari._tests.utils import layer_test_data from napari.layers import Image, Labels, Layer from napari.utils._proxies import PublicOnlyProxy from napari.utils.misc import all_subclasses try: import qtpy # noqa except ModuleNotFoundError: pytest.skip('Cannot test magicgui without qtpy.', allow_module_level=True) except RuntimeError: pytest.skip( 'Cannot test magicgui without Qt bindings.', allow_module_level=True ) # only test the first of each layer type test_data = [] for cls in all_subclasses(Layer): # OctTree Image doesn't have layer_test_data with contextlib.suppress(StopIteration): test_data.append(next(x for x in layer_test_data if x[0] is cls)) test_data.sort(key=lambda x: x[0].__name__) # required for xdist to work @pytest.mark.parametrize('LayerType, data, ndim', test_data) def test_magicgui_add_data(make_napari_viewer, LayerType, data, ndim): """Test that annotating with napari.types.Data works. It expects a raw data format (like a numpy array) and will add a layer of the corresponding type to the viewer. """ viewer = make_napari_viewer() dtype = getattr(types, f'{LayerType.__name__}Data') @magicgui # where `dtype` is something like napari.types.ImageData def add_data() -> dtype: # type: ignore # and data is just the bare numpy-array or similar return data viewer.window.add_dock_widget(add_data) add_data() assert len(viewer.layers) == 1 assert isinstance(viewer.layers[0], LayerType) assert viewer.layers[0].source.widget == add_data @pytest.mark.skipif( sys.version_info < (3, 9), reason='Futures not subscriptable before py3.9' ) @pytest.mark.parametrize('LayerType, data, ndim', test_data) def test_magicgui_add_future_data( qtbot, make_napari_viewer, LayerType, data, ndim ): """Test that annotating with Future[] works.""" from concurrent.futures import Future from functools import partial from qtpy.QtCore import QTimer viewer = make_napari_viewer() dtype = getattr(types, f'{LayerType.__name__}Data') @magicgui # where `dtype` is something like napari.types.ImageData def add_data() -> Future[dtype]: # type: ignore future = Future() # simulate something that isn't immediately ready when function returns QTimer.singleShot(10, partial(future.set_result, data)) return future viewer.window.add_dock_widget(add_data) def _assert_stuff(): assert len(viewer.layers) == 1 assert isinstance(viewer.layers[0], LayerType) assert viewer.layers[0].source.widget == add_data assert len(viewer.layers) == 0 with qtbot.waitSignal(viewer.layers.events.inserted): add_data() _assert_stuff() @pytest.mark.sync_only def test_magicgui_add_threadworker(qtbot, make_napari_viewer): """Test that annotating with FunctionWorker works.""" from napari.qt.threading import FunctionWorker, thread_worker viewer = make_napari_viewer() DATA = np.random.rand(10, 10) @magicgui def add_data(x: int) -> FunctionWorker[types.ImageData]: @thread_worker(start_thread=False) def _slow(): time.sleep(0.1) return DATA return _slow() viewer.window.add_dock_widget(add_data) assert len(viewer.layers) == 0 worker = add_data() # normally you wouldn't start the worker outside of the mgui function # this is just to make testing with threads easier with qtbot.waitSignal(worker.finished): worker.start() assert len(viewer.layers) == 1 assert isinstance(viewer.layers[0], Image) assert viewer.layers[0].source.widget == add_data assert np.array_equal(viewer.layers[0].data, DATA) @pytest.mark.parametrize('LayerType, data, ndim', test_data) def test_magicgui_get_data(make_napari_viewer, LayerType, data, ndim): """Test that annotating parameters with napari.types.Data. This will provide the same dropdown menu appearance as when annotating a parameter with napari.layers.... but the function will receive `layer.data` rather than `layer` """ viewer = make_napari_viewer() dtype = getattr(types, f'{LayerType.__name__}Data') @magicgui # where `dtype` is something like napari.types.ImageData def add_data(x: dtype): # and data is just the bare numpy-array or similar return data viewer.window.add_dock_widget(add_data) layer = LayerType(data) viewer.add_layer(layer) @pytest.mark.parametrize('LayerType, data, ndim', test_data) def test_magicgui_add_layer(make_napari_viewer, LayerType, data, ndim): viewer = make_napari_viewer() @magicgui def add_layer() -> LayerType: return LayerType(data) viewer.window.add_dock_widget(add_layer) add_layer() assert len(viewer.layers) == 1 assert isinstance(viewer.layers[0], LayerType) assert viewer.layers[0].source.widget == add_layer def test_magicgui_add_layer_list(make_napari_viewer): viewer = make_napari_viewer() @magicgui def add_layer() -> List[Layer]: a = Image(data=np.random.randint(0, 10, size=(10, 10))) b = Labels(data=np.random.randint(0, 10, size=(10, 10))) return [a, b] viewer.window.add_dock_widget(add_layer) add_layer() assert len(viewer.layers) == 2 assert isinstance(viewer.layers[0], Image) assert isinstance(viewer.layers[1], Labels) assert viewer.layers[0].source.widget == add_layer assert viewer.layers[1].source.widget == add_layer def test_magicgui_add_layer_data_tuple(make_napari_viewer): viewer = make_napari_viewer() @magicgui def add_layer() -> types.LayerDataTuple: data = ( np.random.randint(0, 10, size=(10, 10)), {'name': 'hi'}, 'labels', ) # it works fine to just return `data` # but this will avoid mypy/linter errors and has no runtime burden return types.LayerDataTuple(data) viewer.window.add_dock_widget(add_layer) add_layer() assert len(viewer.layers) == 1 assert isinstance(viewer.layers[0], Labels) assert viewer.layers[0].source.widget == add_layer def test_magicgui_add_layer_data_tuple_list(make_napari_viewer): viewer = make_napari_viewer() @magicgui def add_layer() -> List[types.LayerDataTuple]: data1 = (np.random.rand(10, 10), {'name': 'hi'}) data2 = ( np.random.randint(0, 10, size=(10, 10)), {'name': 'hi2'}, 'labels', ) return [data1, data2] # type: ignore viewer.window.add_dock_widget(add_layer) add_layer() assert len(viewer.layers) == 2 assert isinstance(viewer.layers[0], Image) assert isinstance(viewer.layers[1], Labels) assert viewer.layers[0].source.widget == add_layer assert viewer.layers[1].source.widget == add_layer def test_magicgui_data_updated(make_napari_viewer): """Test that magic data parameters stay up to date.""" viewer = make_napari_viewer() _returns = [] # the value of x returned from func @magicgui(auto_call=True) def func(x: types.PointsData): _returns.append(x) viewer.window.add_dock_widget(func) points = viewer.add_points(None) # func will have been called with an empty points np.testing.assert_allclose(_returns[-1], np.empty((0, 2))) points.add((10, 10)) # func will have been called with 1 data including 1 point np.testing.assert_allclose(_returns[-1], np.array([[10, 10]])) points.add((15, 15)) # func will have been called with 1 data including 2 points np.testing.assert_allclose(_returns[-1], np.array([[10, 10], [15, 15]])) def test_magicgui_get_viewer(make_napari_viewer): """Test that annotating with napari.Viewer gets the Viewer""" # Make two DIFFERENT viewers viewer1 = make_napari_viewer() viewer2 = make_napari_viewer() assert viewer2 is not viewer1 # Ensure one is returned by napari.current_viewer() from napari import current_viewer assert current_viewer() is viewer2 @magicgui def func(v: Viewer): return v def func_returns(v: Viewer) -> bool: """Helper function determining whether func() returns v""" func_viewer = func() assert isinstance(func_viewer, PublicOnlyProxy) return func_viewer.__wrapped__ is v # We expect func's Viewer to be current_viewer, not viewer assert func_returns(viewer2) assert not func_returns(viewer1) # With viewer as parent, it should be returned instead viewer1.window.add_dock_widget(func) assert func_returns(viewer1) assert not func_returns(viewer2) # no widget should be shown assert not func.v.visible # ensure that viewer2 is still the current viewer assert current_viewer() is viewer2 MGUI_EXPORTS = ['napari.layers.Layer', 'napari.Viewer'] MGUI_EXPORTS += [f'napari.types.{nm.title()}Data' for nm in layers.NAMES] NAMES = ('Image', 'Labels', 'Layer', 'Points', 'Shapes', 'Surface') @pytest.mark.parametrize('name', sorted(MGUI_EXPORTS)) def test_mgui_forward_refs(name, monkeypatch): """make sure that magicgui's `get_widget_class` returns the right widget type for the various napari types... even when expressed as strings. """ import magicgui.widgets from magicgui.type_map import get_widget_class monkeypatch.delitem(sys.modules, 'napari') monkeypatch.delitem(sys.modules, 'napari.viewer') monkeypatch.delitem(sys.modules, 'napari.types') # need to clear all of these submodules too, otherise the layers are oddly not # subclasses of napari.layers.Layer, and napari.layers.NAMES # oddly ends up as an empty set for m in list(sys.modules): if m.startswith('napari.layers') and 'utils' not in m: monkeypatch.delitem(sys.modules, m) wdg, options = get_widget_class(annotation=name) if name == 'napari.Viewer': assert wdg == magicgui.widgets.EmptyWidget and 'bind' in options else: assert wdg == magicgui.widgets.Combobox def test_layers_populate_immediately(make_napari_viewer): """make sure that the layers dropdown is populated upon adding to viewer""" from magicgui.widgets import create_widget labels_layer = create_widget(annotation=Labels, label="ROI") viewer = make_napari_viewer() viewer.add_labels(np.zeros((10, 10), dtype=int)) assert not len(labels_layer.choices) viewer.window.add_dock_widget(labels_layer) assert len(labels_layer.choices) == 1 napari-0.5.0a1/napari/_tests/test_mouse_bindings.py000066400000000000000000000152161437041365600224170ustar00rootroot00000000000000import os from unittest.mock import Mock import numpy as np from napari._tests.utils import skip_on_win_ci @skip_on_win_ci def test_viewer_mouse_bindings(make_napari_viewer): """Test adding mouse bindings to the viewer""" np.random.seed(0) viewer = make_napari_viewer() view = viewer.window._qt_viewer if os.getenv("CI"): viewer.show() mock_press = Mock() mock_drag = Mock() mock_release = Mock() mock_move = Mock() @viewer.mouse_drag_callbacks.append def drag_callback(v, event): assert viewer == v # on press mock_press.method() yield # on move while event.type == 'mouse_move': mock_drag.method() yield # on release mock_release.method() @viewer.mouse_move_callbacks.append def move_callback(v, event): assert viewer == v # on move mock_move.method() # Simulate press only view.canvas.events.mouse_press(pos=(0, 0), modifiers=(), button=0) mock_press.method.assert_called_once() mock_press.reset_mock() mock_drag.method.assert_not_called() mock_release.method.assert_not_called() mock_move.method.assert_not_called() # Simulate release only view.canvas.events.mouse_release(pos=(0, 0), modifiers=(), button=0) mock_press.method.assert_not_called() mock_drag.method.assert_not_called() mock_release.method.assert_called_once() mock_release.reset_mock() mock_move.method.assert_not_called() # Simulate move with no press view.canvas.events.mouse_move(pos=(0, 0), modifiers=()) mock_press.method.assert_not_called() mock_drag.method.assert_not_called() mock_release.method.assert_not_called() mock_move.method.assert_called_once() mock_move.reset_mock() # Simulate press, drag, release view.canvas.events.mouse_press(pos=(0, 0), modifiers=(), button=0) view.canvas.events.mouse_move( pos=(0, 0), modifiers=(), button=0, press_event=True ) view.canvas.events.mouse_release(pos=(0, 0), modifiers=(), button=0) mock_press.method.assert_called_once() mock_drag.method.assert_called_once() mock_release.method.assert_called_once() mock_move.method.assert_not_called() @skip_on_win_ci def test_layer_mouse_bindings(make_napari_viewer): """Test adding mouse bindings to a layer that is selected""" np.random.seed(0) viewer = make_napari_viewer() view = viewer.window._qt_viewer if os.getenv("CI"): viewer.show() layer = viewer.add_image(np.random.random((10, 20))) viewer.layers.selection.add(layer) mock_press = Mock() mock_drag = Mock() mock_release = Mock() mock_move = Mock() @layer.mouse_drag_callbacks.append def drag_callback(_layer, event): assert layer == _layer # on press mock_press.method() yield # on move while event.type == 'mouse_move': mock_drag.method() yield # on release mock_release.method() @layer.mouse_move_callbacks.append def move_callback(_layer, event): assert layer == _layer # on press mock_move.method() # Simulate press only view.canvas.events.mouse_press(pos=(0, 0), modifiers=(), button=0) mock_press.method.assert_called_once() mock_press.reset_mock() mock_drag.method.assert_not_called() mock_release.method.assert_not_called() mock_move.method.assert_not_called() # Simulate release only view.canvas.events.mouse_release(pos=(0, 0), modifiers=(), button=0) mock_press.method.assert_not_called() mock_drag.method.assert_not_called() mock_release.method.assert_called_once() mock_release.reset_mock() mock_move.method.assert_not_called() # Simulate move with no press view.canvas.events.mouse_move(pos=(0, 0), modifiers=()) mock_press.method.assert_not_called() mock_drag.method.assert_not_called() mock_release.method.assert_not_called() mock_move.method.assert_called_once() mock_move.reset_mock() # Simulate press, drag, release view.canvas.events.mouse_press(pos=(0, 0), modifiers=(), button=0) view.canvas.events.mouse_move( pos=(0, 0), modifiers=(), button=0, press_event=True ) view.canvas.events.mouse_release(pos=(0, 0), modifiers=(), button=0) mock_press.method.assert_called_once() mock_drag.method.assert_called_once() mock_release.method.assert_called_once() mock_move.method.assert_not_called() @skip_on_win_ci def test_unselected_layer_mouse_bindings(make_napari_viewer): """Test adding mouse bindings to a layer that is not selected""" np.random.seed(0) viewer = make_napari_viewer() view = viewer.window._qt_viewer if os.getenv("CI"): viewer.show() layer = viewer.add_image(np.random.random((10, 20))) viewer.layers.selection.remove(layer) mock_press = Mock() mock_drag = Mock() mock_release = Mock() mock_move = Mock() @layer.mouse_drag_callbacks.append def drag_callback(_layer, event): assert layer == _layer # on press mock_press.method() yield # on move while event.type == 'mouse_move': mock_drag.method() yield # on release mock_release.method() @layer.mouse_move_callbacks.append def move_callback(_layer, event): assert layer == _layer # on press mock_move.method() # Simulate press only view.canvas.events.mouse_press(pos=(0, 0), modifiers=(), button=0) mock_press.method.assert_not_called() mock_drag.method.assert_not_called() mock_release.method.assert_not_called() mock_move.method.assert_not_called() # Simulate release only view.canvas.events.mouse_release(pos=(0, 0), modifiers=(), button=0) mock_press.method.assert_not_called() mock_drag.method.assert_not_called() mock_release.method.assert_not_called() mock_move.method.assert_not_called() # Simulate move with no press view.canvas.events.mouse_move(pos=(0, 0), modifiers=()) mock_press.method.assert_not_called() mock_drag.method.assert_not_called() mock_release.method.assert_not_called() mock_move.method.assert_not_called() # Simulate press, drag, release view.canvas.events.mouse_press(pos=(0, 0), modifiers=(), button=0) view.canvas.events.mouse_move( pos=(0, 0), modifiers=(), button=0, press_event=True ) view.canvas.events.mouse_release(pos=(0, 0), modifiers=(), button=0) mock_press.method.assert_not_called() mock_drag.method.assert_not_called() mock_release.method.assert_not_called() mock_move.method.assert_not_called() napari-0.5.0a1/napari/_tests/test_multiple_viewers.py000066400000000000000000000012441437041365600230050ustar00rootroot00000000000000import gc from unittest.mock import patch from napari import Viewer def test_multi_viewers_dont_clash(qtbot): v1 = Viewer(show=False, title='v1') v2 = Viewer(show=False, title='v2') assert not v1.grid.enabled assert not v2.grid.enabled v1.window.activate() # a click would do this in the actual gui v1.window._qt_viewer.viewerButtons.gridViewButton.click() assert not v2.grid.enabled assert v1.grid.enabled with patch.object(v1.window._qt_window, '_save_current_window_settings'): v1.close() with patch.object(v2.window._qt_window, '_save_current_window_settings'): v2.close() qtbot.wait(50) gc.collect() napari-0.5.0a1/napari/_tests/test_notebook_display.py000066400000000000000000000053401437041365600227540ustar00rootroot00000000000000import html from unittest.mock import Mock import numpy as np import pytest from napari._tests.utils import skip_on_win_ci from napari.utils import nbscreenshot @skip_on_win_ci def test_nbscreenshot(make_napari_viewer): """Test taking a screenshot.""" viewer = make_napari_viewer() np.random.seed(0) data = np.random.random((10, 15)) viewer.add_image(data) rich_display_object = nbscreenshot(viewer) assert hasattr(rich_display_object, '_repr_png_') # Trigger method that would run in jupyter notebook cell automatically rich_display_object._repr_png_() assert rich_display_object.image is not None @skip_on_win_ci @pytest.mark.parametrize( "alt_text_input, expected_alt_text", [ (None, None), ("Good alt text", "Good alt text"), # Naughty strings https://github.com/minimaxir/big-list-of-naughty-strings # ASCII punctuation (r",./;'[]\-=", ',./;'[]\\-='), # noqa: W605 # ASCII punctuation 2, skipping < because that is interpreted as the start # of an HTML element. ('>?:"{}|_+', '>?:"{}|_+'), ("!@#$%^&*()`~", '!@#$%^&*()`~'), # ASCII punctuation 3 # # Emojis ("😍", "😍"), # emoji 1 ("👨‍🦰 👨🏿‍🦰 👨‍🦱 👨🏿‍🦱 🦹🏿‍♂️", "👨‍🦰 👨🏿‍🦰 👨‍🦱 👨🏿‍🦱 🦹🏿‍♂️"), # emoji 2 (r"¯\_(ツ)_/¯", '¯\\_(ツ)_/¯'), # Japanese emoticon # noqa: W605 # # Special characters ("田中さんにあげて下さい", "田中さんにあげて下さい"), # two-byte characters ("表ポあA鷗ŒéB逍Üߪąñ丂㐀𠀀", "表ポあA鷗ŒéB逍Üߪąñ丂㐀𠀀"), # special unicode chars ("گچپژ", "گچپژ"), # Persian special characters # # Script injection ("", None), # script injection 1 ("<script>alert('1');</script>", None), ("", None), ], ) def test_safe_alt_text(alt_text_input, expected_alt_text): display_obj = nbscreenshot(Mock(), alt_text=alt_text_input) if not expected_alt_text: assert not display_obj.alt_text else: assert html.escape(display_obj.alt_text) == expected_alt_text def test_invalid_alt_text(): with pytest.warns(UserWarning): # because string with only whitespace messes up with the parser display_obj = nbscreenshot(Mock(), alt_text=" ") assert display_obj.alt_text is None with pytest.warns(UserWarning): # because string with only whitespace messes up with the parser display_obj = nbscreenshot(Mock(), alt_text="") assert display_obj.alt_text is None ������������������������������������������������������������������������������������������������������������������������������������������������������������������������������������������������������������������������������������������������������������������������������������������������napari-0.5.0a1/napari/_tests/test_numpy_like.py�����������������������������������������������������0000664�0000000�0000000�00000004532�14370413656�0021565�0����������������������������������������������������������������������������������������������������ustar�00root����������������������������root����������������������������0000000�0000000������������������������������������������������������������������������������������������������������������������������������������������������������������������������import dask.array as da import numpy as np import xarray as xr import zarr from napari.components.viewer_model import ViewerModel def test_dask_2D(): """Test adding 2D dask image.""" viewer = ViewerModel() da.random.seed(0) data = da.random.random((10, 15)) viewer.add_image(data) assert np.all(viewer.layers[0].data == data) def test_dask_nD(): """Test adding nD dask image.""" viewer = ViewerModel() da.random.seed(0) data = da.random.random((10, 15, 6, 16)) viewer.add_image(data) assert np.all(viewer.layers[0].data == data) def test_zarr_2D(): """Test adding 2D zarr image.""" viewer = ViewerModel() data = zarr.zeros((200, 100), chunks=(40, 20)) data[53:63, 10:20] = 1 # If passing a zarr file directly, must pass contrast_limits viewer.add_image(data, contrast_limits=[0, 1]) assert np.all(viewer.layers[0].data == data) def test_zarr_nD(): """Test adding nD zarr image.""" viewer = ViewerModel() data = zarr.zeros((200, 100, 50), chunks=(40, 20, 10)) data[53:63, 10:20, :] = 1 # If passing a zarr file directly, must pass contrast_limits viewer.add_image(data, contrast_limits=[0, 1]) assert np.all(viewer.layers[0].data == data) def test_zarr_dask_2D(): """Test adding 2D dask image.""" viewer = ViewerModel() data = zarr.zeros((200, 100), chunks=(40, 20)) data[53:63, 10:20] = 1 zdata = da.from_zarr(data) viewer.add_image(zdata) assert np.all(viewer.layers[0].data == zdata) def test_zarr_dask_nD(): """Test adding nD zarr image.""" viewer = ViewerModel() data = zarr.zeros((200, 100, 50), chunks=(40, 20, 10)) data[53:63, 10:20, :] = 1 zdata = da.from_zarr(data) viewer.add_image(zdata) assert np.all(viewer.layers[0].data == zdata) def test_xarray_2D(): """Test adding 2D xarray image.""" viewer = ViewerModel() np.random.seed(0) data = np.random.random((10, 15)) xdata = xr.DataArray(data, dims=['y', 'x']) viewer.add_image(data) assert np.all(viewer.layers[0].data == xdata) def test_xarray_nD(): """Test adding nD xarray image.""" viewer = ViewerModel() np.random.seed(0) data = np.random.random((10, 15, 6, 16)) xdata = xr.DataArray(data, dims=['t', 'z', 'y', 'x']) viewer.add_image(xdata) assert np.all(viewer.layers[0].data == xdata) ����������������������������������������������������������������������������������������������������������������������������������������������������������������������napari-0.5.0a1/napari/_tests/test_pytest_plugin.py��������������������������������������������������0000664�0000000�0000000�00000001713�14370413656�0022315�0����������������������������������������������������������������������������������������������������ustar�00root����������������������������root����������������������������0000000�0000000������������������������������������������������������������������������������������������������������������������������������������������������������������������������""" This module tests our "pytest plugin" made available in ``napari.utils._testsupport``. It's here in the top level `_tests` folder because it requires qt, and should be omitted from headless tests. """ import pytest pytest_plugins = "pytester" @pytest.mark.filterwarnings("ignore:`type` argument to addoption()::") @pytest.mark.filterwarnings("ignore:The TerminalReporter.writer::") def test_make_napari_viewer(testdir): """Make sure that our make_napari_viewer plugin works.""" # create a temporary pytest test file testdir.makepyfile( """ def test_make_viewer(make_napari_viewer): viewer = make_napari_viewer() assert viewer.layers == [] assert viewer.__class__.__name__ == 'Viewer' assert not viewer.window._qt_window.isVisible() """ ) # run all tests with pytest result = testdir.runpytest() # check that all 1 test passed result.assert_outcomes(passed=1) �����������������������������������������������������napari-0.5.0a1/napari/_tests/test_sys_info.py�������������������������������������������������������0000664�0000000�0000000�00000000714�14370413656�0021240�0����������������������������������������������������������������������������������������������������ustar�00root����������������������������root����������������������������0000000�0000000������������������������������������������������������������������������������������������������������������������������������������������������������������������������from napari.utils.info import sys_info # vispy use_app tries to start Qt, which can cause segfaults when running # sys_info on CI unless we provide a pytest Qt app def test_sys_info(qapp): str_info = sys_info() assert isinstance(str_info, str) assert '
' not in str_info assert '' not in str_info html_info = sys_info(as_html=True) assert isinstance(html_info, str) assert '
' in html_info assert '' in html_info napari-0.5.0a1/napari/_tests/test_top_level_availability.py000066400000000000000000000003331437041365600241270ustar00rootroot00000000000000import napari def test_top_level_availability(make_napari_viewer): """Current viewer should be available at napari.current_viewer.""" viewer = make_napari_viewer() assert viewer == napari.current_viewer() napari-0.5.0a1/napari/_tests/test_view_layers.py000066400000000000000000000162671437041365600217520ustar00rootroot00000000000000""" Ensure that layers and their convenience methods on the viewer have the same signatures and docstrings. """ import gc import inspect import re from unittest.mock import MagicMock, call import numpy as np import pytest from numpydoc.docscrape import ClassDoc, FunctionDoc import napari from napari import Viewer from napari import layers as module from napari._tests.utils import check_viewer_functioning, layer_test_data from napari.utils.misc import camel_to_snake layers = [] for name in dir(module): obj = getattr(module, name) if obj is module.Layer or not inspect.isclass(obj): continue if issubclass(obj, module.Layer): layers.append(obj) @pytest.mark.parametrize('layer', layers, ids=lambda layer: layer.__name__) def test_docstring(layer): name = layer.__name__ method_name = f'add_{camel_to_snake(name)}' method = getattr(Viewer, method_name) method_doc = FunctionDoc(method) layer_doc = ClassDoc(layer) # check summary section method_summary = ' '.join(method_doc['Summary']) # join multi-line summary summary_format = 'Add an? .+? layer to the layer list.' assert re.match( summary_format, method_summary ), f"improper 'Summary' section of '{method_name}'" # check parameters section method_params = method_doc['Parameters'] layer_params = layer_doc['Parameters'] # Remove path parameter from viewer method if it exists method_params = [m for m in method_params if m.name != 'path'] if name == 'Image': # For Image just test arguments that are in layer are in method named_method_params = [m.name for m in method_params] for layer_param in layer_params: l_name, l_type, l_description = layer_param assert l_name in named_method_params else: try: assert len(method_params) == len(layer_params) for method_param, layer_param in zip(method_params, layer_params): m_name, m_type, m_description = method_param l_name, l_type, l_description = layer_param # descriptions are treated as lists where each line is an # element m_description = ' '.join(m_description) l_description = ' '.join(l_description) assert m_name == l_name, 'different parameter names or order' assert ( m_type == l_type ), f"type mismatch of parameter '{m_name}'" assert ( m_description == l_description ), f"description mismatch of parameter '{m_name}'" except AssertionError as e: raise AssertionError( f"docstrings don't match for class {name}" ) from e # check returns section (method_returns,) = method_doc[ 'Returns' ] # only one thing should be returned description = ' '.join(method_returns[-1]) # join multi-line description method_returns = *method_returns[:-1], description if name == 'Image': assert method_returns == ( 'layer', f':class:`napari.layers.{name}` or list', f'The newly-created {name.lower()} layer or list of {name.lower()} layers.', # noqa: E501 ), f"improper 'Returns' section of '{method_name}'" else: assert method_returns == ( 'layer', f':class:`napari.layers.{name}`', f'The newly-created {name.lower()} layer.', ), f"improper 'Returns' section of '{method_name}'" @pytest.mark.parametrize('layer', layers, ids=lambda layer: layer.__name__) def test_signature(layer): name = layer.__name__ method = getattr(Viewer, f'add_{camel_to_snake(name)}') class_parameters = dict(inspect.signature(layer.__init__).parameters) method_parameters = dict(inspect.signature(method).parameters) fail_msg = f"signatures don't match for class {name}" if name == 'Image': # If Image just test that class params appear in method for class_param in class_parameters.keys(): assert class_param in method_parameters.keys(), fail_msg else: assert class_parameters == method_parameters, fail_msg # plugin_manager fixture is added to prevent errors due to installed plugins @pytest.mark.parametrize('layer_type, data, ndim', layer_test_data) def test_view(qtbot, napari_plugin_manager, layer_type, data, ndim): np.random.seed(0) viewer = getattr(napari, f'view_{layer_type.__name__.lower()}')( data, show=False ) view = viewer.window._qt_viewer check_viewer_functioning(viewer, view, data, ndim) viewer.close() # plugin_manager fixture is added to prevent errors due to installed plugins def test_view_multichannel(qtbot, napari_plugin_manager): """Test adding image.""" np.random.seed(0) data = np.random.random((15, 10, 5)) viewer = napari.view_image(data, channel_axis=-1, show=False) assert len(viewer.layers) == data.shape[-1] for i in range(data.shape[-1]): assert np.all(viewer.layers[i].data == data.take(i, axis=-1)) viewer.close() def test_kwargs_passed(monkeypatch): import napari.view_layers viewer_mock = MagicMock(napari.Viewer) monkeypatch.setattr(napari.view_layers, 'Viewer', viewer_mock) napari.view_path( path='some/path', title='my viewer', ndisplay=3, name='img name', scale=(1, 2, 3), ) assert viewer_mock.mock_calls == [ call(title='my viewer'), call().open(path='some/path', name='img name', scale=(1, 2, 3)), ] # plugin_manager fixture is added to prevent errors due to installed plugins def test_imshow(qtbot, napari_plugin_manager): shape = (10, 15) ndim = len(shape) np.random.seed(0) data = np.random.random(shape) viewer, layer = napari.imshow(data, channel_axis=None, show=False) view = viewer.window._qt_viewer check_viewer_functioning(viewer, view, data, ndim) assert isinstance(layer, napari.layers.Image) viewer.close() # plugin_manager fixture is added to prevent errors due to installed plugins def test_imshow_multichannel(qtbot, napari_plugin_manager): """Test adding image.""" np.random.seed(0) data = np.random.random((15, 10, 5)) viewer, layers = napari.imshow(data, channel_axis=-1, show=False) assert len(layers) == data.shape[-1] assert isinstance(layers, tuple) for i in range(data.shape[-1]): assert np.all(layers[i].data == data.take(i, axis=-1)) viewer.close() # Run a full garbage collection here so that any remaining viewer # and related instances are removed for future tests that may use # make_napari_viewer. gc.collect() # plugin_manager fixture is added to prevent errors due to installed plugins def test_imshow_with_viewer(qtbot, napari_plugin_manager, make_napari_viewer): shape = (10, 15) ndim = len(shape) np.random.seed(0) data = np.random.random(shape) viewer = make_napari_viewer() viewer2, layer = napari.imshow(data, viewer=viewer, show=False) assert viewer is viewer2 np.testing.assert_array_equal(data, layer.data) view = viewer.window._qt_viewer check_viewer_functioning(viewer, view, data, ndim) viewer.close() napari-0.5.0a1/napari/_tests/test_viewer.py000066400000000000000000000273611437041365600207170ustar00rootroot00000000000000import os import numpy as np import pytest from napari import Viewer, layers from napari._tests.utils import ( add_layer_by_type, check_view_transform_consistency, check_viewer_functioning, layer_test_data, skip_local_popups, skip_on_win_ci, ) from napari.settings import get_settings from napari.utils._tests.test_naming import eval_with_filename from napari.utils.action_manager import action_manager def _get_provider_actions(type_): actions = set() for superclass in type_.mro(): actions.update( action.command for action in action_manager._get_provider_actions( superclass ).values() ) return actions def _assert_shortcuts_exist_for_each_action(type_): actions = _get_provider_actions(type_) shortcuts = { name.partition(':')[-1] for name in get_settings().shortcuts.shortcuts } shortcuts.update(func.__name__ for func in type_.class_keymap.values()) for action in actions: assert ( action.__name__ in shortcuts ), f"missing shortcut for action '{action.__name__}' on '{type_.__name__}' is missing" viewer_actions = _get_provider_actions(Viewer) def test_all_viewer_actions_are_accessible_via_shortcut(make_napari_viewer): """ Make sure we do find all the actions attached to a viewer via keybindings """ # instantiate to make sure everything is initialized correctly _ = make_napari_viewer() _assert_shortcuts_exist_for_each_action(Viewer) @pytest.mark.xfail def test_non_existing_bindings(): """ Those are condition tested in next unittest; but do not exists; this is likely due to an oversight somewhere. """ assert 'play' in [func.__name__ for func in viewer_actions] assert 'toggle_fullscreen' in [func.__name__ for func in viewer_actions] @pytest.mark.parametrize('func', viewer_actions) def test_viewer_actions(make_napari_viewer, func): viewer = make_napari_viewer() if func.__name__ == 'toggle_fullscreen' and not os.getenv("CI"): pytest.skip("Fullscreen cannot be tested in CI") if func.__name__ == 'play': pytest.skip("Play cannot be tested with Pytest") func(viewer) def test_viewer(make_napari_viewer): """Test instantiating viewer.""" viewer = make_napari_viewer() view = viewer.window._qt_viewer assert viewer.title == 'napari' assert view.viewer == viewer assert len(viewer.layers) == 0 assert view.layers.model().rowCount() == 0 assert viewer.dims.ndim == 2 assert view.dims.nsliders == viewer.dims.ndim assert np.sum(view.dims._displayed_sliders) == 0 # Switch to 3D rendering mode and back to 2D rendering mode viewer.dims.ndisplay = 3 assert viewer.dims.ndisplay == 3 viewer.dims.ndisplay = 2 assert viewer.dims.ndisplay == 2 @pytest.mark.parametrize('layer_class, data, ndim', layer_test_data) def test_add_layer(make_napari_viewer, layer_class, data, ndim): viewer = make_napari_viewer() layer = add_layer_by_type(viewer, layer_class, data, visible=True) check_viewer_functioning(viewer, viewer.window._qt_viewer, data, ndim) for func in layer.class_keymap.values(): func(layer) layer_types = ( 'Image', 'Vectors', 'Surface', 'Tracks', 'Points', 'Labels', 'Shapes', ) @pytest.mark.parametrize('layer_class, data, ndim', layer_test_data) def test_all_layer_actions_are_accessible_via_shortcut( layer_class, data, ndim ): """ Make sure we do find all the actions attached to a layer via keybindings """ # instantiate to make sure everything is initialized correctly _ = layer_class(data) _assert_shortcuts_exist_for_each_action(layer_class) @pytest.mark.parametrize('layer_class, a_unique_name, ndim', layer_test_data) def test_add_layer_magic_name( make_napari_viewer, layer_class, a_unique_name, ndim ): """Test magic_name works when using add_* for layers""" # Tests for issue #1709 viewer = make_napari_viewer() # noqa: F841 layer = eval_with_filename( "add_layer_by_type(viewer, layer_class, a_unique_name)", "somefile.py", ) assert layer.name == "a_unique_name" @skip_on_win_ci def test_screenshot(make_napari_viewer, qtbot): """Test taking a screenshot.""" viewer = make_napari_viewer() np.random.seed(0) # Add image data = np.random.random((10, 15)) viewer.add_image(data) # Add labels data = np.random.randint(20, size=(10, 15)) viewer.add_labels(data) # Add points data = 20 * np.random.random((10, 2)) viewer.add_points(data) # Add vectors data = 20 * np.random.random((10, 2, 2)) viewer.add_vectors(data) # Add shapes data = 20 * np.random.random((10, 4, 2)) viewer.add_shapes(data) # Take screenshot of the image canvas only screenshot = viewer.screenshot(canvas_only=True, flash=False) assert screenshot.ndim == 3 # Take screenshot with the viewer included screenshot = viewer.screenshot(canvas_only=False, flash=False) assert screenshot.ndim == 3 # test size argument (and ensure it coerces to int) screenshot = viewer.screenshot(canvas_only=True, size=(20, 20.0)) assert screenshot.shape == (20, 20, 4) # Here we wait until the flash animation will be over. We cannot wait on finished # signal as _flash_animation may be already removed when calling wait. qtbot.waitUntil( lambda: not hasattr( viewer.window._qt_viewer._welcome_widget, '_flash_animation' ) ) @skip_on_win_ci def test_changing_theme(make_napari_viewer): """Test changing the theme updates the full window.""" viewer = make_napari_viewer(show=False) viewer.window._qt_viewer.set_welcome_visible(False) viewer.add_points(data=None) size = viewer.window._qt_viewer.size() viewer.window._qt_viewer.setFixedSize(size) assert viewer.theme == 'dark' screenshot_dark = viewer.screenshot(canvas_only=False, flash=False) viewer.theme = 'light' assert viewer.theme == 'light' screenshot_light = viewer.screenshot(canvas_only=False, flash=False) equal = (screenshot_dark == screenshot_light).min(-1) # more than 99.5% of the pixels have changed assert (np.count_nonzero(equal) / equal.size) < 0.05, "Themes too similar" with pytest.raises(ValueError): viewer.theme = 'nonexistent_theme' # TODO: revisit the need for sync_only here. # An async failure was observed here on CI, but was not reproduced locally @pytest.mark.sync_only @pytest.mark.parametrize('layer_class, data, ndim', layer_test_data) def test_roll_transpose_update(make_napari_viewer, layer_class, data, ndim): """Check that transpose and roll preserve correct transform sequence.""" viewer = make_napari_viewer() np.random.seed(0) layer = add_layer_by_type(viewer, layer_class, data) # Set translations and scalings (match type of visual layer storing): transf_dict = { 'translate': np.random.randint(0, 10, ndim).astype(np.float32), 'scale': np.random.rand(ndim).astype(np.float32), } for k, val in transf_dict.items(): setattr(layer, k, val) if layer_class in [layers.Image, layers.Labels]: transf_dict['translate'] -= transf_dict['scale'] / 2 # Check consistency: check_view_transform_consistency(layer, viewer, transf_dict) # Roll dims and check again: viewer.dims._roll() check_view_transform_consistency(layer, viewer, transf_dict) # Transpose and check again: viewer.dims.transpose() check_view_transform_consistency(layer, viewer, transf_dict) def test_toggling_axes(make_napari_viewer): """Test toggling axes.""" viewer = make_napari_viewer() # Check axes are not visible assert not viewer.axes.visible # Make axes visible viewer.axes.visible = True assert viewer.axes.visible # Enter 3D rendering and check axes still visible viewer.dims.ndisplay = 3 assert viewer.axes.visible # Make axes not visible viewer.axes.visible = False assert not viewer.axes.visible def test_toggling_scale_bar(make_napari_viewer): """Test toggling scale bar.""" viewer = make_napari_viewer() # Check scale bar is not visible assert not viewer.scale_bar.visible # Make scale bar visible viewer.scale_bar.visible = True assert viewer.scale_bar.visible # Enter 3D rendering and check scale bar is still visible viewer.dims.ndisplay = 3 assert viewer.scale_bar.visible # Make scale bar not visible viewer.scale_bar.visible = False assert not viewer.scale_bar.visible def test_removing_points_data(make_napari_viewer): viewer = make_napari_viewer() points = np.random.random((4, 2)) * 4 pts_layer = viewer.add_points(points) pts_layer.data = np.zeros([0, 2]) assert len(pts_layer.data) == 0 def test_deleting_points(make_napari_viewer): viewer = make_napari_viewer() points = np.random.random((4, 2)) * 4 pts_layer = viewer.add_points(points) pts_layer.selected_data = {0} pts_layer.remove_selected() assert len(pts_layer.data) == 3 @skip_on_win_ci @skip_local_popups def test_custom_layer(make_napari_viewer): """Make sure that custom layers subclasses can be added to the viewer.""" class NewLabels(layers.Labels): """'Empty' extension of napari Labels layer.""" # Make a viewer and add the custom layer viewer = make_napari_viewer(show=True) viewer.add_layer(NewLabels(np.zeros((10, 10, 10), dtype=np.uint8))) def test_emitting_data_doesnt_change_points_value(make_napari_viewer): """Test emitting data with no change doesn't change the layer _value.""" viewer = make_napari_viewer() data = np.array([[0, 0], [10, 10], [20, 20]]) layer = viewer.add_points(data, size=2) viewer.layers.selection.active = layer assert layer._value is None viewer.mouse_over_canvas = True viewer.cursor.position = tuple(layer.data[1]) assert layer._value == 1 layer.events.data(value=layer.data) assert layer._value == 1 @pytest.mark.parametrize('layer_class, data, ndim', layer_test_data) def test_emitting_data_doesnt_change_cursor_position( make_napari_viewer, layer_class, data, ndim ): """Test emitting data event from layer doesn't change cursor position""" viewer = make_napari_viewer() layer = layer_class(data) viewer.add_layer(layer) new_position = (5,) * ndim viewer.cursor.position = new_position layer.events.data(value=layer.data) assert viewer.cursor.position == new_position @skip_local_popups @skip_on_win_ci def test_empty_shapes_dims(make_napari_viewer): """make sure an empty shapes layer can render in 3D""" viewer = make_napari_viewer(show=True) viewer.add_shapes(None) viewer.dims.ndisplay = 3 def test_current_viewer(make_napari_viewer): """Test that the viewer made last is the "current_viewer()" until another is activated""" # Make two DIFFERENT viewers viewer1: Viewer = make_napari_viewer() viewer2: Viewer = make_napari_viewer() assert viewer2 is not viewer1 # Ensure one is returned by napari.current_viewer() from napari import current_viewer assert current_viewer() is viewer2 assert current_viewer() is not viewer1 viewer1.window.activate() assert current_viewer() is viewer1 assert current_viewer() is not viewer2 def test_reset_empty(make_napari_viewer): """ Test that resetting an empty viewer doesn't crash https://github.com/napari/napari/issues/4867 """ viewer = make_napari_viewer() viewer.reset() def test_reset_non_empty(make_napari_viewer): """ Test that resetting a non-empty viewer doesn't crash https://github.com/napari/napari/issues/4867 """ viewer = make_napari_viewer() viewer.add_points([(0, 1), (2, 3)]) viewer.reset() napari-0.5.0a1/napari/_tests/test_viewer_layer_parity.py000066400000000000000000000020701437041365600234710ustar00rootroot00000000000000""" Ensure that layers and their convenience methods on the viewer have the same signatures. """ import inspect from napari import Viewer from napari.view_layers import imshow def test_imshow_signature_consistency(): # Collect the signatures for imshow and the associated Viewer methods viewer_parameters = { **inspect.signature(Viewer.__init__).parameters, **inspect.signature(Viewer.add_image).parameters, } imshow_parameters = dict(inspect.signature(imshow).parameters) # Remove unique parameters del imshow_parameters['viewer'] del viewer_parameters['self'] # Ensure both have the same parameter names assert imshow_parameters.keys() == viewer_parameters.keys() # Ensure the parameters have the same defaults for name, parameter in viewer_parameters.items(): # data is a required for imshow, but optional for add_image if name == 'data': continue fail_msg = f'Signature mismatch on {parameter}' assert imshow_parameters[name].default == parameter.default, fail_msg napari-0.5.0a1/napari/_tests/test_with_screenshot.py000066400000000000000000000427541437041365600226310ustar00rootroot00000000000000import collections import numpy as np import pytest from napari._tests.utils import skip_local_popups, skip_on_win_ci from napari.utils._proxies import ReadOnlyWrapper from napari.utils.interactions import ( mouse_move_callbacks, mouse_press_callbacks, mouse_release_callbacks, ) @skip_on_win_ci @skip_local_popups def test_z_order_adding_removing_images(make_napari_viewer): """Test z order is correct after adding/ removing images.""" data = np.ones((11, 11)) viewer = make_napari_viewer(show=True) viewer.add_image(data, colormap='red', name='red') viewer.add_image(data, colormap='green', name='green') viewer.add_image(data, colormap='blue', name='blue') # Check that blue is visible screenshot = viewer.screenshot(canvas_only=True, flash=False) center = tuple(np.round(np.divide(screenshot.shape[:2], 2)).astype(int)) np.testing.assert_almost_equal(screenshot[center], [0, 0, 255, 255]) # Remove and re-add image viewer.layers.remove('red') viewer.add_image(data, colormap='red', name='red') # Check that red is visible screenshot = viewer.screenshot(canvas_only=True, flash=False) np.testing.assert_almost_equal(screenshot[center], [255, 0, 0, 255]) # Remove two other images viewer.layers.remove('green') viewer.layers.remove('blue') # Check that red is still visible screenshot = viewer.screenshot(canvas_only=True, flash=False) np.testing.assert_almost_equal(screenshot[center], [255, 0, 0, 255]) # Add two other layers back viewer.add_image(data, colormap='green', name='green') viewer.add_image(data, colormap='blue', name='blue') # Check that blue is visible screenshot = viewer.screenshot(canvas_only=True, flash=False) np.testing.assert_almost_equal(screenshot[center], [0, 0, 255, 255]) # Hide blue viewer.layers['blue'].visible = False # Check that green is visible. Note this assert was failing before #1463 screenshot = viewer.screenshot(canvas_only=True, flash=False) np.testing.assert_almost_equal(screenshot[center], [0, 255, 0, 255]) @skip_on_win_ci @skip_local_popups def test_z_order_images(make_napari_viewer): """Test changing order of images changes z order in display.""" data = np.ones((11, 11)) viewer = make_napari_viewer(show=True) viewer.add_image(data, colormap='red') viewer.add_image(data, colormap='blue') screenshot = viewer.screenshot(canvas_only=True, flash=False) center = tuple(np.round(np.divide(screenshot.shape[:2], 2)).astype(int)) # Check that blue is visible np.testing.assert_almost_equal(screenshot[center], [0, 0, 255, 255]) viewer.layers.move(1, 0) screenshot = viewer.screenshot(canvas_only=True, flash=False) center = tuple(np.round(np.divide(screenshot.shape[:2], 2)).astype(int)) # Check that red is now visible np.testing.assert_almost_equal(screenshot[center], [255, 0, 0, 255]) @skip_on_win_ci @skip_local_popups def test_z_order_image_points(make_napari_viewer): """Test changing order of image and points changes z order in display.""" data = np.ones((11, 11)) viewer = make_napari_viewer(show=True) viewer.add_image(data, colormap='red') viewer.add_points([5, 5], face_color='blue', size=10) screenshot = viewer.screenshot(canvas_only=True, flash=False) center = tuple(np.round(np.divide(screenshot.shape[:2], 2)).astype(int)) # Check that blue is visible np.testing.assert_almost_equal(screenshot[center], [0, 0, 255, 255]) viewer.layers.move(1, 0) screenshot = viewer.screenshot(canvas_only=True, flash=False) center = tuple(np.round(np.divide(screenshot.shape[:2], 2)).astype(int)) # Check that red is now visible np.testing.assert_almost_equal(screenshot[center], [255, 0, 0, 255]) @skip_on_win_ci @skip_local_popups def test_z_order_images_after_ndisplay(make_napari_viewer): """Test z order of images remanins constant after chaning ndisplay.""" data = np.ones((11, 11)) viewer = make_napari_viewer(show=True) viewer.add_image(data, colormap='red') viewer.add_image(data, colormap='blue') screenshot = viewer.screenshot(canvas_only=True, flash=False) center = tuple(np.round(np.divide(screenshot.shape[:2], 2)).astype(int)) # Check that blue is visible np.testing.assert_almost_equal(screenshot[center], [0, 0, 255, 255]) # Switch to 3D rendering viewer.dims.ndisplay = 3 screenshot = viewer.screenshot(canvas_only=True, flash=False) center = tuple(np.round(np.divide(screenshot.shape[:2], 2)).astype(int)) # Check that blue is still visible np.testing.assert_almost_equal(screenshot[center], [0, 0, 255, 255]) # Switch back to 2D rendering viewer.dims.ndisplay = 2 screenshot = viewer.screenshot(canvas_only=True, flash=False) center = tuple(np.round(np.divide(screenshot.shape[:2], 2)).astype(int)) # Check that blue is still visible np.testing.assert_almost_equal(screenshot[center], [0, 0, 255, 255]) @pytest.mark.skip('Image is 1 pixel thick in 3D, so point is swallowed') @skip_on_win_ci @skip_local_popups def test_z_order_image_points_after_ndisplay(make_napari_viewer): """Test z order of image and points remanins constant after chaning ndisplay.""" data = np.ones((11, 11)) viewer = make_napari_viewer(show=True) viewer.add_image(data, colormap='red') viewer.add_points([5, 5], face_color='blue', size=5) screenshot = viewer.screenshot(canvas_only=True, flash=False) center = tuple(np.round(np.divide(screenshot.shape[:2], 2)).astype(int)) # Check that blue is visible np.testing.assert_almost_equal(screenshot[center], [0, 0, 255, 255]) # Switch to 3D rendering viewer.dims.ndisplay = 3 screenshot = viewer.screenshot(canvas_only=True, flash=False) center = tuple(np.round(np.divide(screenshot.shape[:2], 2)).astype(int)) # Check that blue is still visible np.testing.assert_almost_equal(screenshot[center], [0, 0, 255, 255]) # Switch back to 2D rendering viewer.dims.ndisplay = 2 screenshot = viewer.screenshot(canvas_only=True, flash=False) center = tuple(np.round(np.divide(screenshot.shape[:2], 2)).astype(int)) # Check that blue is still visible np.testing.assert_almost_equal(screenshot[center], [0, 0, 255, 255]) @skip_on_win_ci @skip_local_popups def test_changing_image_colormap(make_napari_viewer): """Test changing colormap changes rendering.""" viewer = make_napari_viewer(show=True) data = np.ones((20, 20, 20)) layer = viewer.add_image(data, contrast_limits=[0, 1]) screenshot = viewer.screenshot(canvas_only=True, flash=False) center = tuple(np.round(np.divide(screenshot.shape[:2], 2)).astype(int)) np.testing.assert_almost_equal(screenshot[center], [255, 255, 255, 255]) layer.colormap = 'red' screenshot = viewer.screenshot(canvas_only=True, flash=False) np.testing.assert_almost_equal(screenshot[center], [255, 0, 0, 255]) viewer.dims.ndisplay = 3 screenshot = viewer.screenshot(canvas_only=True, flash=False) np.testing.assert_almost_equal(screenshot[center], [255, 0, 0, 255]) layer.colormap = 'blue' screenshot = viewer.screenshot(canvas_only=True, flash=False) np.testing.assert_almost_equal(screenshot[center], [0, 0, 255, 255]) viewer.dims.ndisplay = 2 screenshot = viewer.screenshot(canvas_only=True, flash=False) np.testing.assert_almost_equal(screenshot[center], [0, 0, 255, 255]) @skip_on_win_ci @skip_local_popups def test_changing_image_gamma(make_napari_viewer): """Test changing gamma changes rendering.""" viewer = make_napari_viewer(show=True) data = np.ones((20, 20, 20)) layer = viewer.add_image(data, contrast_limits=[0, 2]) screenshot = viewer.screenshot(canvas_only=True, flash=False) center = tuple(np.round(np.divide(screenshot.shape[:2], 2)).astype(int)) assert 127 <= screenshot[center + (0,)] <= 129 layer.gamma = 0.1 screenshot = viewer.screenshot(canvas_only=True, flash=False) assert screenshot[center + (0,)] > 230 viewer.dims.ndisplay = 3 screenshot = viewer.screenshot(canvas_only=True, flash=False) assert screenshot[center + (0,)] > 230 layer.gamma = 1.9 screenshot = viewer.screenshot(canvas_only=True, flash=False) assert screenshot[center + (0,)] < 80 viewer.dims.ndisplay = 2 screenshot = viewer.screenshot(canvas_only=True, flash=False) assert screenshot[center + (0,)] < 80 @skip_on_win_ci @skip_local_popups def test_grid_mode(make_napari_viewer): """Test changing gamma changes rendering.""" viewer = make_napari_viewer(show=True) # Add images data = np.ones((6, 15, 15)) viewer.add_image(data, channel_axis=0, blending='translucent') assert not viewer.grid.enabled assert viewer.grid.actual_shape(6) == (1, 1) assert viewer.grid.stride == 1 translations = [layer._translate_grid for layer in viewer.layers] expected_translations = np.zeros((6, 2)) np.testing.assert_allclose(translations, expected_translations) # check screenshot screenshot = viewer.screenshot(canvas_only=True, flash=False) center = tuple(np.round(np.divide(screenshot.shape[:2], 2)).astype(int)) np.testing.assert_almost_equal(screenshot[center], [0, 0, 255, 255]) # enter grid view viewer.grid.enabled = True assert viewer.grid.enabled assert viewer.grid.actual_shape(6) == (2, 3) assert viewer.grid.stride == 1 translations = [layer._translate_grid for layer in viewer.layers] expected_translations = [ [0, 0], [0, 15], [0, 30], [15, 0], [15, 15], [15, 30], ] np.testing.assert_allclose(translations, expected_translations[::-1]) # check screenshot screenshot = viewer.screenshot(canvas_only=True, flash=False) # sample 6 squares of the grid and check they have right colors pos = [ (1 / 3, 1 / 4), (1 / 3, 1 / 2), (1 / 3, 3 / 4), (2 / 3, 1 / 4), (2 / 3, 1 / 2), (2 / 3, 3 / 4), ] # BGRMYC color order color = [ [0, 0, 255, 255], [0, 255, 0, 255], [255, 0, 0, 255], [255, 0, 255, 255], [255, 255, 0, 255], [0, 255, 255, 255], ] for c, p in zip(color, pos): coord = tuple( np.round(np.multiply(screenshot.shape[:2], p)).astype(int) ) np.testing.assert_almost_equal(screenshot[coord], c) # reorder layers, swapping 0 and 5 viewer.layers.move(5, 0) viewer.layers.move(1, 6) # check screenshot screenshot = viewer.screenshot(canvas_only=True, flash=False) # CGRMYB color order color = [ [0, 255, 255, 255], [0, 255, 0, 255], [255, 0, 0, 255], [255, 0, 255, 255], [255, 255, 0, 255], [0, 0, 255, 255], ] for c, p in zip(color, pos): coord = tuple( np.round(np.multiply(screenshot.shape[:2], p)).astype(int) ) np.testing.assert_almost_equal(screenshot[coord], c) # return to stack view viewer.grid.enabled = False assert not viewer.grid.enabled assert viewer.grid.actual_shape(6) == (1, 1) assert viewer.grid.stride == 1 translations = [layer._translate_grid for layer in viewer.layers] expected_translations = np.zeros((6, 2)) np.testing.assert_allclose(translations, expected_translations) # check screenshot screenshot = viewer.screenshot(canvas_only=True, flash=False) center = tuple(np.round(np.divide(screenshot.shape[:2], 2)).astype(int)) np.testing.assert_almost_equal(screenshot[center], [0, 255, 255, 255]) @skip_on_win_ci @skip_local_popups def test_changing_image_attenuation(make_napari_viewer): """Test changing attenuation value changes rendering.""" data = np.zeros((100, 10, 10)) data[-1] = 1 viewer = make_napari_viewer(show=True) viewer.dims.ndisplay = 3 viewer.add_image(data, contrast_limits=[0, 1]) # normal mip viewer.layers[0].rendering = 'mip' screenshot = viewer.screenshot(canvas_only=True, flash=False) center = tuple(np.round(np.divide(screenshot.shape[:2], 2)).astype(int)) mip_value = screenshot[center][0] # zero attenuation (still attenuated!) viewer.layers[0].rendering = 'attenuated_mip' viewer.layers[0].attenuation = 0.0 screenshot = viewer.screenshot(canvas_only=True, flash=False) zero_att_value = screenshot[center][0] # increase attenuation viewer.layers[0].attenuation = 0.5 screenshot = viewer.screenshot(canvas_only=True, flash=False) more_att_value = screenshot[center][0] # Check that rendering has been attenuated assert zero_att_value < more_att_value < mip_value @skip_on_win_ci @skip_local_popups def test_labels_painting(make_napari_viewer): """Test painting labels updates image.""" data = np.zeros((100, 100), dtype=np.int32) viewer = make_napari_viewer(show=True) viewer.add_labels(data) layer = viewer.layers[0] screenshot = viewer.screenshot(canvas_only=True, flash=False) # Check that no painting has occurred assert layer.data.max() == 0 assert screenshot[:, :, :2].max() == 0 # Enter paint mode viewer.cursor.position = (0, 0) layer.mode = 'paint' layer.selected_label = 3 # Simulate click Event = collections.namedtuple( 'Event', field_names=['type', 'is_dragging', 'position'] ) # Simulate click event = ReadOnlyWrapper( Event( type='mouse_press', is_dragging=False, position=viewer.cursor.position, ) ) mouse_press_callbacks(layer, event) viewer.cursor.position = (100, 100) # Simulate drag event = ReadOnlyWrapper( Event( type='mouse_move', is_dragging=True, position=viewer.cursor.position, ) ) mouse_move_callbacks(layer, event) # Simulate release event = ReadOnlyWrapper( Event( type='mouse_release', is_dragging=False, position=viewer.cursor.position, ) ) mouse_release_callbacks(layer, event) event = ReadOnlyWrapper( Event( type='mouse_press', is_dragging=False, position=viewer.cursor.position, ) ) mouse_press_callbacks(layer, event) screenshot = viewer.screenshot(canvas_only=True, flash=False) # Check that painting has now occurred assert layer.data.max() > 0 assert screenshot[:, :, :2].max() > 0 @pytest.mark.skip("Welcome visual temporarily disabled") @skip_on_win_ci @skip_local_popups def test_welcome(make_napari_viewer): """Test that something visible on launch.""" viewer = make_napari_viewer(show=True) # Check something is visible screenshot = viewer.screenshot(canvas_only=True, flash=False) assert len(viewer.layers) == 0 assert screenshot[..., :-1].max() > 0 # Check adding zeros image makes it go away viewer.add_image(np.zeros((1, 1))) screenshot = viewer.screenshot(canvas_only=True, flash=False) assert len(viewer.layers) == 1 assert screenshot[..., :-1].max() == 0 # Remove layer and check something is visible again viewer.layers.pop(0) screenshot = viewer.screenshot(canvas_only=True, flash=False) assert len(viewer.layers) == 0 assert screenshot[..., :-1].max() > 0 @skip_on_win_ci @skip_local_popups def test_axes_visible(make_napari_viewer): """Test that something appears when axes become visible.""" viewer = make_napari_viewer(show=True) viewer.window._qt_viewer.set_welcome_visible(False) # Check axes are not visible launch_screenshot = viewer.screenshot(canvas_only=True, flash=False) assert not viewer.axes.visible # Make axes visible and check something is seen viewer.axes.visible = True on_screenshot = viewer.screenshot(canvas_only=True, flash=False) assert viewer.axes.visible assert abs(on_screenshot - launch_screenshot).max() > 0 # Make axes not visible and check they are gone viewer.axes.visible = False off_screenshot = viewer.screenshot(canvas_only=True, flash=False) assert not viewer.axes.visible np.testing.assert_almost_equal(launch_screenshot, off_screenshot) @skip_on_win_ci @skip_local_popups def test_scale_bar_visible(make_napari_viewer): """Test that something appears when scale bar becomes visible.""" viewer = make_napari_viewer(show=True) viewer.window._qt_viewer.set_welcome_visible(False) # Check scale bar is not visible launch_screenshot = viewer.screenshot(canvas_only=True, flash=False) assert not viewer.scale_bar.visible # Make scale bar visible and check something is seen viewer.scale_bar.visible = True on_screenshot = viewer.screenshot(canvas_only=True, flash=False) assert viewer.scale_bar.visible assert abs(on_screenshot - launch_screenshot).max() > 0 # Make scale bar not visible and check it is gone viewer.scale_bar.visible = False off_screenshot = viewer.screenshot(canvas_only=True, flash=False) assert not viewer.scale_bar.visible np.testing.assert_almost_equal(launch_screenshot, off_screenshot) @skip_on_win_ci @skip_local_popups def test_screenshot_has_no_border(make_napari_viewer): """See https://github.com/napari/napari/issues/3357""" viewer = make_napari_viewer(show=True) image_data = np.ones((60, 80)) viewer.add_image(image_data, colormap='red') # Zoom in dramatically to make the screenshot all red. viewer.camera.zoom = 1000 screenshot = viewer.screenshot(canvas_only=True, flash=False) expected = np.broadcast_to([255, 0, 0, 255], screenshot.shape) np.testing.assert_array_equal(screenshot, expected) napari-0.5.0a1/napari/_tests/utils.py000066400000000000000000000222501437041365600175070ustar00rootroot00000000000000import os import sys from collections import abc from typing import Any, Dict import numpy as np import pandas as pd import pytest from napari import Viewer from napari.layers import ( Image, Labels, Points, Shapes, Surface, Tracks, Vectors, ) from napari.utils.color import ColorArray skip_on_win_ci = pytest.mark.skipif( sys.platform.startswith('win') and os.getenv('CI', '0') != '0', reason='Screenshot tests are not supported on windows CI.', ) skip_local_popups = pytest.mark.skipif( not os.getenv('CI') and os.getenv('NAPARI_POPUP_TESTS', '0') == '0', reason='Tests requiring GUI windows are skipped locally by default.' ' Set NAPARI_POPUP_TESTS=1 environment variable to enable.', ) """ The default timeout duration in seconds when waiting on tasks running in non-main threads. The value was chosen to be consistent with `QtBot.waitSignal` and `QtBot.waitUntil`. """ DEFAULT_TIMEOUT_SECS: float = 5 """ Used as pytest params for testing layer add and view functionality (Layer class, data, ndim) """ layer_test_data = [ (Image, np.random.random((10, 15)), 2), (Image, np.random.random((10, 15, 20)), 3), (Image, np.random.random((5, 10, 15, 20)), 4), (Image, [np.random.random(s) for s in [(40, 20), (20, 10), (10, 5)]], 2), (Image, np.array([[1.5, np.nan], [np.inf, 2.2]]), 2), (Labels, np.random.randint(20, size=(10, 15)), 2), (Labels, np.zeros((10, 10), dtype=bool), 2), (Labels, np.random.randint(20, size=(6, 10, 15)), 3), ( Labels, [np.random.randint(20, size=s) for s in [(40, 20), (20, 10), (10, 5)]], 2, ), (Points, 20 * np.random.random((10, 2)), 2), (Points, 20 * np.random.random((10, 3)), 3), (Vectors, 20 * np.random.random((10, 2, 2)), 2), (Shapes, 20 * np.random.random((10, 4, 2)), 2), ( Surface, ( 20 * np.random.random((10, 3)), np.random.randint(10, size=(6, 3)), np.random.random(10), ), 3, ), ( Tracks, np.column_stack( (np.ones(20), np.arange(20), 20 * np.random.random((20, 2))) ), 3, ), ( Tracks, np.column_stack( (np.ones(20), np.arange(20), 20 * np.random.random((20, 3))) ), 4, ), ] try: import tensorstore as ts m = ts.array(np.random.random((10, 15))) p = [ts.array(np.random.random(s)) for s in [(40, 20), (20, 10), (10, 5)]] layer_test_data.extend([(Image, m, 2), (Image, p, 2)]) except ImportError: pass classes = [Labels, Points, Vectors, Shapes, Surface, Tracks, Image] names = [cls.__name__.lower() for cls in classes] layer2addmethod = { cls: getattr(Viewer, 'add_' + name) for cls, name in zip(classes, names) } # examples of valid tuples that might be passed to viewer._add_layer_from_data good_layer_data = [ (np.random.random((10, 10)),), (np.random.random((10, 10, 3)), {'rgb': True}), (np.random.randint(20, size=(10, 15)), {'seed': 0.3}, 'labels'), (np.random.random((10, 2)) * 20, {'face_color': 'blue'}, 'points'), (np.random.random((10, 2, 2)) * 20, {}, 'vectors'), (np.random.random((10, 4, 2)) * 20, {'opacity': 1}, 'shapes'), ( ( np.random.random((10, 3)), np.random.randint(10, size=(6, 3)), np.random.random(10), ), {'name': 'some surface'}, 'surface', ), ] def add_layer_by_type(viewer, layer_type, data, visible=True): """ Convenience method that maps a LayerType to its add_layer method. Parameters ---------- layer_type : LayerTypes Layer type to add data The layer data to view """ return layer2addmethod[layer_type](viewer, data, visible=visible) def are_objects_equal(object1, object2): """ compare two (collections of) arrays or other objects for equality. Ignores nan. """ if isinstance(object1, abc.Sequence): items = zip(object1, object2) elif isinstance(object1, dict): items = [(value, object2[key]) for key, value in object1.items()] else: items = [(object1, object2)] # equal_nan does not exist in array_equal in old numpy npy_major_version = tuple(int(v) for v in np.__version__.split('.')[:2]) if npy_major_version < (1, 19): fixed = [(np.nan_to_num(a1), np.nan_to_num(a2)) for a1, a2 in items] return np.all([np.all(a1 == a2) for a1, a2 in fixed]) try: return np.all( [np.array_equal(a1, a2, equal_nan=True) for a1, a2 in items] ) except TypeError: # np.array_equal fails for arrays of type `object` (e.g: strings) return np.all([a1 == a2 for a1, a2 in items]) def check_viewer_functioning(viewer, view=None, data=None, ndim=2): viewer.dims.ndisplay = 2 # if multiscale or composite data (surface), check one by one assert are_objects_equal(viewer.layers[0].data, data) assert len(viewer.layers) == 1 assert view.layers.model().rowCount() == len(viewer.layers) assert viewer.dims.ndim == ndim assert view.dims.nsliders == viewer.dims.ndim assert np.sum(view.dims._displayed_sliders) == ndim - 2 # Switch to 3D rendering mode and back to 2D rendering mode viewer.dims.ndisplay = 3 assert viewer.dims.ndisplay == 3 # Flip dims order displayed dims_order = list(range(ndim)) viewer.dims.order = dims_order assert viewer.dims.order == tuple(dims_order) # Flip dims order including non-displayed dims_order[0], dims_order[-1] = dims_order[-1], dims_order[0] viewer.dims.order = dims_order assert viewer.dims.order == tuple(dims_order) viewer.dims.ndisplay = 2 assert viewer.dims.ndisplay == 2 def check_view_transform_consistency(layer, viewer, transf_dict): """Check layer transforms have been applied to the view. Note this check only works for non-multiscale data. Parameters ---------- layer : napari.layers.Layer Layer model. viewer : napari.Viewer Viewer, including Qt elements transf_dict : dict Dictionary of transform properties with keys referring to the name of the transform property (i.e. `scale`, `translate`) and the value corresponding to the array of property values """ if layer.multiscale: return None # Get an handle on visual layer: vis_lyr = viewer.window._qt_viewer.layer_to_visual[layer] # Visual layer attributes should match expected from viewer dims: for transf_name, transf in transf_dict.items(): disp_dims = list(viewer.dims.displayed) # dimensions displayed in 2D # values of visual layer vis_vals = getattr(vis_lyr, transf_name)[1::-1] np.testing.assert_almost_equal(vis_vals, transf[disp_dims]) def check_layer_world_data_extent( layer, extent, scale, translate, pixels=False ): """Test extents after applying transforms. Parameters ---------- layer : napari.layers.Layer Layer to be tested. extent : array, shape (2, D) Extent of data in layer. scale : array, shape (D,) Scale to be applied to layer. translate : array, shape (D,) Translation to be applied to layer. """ np.testing.assert_almost_equal(layer.extent.data, extent) world_extent = extent - 0.5 if pixels else extent np.testing.assert_almost_equal(layer.extent.world, world_extent) # Apply scale transformation layer.scale = scale scaled_world_extent = np.multiply(world_extent, scale) np.testing.assert_almost_equal(layer.extent.data, extent) np.testing.assert_almost_equal(layer.extent.world, scaled_world_extent) # Apply translation transformation layer.translate = translate translated_world_extent = np.add(scaled_world_extent, translate) np.testing.assert_almost_equal(layer.extent.data, extent) np.testing.assert_almost_equal(layer.extent.world, translated_world_extent) def assert_layer_state_equal( actual: Dict[str, Any], expected: Dict[str, Any] ) -> None: """Asserts that an layer state dictionary is equal to an expected one. This is useful because some members of state may array-like whereas others maybe dataframe-like, which need to be checked for equality differently. """ assert actual.keys() == expected.keys() for name in actual: actual_value = actual[name] expected_value = expected[name] if isinstance(actual_value, pd.DataFrame): pd.testing.assert_frame_equal(actual_value, expected_value) else: np.testing.assert_equal(actual_value, expected_value) def assert_colors_equal(actual, expected): """Asserts that a sequence of colors is equal to an expected one. This converts elements in the given sequences from color values recognized by ``transform_color`` to the canonical RGBA array form. Examples -------- >>> assert_colors_equal([[1, 0, 0, 1], [0, 0, 1, 1]], ['red', 'blue']) >>> assert_colors_equal([[1, 0, 0, 1], [0, 0, 1, 1]], ['red', 'green']) Traceback (most recent call last): AssertionError: ... """ actual_array = ColorArray.validate(actual) expected_array = ColorArray.validate(expected) np.testing.assert_array_equal(actual_array, expected_array) napari-0.5.0a1/napari/_vendor/000077500000000000000000000000001437041365600161275ustar00rootroot00000000000000napari-0.5.0a1/napari/_vendor/__init__.py000066400000000000000000000000001437041365600202260ustar00rootroot00000000000000napari-0.5.0a1/napari/_vendor/darkdetect/000077500000000000000000000000001437041365600202415ustar00rootroot00000000000000napari-0.5.0a1/napari/_vendor/darkdetect/LICENSE000066400000000000000000000027271437041365600212560ustar00rootroot00000000000000Copyright (c) 2019, Alberto Sottile All rights reserved. Redistribution and use in source and binary forms, with or without modification, are permitted provided that the following conditions are met: * Redistributions of source code must retain the above copyright notice, this list of conditions and the following disclaimer. * Redistributions in binary form must reproduce the above copyright notice, this list of conditions and the following disclaimer in the documentation and/or other materials provided with the distribution. * Neither the name of "darkdetect" nor the names of its contributors may be used to endorse or promote products derived from this software without specific prior written permission. THIS SOFTWARE IS PROVIDED BY THE COPYRIGHT HOLDERS AND CONTRIBUTORS "AS IS" AND ANY EXPRESS OR IMPLIED WARRANTIES, INCLUDING, BUT NOT LIMITED TO, THE IMPLIED WARRANTIES OF MERCHANTABILITY AND FITNESS FOR A PARTICULAR PURPOSE ARE DISCLAIMED. IN NO EVENT SHALL "Alberto Sottile" BE LIABLE FOR ANY DIRECT, INDIRECT, INCIDENTAL, SPECIAL, EXEMPLARY, OR CONSEQUENTIAL DAMAGES (INCLUDING, BUT NOT LIMITED TO, PROCUREMENT OF SUBSTITUTE GOODS OR SERVICES; LOSS OF USE, DATA, OR PROFITS; OR BUSINESS INTERRUPTION) HOWEVER CAUSED AND ON ANY THEORY OF LIABILITY, WHETHER IN CONTRACT, STRICT LIABILITY, OR TORT (INCLUDING NEGLIGENCE OR OTHERWISE) ARISING IN ANY WAY OUT OF THE USE OF THIS SOFTWARE, EVEN IF ADVISED OF THE POSSIBILITY OF SUCH DAMAGE. napari-0.5.0a1/napari/_vendor/darkdetect/__init__.py000066400000000000000000000026041437041365600223540ustar00rootroot00000000000000#----------------------------------------------------------------------------- # Copyright (C) 2019 Alberto Sottile # # Distributed under the terms of the 3-clause BSD License. #----------------------------------------------------------------------------- __version__ = '0.8.0' import sys import platform def macos_supported_version(): sysver = platform.mac_ver()[0] #typically 10.14.2 or 12.3 major = int(sysver.split('.')[0]) if major < 10: return False elif major >= 11: return True else: minor = int(sysver.split('.')[1]) if minor < 14: return False else: return True if sys.platform == "darwin": if macos_supported_version(): from ._mac_detect import * else: from ._dummy import * elif sys.platform == "win32" and platform.release().isdigit() and int(platform.release()) >= 10: # Checks if running Windows 10 version 10.0.14393 (Anniversary Update) OR HIGHER. The getwindowsversion method returns a tuple. # The third item is the build number that we can use to check if the user has a new enough version of Windows. winver = int(platform.version().split('.')[2]) if winver >= 14393: from ._windows_detect import * else: from ._dummy import * elif sys.platform == "linux": from ._linux_detect import * else: from ._dummy import * del sys, platform napari-0.5.0a1/napari/_vendor/darkdetect/__main__.py000066400000000000000000000005141437041365600223330ustar00rootroot00000000000000#----------------------------------------------------------------------------- # Copyright (C) 2019 Alberto Sottile # # Distributed under the terms of the 3-clause BSD License. #----------------------------------------------------------------------------- import darkdetect print('Current theme: {}'.format(darkdetect.theme())) napari-0.5.0a1/napari/_vendor/darkdetect/_dummy.py000066400000000000000000000007311437041365600221060ustar00rootroot00000000000000#----------------------------------------------------------------------------- # Copyright (C) 2019 Alberto Sottile # # Distributed under the terms of the 3-clause BSD License. #----------------------------------------------------------------------------- import typing def theme(): return None def isDark(): return None def isLight(): return None def listener(callback: typing.Callable[[str], None]) -> None: raise NotImplementedError() napari-0.5.0a1/napari/_vendor/darkdetect/_linux_detect.py000066400000000000000000000030601437041365600234400ustar00rootroot00000000000000#----------------------------------------------------------------------------- # Copyright (C) 2019 Alberto Sottile, Eric Larson # # Distributed under the terms of the 3-clause BSD License. #----------------------------------------------------------------------------- import subprocess def theme(): try: #Using the freedesktop specifications for checking dark mode out = subprocess.run( ['gsettings', 'get', 'org.gnome.desktop.interface', 'color-scheme'], capture_output=True) stdout = out.stdout.decode() #If not found then trying older gtk-theme method if len(stdout)<1: out = subprocess.run( ['gsettings', 'get', 'org.gnome.desktop.interface', 'gtk-theme'], capture_output=True) stdout = out.stdout.decode() except Exception: return 'Light' # we have a string, now remove start and end quote theme = stdout.lower().strip()[1:-1] if '-dark' in theme.lower(): return 'Dark' else: return 'Light' def isDark(): return theme() == 'Dark' def isLight(): return theme() == 'Light' # def listener(callback: typing.Callable[[str], None]) -> None: def listener(callback): with subprocess.Popen( ('gsettings', 'monitor', 'org.gnome.desktop.interface', 'gtk-theme'), stdout=subprocess.PIPE, universal_newlines=True, ) as p: for line in p.stdout: callback('Dark' if '-dark' in line.strip().removeprefix("gtk-theme: '").removesuffix("'").lower() else 'Light') napari-0.5.0a1/napari/_vendor/darkdetect/_mac_detect.py000066400000000000000000000072411437041365600230460ustar00rootroot00000000000000#----------------------------------------------------------------------------- # Copyright (C) 2019 Alberto Sottile # # Distributed under the terms of the 3-clause BSD License. #----------------------------------------------------------------------------- import ctypes import ctypes.util import subprocess import sys import os from pathlib import Path from typing import Callable try: from Foundation import NSObject, NSKeyValueObservingOptionNew, NSKeyValueChangeNewKey, NSUserDefaults from PyObjCTools import AppHelper _can_listen = True except ModuleNotFoundError: _can_listen = False try: # macOS Big Sur+ use "a built-in dynamic linker cache of all system-provided libraries" appkit = ctypes.cdll.LoadLibrary('AppKit.framework/AppKit') objc = ctypes.cdll.LoadLibrary('libobjc.dylib') except OSError: # revert to full path for older OS versions and hardened programs appkit = ctypes.cdll.LoadLibrary(ctypes.util.find_library('AppKit')) objc = ctypes.cdll.LoadLibrary(ctypes.util.find_library('objc')) void_p = ctypes.c_void_p ull = ctypes.c_uint64 objc.objc_getClass.restype = void_p objc.sel_registerName.restype = void_p # See https://docs.python.org/3/library/ctypes.html#function-prototypes for arguments description MSGPROTOTYPE = ctypes.CFUNCTYPE(void_p, void_p, void_p, void_p) msg = MSGPROTOTYPE(('objc_msgSend', objc), ((1 ,'', None), (1, '', None), (1, '', None))) def _utf8(s): if not isinstance(s, bytes): s = s.encode('utf8') return s def n(name): return objc.sel_registerName(_utf8(name)) def C(classname): return objc.objc_getClass(_utf8(classname)) def theme(): NSAutoreleasePool = objc.objc_getClass('NSAutoreleasePool') pool = msg(NSAutoreleasePool, n('alloc')) pool = msg(pool, n('init')) NSUserDefaults = C('NSUserDefaults') stdUserDef = msg(NSUserDefaults, n('standardUserDefaults')) NSString = C('NSString') key = msg(NSString, n("stringWithUTF8String:"), _utf8('AppleInterfaceStyle')) appearanceNS = msg(stdUserDef, n('stringForKey:'), void_p(key)) appearanceC = msg(appearanceNS, n('UTF8String')) if appearanceC is not None: out = ctypes.string_at(appearanceC) else: out = None msg(pool, n('release')) if out is not None: return out.decode('utf-8') else: return 'Light' def isDark(): return theme() == 'Dark' def isLight(): return theme() == 'Light' def _listen_child(): """ Run by a child process, install an observer and print theme on change """ import signal signal.signal(signal.SIGINT, signal.SIG_IGN) OBSERVED_KEY = "AppleInterfaceStyle" class Observer(NSObject): def observeValueForKeyPath_ofObject_change_context_( self, path, object, changeDescription, context ): result = changeDescription[NSKeyValueChangeNewKey] try: print(f"{'Light' if result is None else result}", flush=True) except IOError: os._exit(1) observer = Observer.new() # Keep a reference alive after installing defaults = NSUserDefaults.standardUserDefaults() defaults.addObserver_forKeyPath_options_context_( observer, OBSERVED_KEY, NSKeyValueObservingOptionNew, 0 ) AppHelper.runConsoleEventLoop() def listener(callback: Callable[[str], None]) -> None: if not _can_listen: raise NotImplementedError() with subprocess.Popen( (sys.executable, "-c", "import _mac_detect as m; m._listen_child()"), stdout=subprocess.PIPE, universal_newlines=True, cwd=Path(__file__).parent, ) as p: for line in p.stdout: callback(line.strip()) napari-0.5.0a1/napari/_vendor/darkdetect/_windows_detect.py000066400000000000000000000101141437041365600237710ustar00rootroot00000000000000from winreg import HKEY_CURRENT_USER as hkey, QueryValueEx as getSubkeyValue, OpenKey as getKey import ctypes import ctypes.wintypes advapi32 = ctypes.windll.advapi32 # LSTATUS RegOpenKeyExA( # HKEY hKey, # LPCSTR lpSubKey, # DWORD ulOptions, # REGSAM samDesired, # PHKEY phkResult # ); advapi32.RegOpenKeyExA.argtypes = ( ctypes.wintypes.HKEY, ctypes.wintypes.LPCSTR, ctypes.wintypes.DWORD, ctypes.wintypes.DWORD, ctypes.POINTER(ctypes.wintypes.HKEY), ) advapi32.RegOpenKeyExA.restype = ctypes.wintypes.LONG # LSTATUS RegQueryValueExA( # HKEY hKey, # LPCSTR lpValueName, # LPDWORD lpReserved, # LPDWORD lpType, # LPBYTE lpData, # LPDWORD lpcbData # ); advapi32.RegQueryValueExA.argtypes = ( ctypes.wintypes.HKEY, ctypes.wintypes.LPCSTR, ctypes.wintypes.LPDWORD, ctypes.wintypes.LPDWORD, ctypes.wintypes.LPBYTE, ctypes.wintypes.LPDWORD, ) advapi32.RegQueryValueExA.restype = ctypes.wintypes.LONG # LSTATUS RegNotifyChangeKeyValue( # HKEY hKey, # WINBOOL bWatchSubtree, # DWORD dwNotifyFilter, # HANDLE hEvent, # WINBOOL fAsynchronous # ); advapi32.RegNotifyChangeKeyValue.argtypes = ( ctypes.wintypes.HKEY, ctypes.wintypes.BOOL, ctypes.wintypes.DWORD, ctypes.wintypes.HANDLE, ctypes.wintypes.BOOL, ) advapi32.RegNotifyChangeKeyValue.restype = ctypes.wintypes.LONG def theme(): """ Uses the Windows Registry to detect if the user is using Dark Mode """ # Registry will return 0 if Windows is in Dark Mode and 1 if Windows is in Light Mode. This dictionary converts that output into the text that the program is expecting. valueMeaning = {0: "Dark", 1: "Light"} # In HKEY_CURRENT_USER, get the Personalisation Key. try: key = getKey(hkey, "Software\\Microsoft\\Windows\\CurrentVersion\\Themes\\Personalize") # In the Personalisation Key, get the AppsUseLightTheme subkey. This returns a tuple. # The first item in the tuple is the result we want (0 or 1 indicating Dark Mode or Light Mode); the other value is the type of subkey e.g. DWORD, QWORD, String, etc. subkey = getSubkeyValue(key, "AppsUseLightTheme")[0] except FileNotFoundError: # some headless Windows instances (e.g. GitHub Actions or Docker images) do not have this key return None return valueMeaning[subkey] def isDark(): if theme() is not None: return theme() == 'Dark' def isLight(): if theme() is not None: return theme() == 'Light' #def listener(callback: typing.Callable[[str], None]) -> None: def listener(callback): hKey = ctypes.wintypes.HKEY() advapi32.RegOpenKeyExA( ctypes.wintypes.HKEY(0x80000001), # HKEY_CURRENT_USER ctypes.wintypes.LPCSTR(b'SOFTWARE\\Microsoft\\Windows\\CurrentVersion\\Themes\\Personalize'), ctypes.wintypes.DWORD(), ctypes.wintypes.DWORD(0x00020019), # KEY_READ ctypes.byref(hKey), ) dwSize = ctypes.wintypes.DWORD(ctypes.sizeof(ctypes.wintypes.DWORD)) queryValueLast = ctypes.wintypes.DWORD() queryValue = ctypes.wintypes.DWORD() advapi32.RegQueryValueExA( hKey, ctypes.wintypes.LPCSTR(b'AppsUseLightTheme'), ctypes.wintypes.LPDWORD(), ctypes.wintypes.LPDWORD(), ctypes.cast(ctypes.byref(queryValueLast), ctypes.wintypes.LPBYTE), ctypes.byref(dwSize), ) while True: advapi32.RegNotifyChangeKeyValue( hKey, ctypes.wintypes.BOOL(True), ctypes.wintypes.DWORD(0x00000004), # REG_NOTIFY_CHANGE_LAST_SET ctypes.wintypes.HANDLE(None), ctypes.wintypes.BOOL(False), ) advapi32.RegQueryValueExA( hKey, ctypes.wintypes.LPCSTR(b'AppsUseLightTheme'), ctypes.wintypes.LPDWORD(), ctypes.wintypes.LPDWORD(), ctypes.cast(ctypes.byref(queryValue), ctypes.wintypes.LPBYTE), ctypes.byref(dwSize), ) if queryValueLast.value != queryValue.value: queryValueLast.value = queryValue.value callback('Light' if queryValue.value else 'Dark') napari-0.5.0a1/napari/_vendor/experimental/000077500000000000000000000000001437041365600206245ustar00rootroot00000000000000napari-0.5.0a1/napari/_vendor/experimental/__init__.py000066400000000000000000000000001437041365600227230ustar00rootroot00000000000000napari-0.5.0a1/napari/_vendor/experimental/cachetools/000077500000000000000000000000001437041365600227505ustar00rootroot00000000000000napari-0.5.0a1/napari/_vendor/experimental/cachetools/CHANGELOG.rst000066400000000000000000000135471437041365600250030ustar00rootroot00000000000000v4.1.1 (2020-06-28) =================== - Improve ``popitem()`` exception context handling. - Replace ``float('inf')`` with ``math.inf``. - Improve "envkey" documentation example. v4.1.0 (2020-04-08) =================== - Support ``user_function`` with ``cachetools.func`` decorators (Python 3.8 compatibility). - Support ``cache_parameters()`` with ``cachetools.func`` decorators (Python 3.9 compatibility). v4.0.0 (2019-12-15) =================== - Require Python 3.5 or later. v3.1.1 (2019-05-23) =================== - Document how to use shared caches with ``@cachedmethod``. - Fix pickling/unpickling of cache keys v3.1.0 (2019-01-29) =================== - Fix Python 3.8 compatibility issue. - Use ``time.monotonic`` as default timer if available. - Improve documentation regarding thread safety. v3.0.0 (2018-11-04) =================== - Officially support Python 3.7. - Drop Python 3.3 support (breaking change). - Remove ``missing`` cache constructor parameter (breaking change). - Remove ``self`` from ``@cachedmethod`` key arguments (breaking change). - Add support for ``maxsize=None`` in ``cachetools.func`` decorators. v2.1.0 (2018-05-12) =================== - Deprecate ``missing`` cache constructor parameter. - Handle overridden ``getsizeof()`` method in subclasses. - Fix Python 2.7 ``RRCache`` pickling issues. - Various documentation improvements. v2.0.1 (2017-08-11) =================== - Officially support Python 3.6. - Move documentation to RTD. - Documentation: Update import paths for key functions (courtesy of slavkoja). v2.0.0 (2016-10-03) =================== - Drop Python 3.2 support (breaking change). - Drop support for deprecated features (breaking change). - Move key functions to separate package (breaking change). - Accept non-integer ``maxsize`` in ``Cache.__repr__()``. v1.1.6 (2016-04-01) =================== - Reimplement ``LRUCache`` and ``TTLCache`` using ``collections.OrderedDict``. Note that this will break pickle compatibility with previous versions. - Fix ``TTLCache`` not calling ``__missing__()`` of derived classes. - Handle ``ValueError`` in ``Cache.__missing__()`` for consistency with caching decorators. - Improve how ``TTLCache`` handles expired items. - Use ``Counter.most_common()`` for ``LFUCache.popitem()``. v1.1.5 (2015-10-25) =================== - Refactor ``Cache`` base class. Note that this will break pickle compatibility with previous versions. - Clean up ``LRUCache`` and ``TTLCache`` implementations. v1.1.4 (2015-10-24) =================== - Refactor ``LRUCache`` and ``TTLCache`` implementations. Note that this will break pickle compatibility with previous versions. - Document pending removal of deprecated features. - Minor documentation improvements. v1.1.3 (2015-09-15) =================== - Fix pickle tests. v1.1.2 (2015-09-15) =================== - Fix pickling of large ``LRUCache`` and ``TTLCache`` instances. v1.1.1 (2015-09-07) =================== - Improve key functions. - Improve documentation. - Improve unit test coverage. v1.1.0 (2015-08-28) =================== - Add ``@cached`` function decorator. - Add ``hashkey`` and ``typedkey`` fuctions. - Add `key` and `lock` arguments to ``@cachedmethod``. - Set ``__wrapped__`` attributes for Python versions < 3.2. - Move ``functools`` compatible decorators to ``cachetools.func``. - Deprecate ``@cachedmethod`` `typed` argument. - Deprecate `cache` attribute for ``@cachedmethod`` wrappers. - Deprecate `getsizeof` and `lock` arguments for `cachetools.func` decorator. v1.0.3 (2015-06-26) =================== - Clear cache statistics when calling ``clear_cache()``. v1.0.2 (2015-06-18) =================== - Allow simple cache instances to be pickled. - Refactor ``Cache.getsizeof`` and ``Cache.missing`` default implementation. v1.0.1 (2015-06-06) =================== - Code cleanup for improved PEP 8 conformance. - Add documentation and unit tests for using ``@cachedmethod`` with generic mutable mappings. - Improve documentation. v1.0.0 (2014-12-19) =================== - Provide ``RRCache.choice`` property. - Improve documentation. v0.8.2 (2014-12-15) =================== - Use a ``NestedTimer`` for ``TTLCache``. v0.8.1 (2014-12-07) =================== - Deprecate ``Cache.getsize()``. v0.8.0 (2014-12-03) =================== - Ignore ``ValueError`` raised on cache insertion in decorators. - Add ``Cache.getsize()``. - Add ``Cache.__missing__()``. - Feature freeze for `v1.0`. v0.7.1 (2014-11-22) =================== - Fix `MANIFEST.in`. v0.7.0 (2014-11-12) =================== - Deprecate ``TTLCache.ExpiredError``. - Add `choice` argument to ``RRCache`` constructor. - Refactor ``LFUCache``, ``LRUCache`` and ``TTLCache``. - Use custom ``NullContext`` implementation for unsynchronized function decorators. v0.6.0 (2014-10-13) =================== - Raise ``TTLCache.ExpiredError`` for expired ``TTLCache`` items. - Support unsynchronized function decorators. - Allow ``@cachedmethod.cache()`` to return None v0.5.1 (2014-09-25) =================== - No formatting of ``KeyError`` arguments. - Update ``README.rst``. v0.5.0 (2014-09-23) =================== - Do not delete expired items in TTLCache.__getitem__(). - Add ``@ttl_cache`` function decorator. - Fix public ``getsizeof()`` usage. v0.4.0 (2014-06-16) =================== - Add ``TTLCache``. - Add ``Cache`` base class. - Remove ``@cachedmethod`` `lock` parameter. v0.3.1 (2014-05-07) =================== - Add proper locking for ``cache_clear()`` and ``cache_info()``. - Report `size` in ``cache_info()``. v0.3.0 (2014-05-06) =================== - Remove ``@cache`` decorator. - Add ``size``, ``getsizeof`` members. - Add ``@cachedmethod`` decorator. v0.2.0 (2014-04-02) =================== - Add ``@cache`` decorator. - Update documentation. v0.1.0 (2014-03-27) =================== - Initial release. napari-0.5.0a1/napari/_vendor/experimental/cachetools/LICENSE000066400000000000000000000020751437041365600237610ustar00rootroot00000000000000The MIT License (MIT) Copyright (c) 2014-2020 Thomas Kemmer 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. napari-0.5.0a1/napari/_vendor/experimental/cachetools/__init__.py000066400000000000000000000000451437041365600250600ustar00rootroot00000000000000from .cachetools.lru import LRUCache napari-0.5.0a1/napari/_vendor/experimental/cachetools/cachetools/000077500000000000000000000000001437041365600250745ustar00rootroot00000000000000napari-0.5.0a1/napari/_vendor/experimental/cachetools/cachetools/__init__.py000066400000000000000000000005671437041365600272150ustar00rootroot00000000000000"""Extensible memoizing collections and decorators.""" from .cache import Cache from .decorators import cached, cachedmethod from .lfu import LFUCache from .lru import LRUCache from .rr import RRCache from .ttl import TTLCache __all__ = ( 'Cache', 'LFUCache', 'LRUCache', 'RRCache', 'TTLCache', 'cached', 'cachedmethod' ) __version__ = '4.1.1' napari-0.5.0a1/napari/_vendor/experimental/cachetools/cachetools/abc.py000066400000000000000000000020641437041365600261750ustar00rootroot00000000000000from abc import abstractmethod from collections.abc import MutableMapping class DefaultMapping(MutableMapping): __slots__ = () @abstractmethod def __contains__(self, key): # pragma: nocover return False @abstractmethod def __getitem__(self, key): # pragma: nocover if hasattr(self.__class__, '__missing__'): return self.__class__.__missing__(self, key) else: raise KeyError(key) def get(self, key, default=None): if key in self: return self[key] else: return default __marker = object() def pop(self, key, default=__marker): if key in self: value = self[key] del self[key] elif default is self.__marker: raise KeyError(key) else: value = default return value def setdefault(self, key, default=None): if key in self: value = self[key] else: self[key] = value = default return value DefaultMapping.register(dict) napari-0.5.0a1/napari/_vendor/experimental/cachetools/cachetools/cache.py000066400000000000000000000043401437041365600265120ustar00rootroot00000000000000from .abc import DefaultMapping class _DefaultSize(object): def __getitem__(self, _): return 1 def __setitem__(self, _, value): assert value == 1 def pop(self, _): return 1 class Cache(DefaultMapping): """Mutable mapping to serve as a simple cache or cache base class.""" __size = _DefaultSize() def __init__(self, maxsize, getsizeof=None): if getsizeof: self.getsizeof = getsizeof if self.getsizeof is not Cache.getsizeof: self.__size = dict() self.__data = dict() self.__currsize = 0 self.__maxsize = maxsize def __repr__(self): return '%s(%r, maxsize=%r, currsize=%r)' % ( self.__class__.__name__, list(self.__data.items()), self.__maxsize, self.__currsize, ) def __getitem__(self, key): try: return self.__data[key] except KeyError: return self.__missing__(key) def __setitem__(self, key, value): maxsize = self.__maxsize size = self.getsizeof(value) if size > maxsize: raise ValueError('value too large') if key not in self.__data or self.__size[key] < size: while self.__currsize + size > maxsize: self.popitem() if key in self.__data: diffsize = size - self.__size[key] else: diffsize = size self.__data[key] = value self.__size[key] = size self.__currsize += diffsize def __delitem__(self, key): size = self.__size.pop(key) del self.__data[key] self.__currsize -= size def __contains__(self, key): return key in self.__data def __missing__(self, key): raise KeyError(key) def __iter__(self): return iter(self.__data) def __len__(self): return len(self.__data) @property def maxsize(self): """The maximum size of the cache.""" return self.__maxsize @property def currsize(self): """The current size of the cache.""" return self.__currsize @staticmethod def getsizeof(value): """Return the size of a cache element's value.""" return 1 napari-0.5.0a1/napari/_vendor/experimental/cachetools/cachetools/decorators.py000066400000000000000000000054151437041365600276200ustar00rootroot00000000000000import functools from .keys import hashkey def cached(cache, key=hashkey, lock=None): """Decorator to wrap a function with a memoizing callable that saves results in a cache. """ def decorator(func): if cache is None: def wrapper(*args, **kwargs): return func(*args, **kwargs) elif lock is None: def wrapper(*args, **kwargs): k = key(*args, **kwargs) try: return cache[k] except KeyError: pass # key not found v = func(*args, **kwargs) try: cache[k] = v except ValueError: pass # value too large return v else: def wrapper(*args, **kwargs): k = key(*args, **kwargs) try: with lock: return cache[k] except KeyError: pass # key not found v = func(*args, **kwargs) try: with lock: cache[k] = v except ValueError: pass # value too large return v return functools.update_wrapper(wrapper, func) return decorator def cachedmethod(cache, key=hashkey, lock=None): """Decorator to wrap a class or instance method with a memoizing callable that saves results in a cache. """ def decorator(method): if lock is None: def wrapper(self, *args, **kwargs): c = cache(self) if c is None: return method(self, *args, **kwargs) k = key(*args, **kwargs) try: return c[k] except KeyError: pass # key not found v = method(self, *args, **kwargs) try: c[k] = v except ValueError: pass # value too large return v else: def wrapper(self, *args, **kwargs): c = cache(self) if c is None: return method(self, *args, **kwargs) k = key(*args, **kwargs) try: with lock(self): return c[k] except KeyError: pass # key not found v = method(self, *args, **kwargs) try: with lock(self): c[k] = v except ValueError: pass # value too large return v return functools.update_wrapper(wrapper, method) return decorator napari-0.5.0a1/napari/_vendor/experimental/cachetools/cachetools/func.py000066400000000000000000000076511437041365600264120ustar00rootroot00000000000000"""`functools.lru_cache` compatible memoizing function decorators.""" import collections import functools import math import random import time try: from threading import RLock except ImportError: # pragma: no cover from dummy_threading import RLock from . import keys from .lfu import LFUCache from .lru import LRUCache from .rr import RRCache from .ttl import TTLCache __all__ = ('lfu_cache', 'lru_cache', 'rr_cache', 'ttl_cache') _CacheInfo = collections.namedtuple('CacheInfo', [ 'hits', 'misses', 'maxsize', 'currsize' ]) class _UnboundCache(dict): @property def maxsize(self): return None @property def currsize(self): return len(self) class _UnboundTTLCache(TTLCache): def __init__(self, ttl, timer): TTLCache.__init__(self, math.inf, ttl, timer) @property def maxsize(self): return None def _cache(cache, typed): maxsize = cache.maxsize def decorator(func): key = keys.typedkey if typed else keys.hashkey lock = RLock() stats = [0, 0] def wrapper(*args, **kwargs): k = key(*args, **kwargs) with lock: try: v = cache[k] stats[0] += 1 return v except KeyError: stats[1] += 1 v = func(*args, **kwargs) try: with lock: cache[k] = v except ValueError: pass # value too large return v def cache_info(): with lock: hits, misses = stats maxsize = cache.maxsize currsize = cache.currsize return _CacheInfo(hits, misses, maxsize, currsize) def cache_clear(): with lock: try: cache.clear() finally: stats[:] = [0, 0] wrapper.cache_info = cache_info wrapper.cache_clear = cache_clear wrapper.cache_parameters = lambda: {'maxsize': maxsize, 'typed': typed} functools.update_wrapper(wrapper, func) return wrapper return decorator def lfu_cache(maxsize=128, typed=False): """Decorator to wrap a function with a memoizing callable that saves up to `maxsize` results based on a Least Frequently Used (LFU) algorithm. """ if maxsize is None: return _cache(_UnboundCache(), typed) elif callable(maxsize): return _cache(LFUCache(128), typed)(maxsize) else: return _cache(LFUCache(maxsize), typed) def lru_cache(maxsize=128, typed=False): """Decorator to wrap a function with a memoizing callable that saves up to `maxsize` results based on a Least Recently Used (LRU) algorithm. """ if maxsize is None: return _cache(_UnboundCache(), typed) elif callable(maxsize): return _cache(LRUCache(128), typed)(maxsize) else: return _cache(LRUCache(maxsize), typed) def rr_cache(maxsize=128, choice=random.choice, typed=False): """Decorator to wrap a function with a memoizing callable that saves up to `maxsize` results based on a Random Replacement (RR) algorithm. """ if maxsize is None: return _cache(_UnboundCache(), typed) elif callable(maxsize): return _cache(RRCache(128, choice), typed)(maxsize) else: return _cache(RRCache(maxsize, choice), typed) def ttl_cache(maxsize=128, ttl=600, timer=time.monotonic, typed=False): """Decorator to wrap a function with a memoizing callable that saves up to `maxsize` results based on a Least Recently Used (LRU) algorithm with a per-item time-to-live (TTL) value. """ if maxsize is None: return _cache(_UnboundTTLCache(ttl, timer), typed) elif callable(maxsize): return _cache(TTLCache(128, ttl, timer), typed)(maxsize) else: return _cache(TTLCache(maxsize, ttl, timer), typed) napari-0.5.0a1/napari/_vendor/experimental/cachetools/cachetools/keys.py000066400000000000000000000026721437041365600264300ustar00rootroot00000000000000"""Key functions for memoizing decorators.""" __all__ = ('hashkey', 'typedkey') class _HashedTuple(tuple): """A tuple that ensures that hash() will be called no more than once per element, since cache decorators will hash the key multiple times on a cache miss. See also _HashedSeq in the standard library functools implementation. """ __hashvalue = None def __hash__(self, hash=tuple.__hash__): hashvalue = self.__hashvalue if hashvalue is None: self.__hashvalue = hashvalue = hash(self) return hashvalue def __add__(self, other, add=tuple.__add__): return _HashedTuple(add(self, other)) def __radd__(self, other, add=tuple.__add__): return _HashedTuple(add(other, self)) def __getstate__(self): return {} # used for separating keyword arguments; we do not use an object # instance here so identity is preserved when pickling/unpickling _kwmark = (_HashedTuple,) def hashkey(*args, **kwargs): """Return a cache key for the specified hashable arguments.""" if kwargs: return _HashedTuple(args + sum(sorted(kwargs.items()), _kwmark)) else: return _HashedTuple(args) def typedkey(*args, **kwargs): """Return a typed cache key for the specified hashable arguments.""" key = hashkey(*args, **kwargs) key += tuple(type(v) for v in args) key += tuple(type(v) for _, v in sorted(kwargs.items())) return key napari-0.5.0a1/napari/_vendor/experimental/cachetools/cachetools/lfu.py000066400000000000000000000020511437041365600262320ustar00rootroot00000000000000import collections from .cache import Cache class LFUCache(Cache): """Least Frequently Used (LFU) cache implementation.""" def __init__(self, maxsize, getsizeof=None): Cache.__init__(self, maxsize, getsizeof) self.__counter = collections.Counter() def __getitem__(self, key, cache_getitem=Cache.__getitem__): value = cache_getitem(self, key) self.__counter[key] -= 1 return value def __setitem__(self, key, value, cache_setitem=Cache.__setitem__): cache_setitem(self, key, value) self.__counter[key] -= 1 def __delitem__(self, key, cache_delitem=Cache.__delitem__): cache_delitem(self, key) del self.__counter[key] def popitem(self): """Remove and return the `(key, value)` pair least frequently used.""" try: (key, _), = self.__counter.most_common(1) except ValueError: msg = '%s is empty' % self.__class__.__name__ raise KeyError(msg) from None else: return (key, self.pop(key)) napari-0.5.0a1/napari/_vendor/experimental/cachetools/cachetools/lru.py000066400000000000000000000022441437041365600262520ustar00rootroot00000000000000import collections from .cache import Cache class LRUCache(Cache): """Least Recently Used (LRU) cache implementation.""" def __init__(self, maxsize, getsizeof=None): Cache.__init__(self, maxsize, getsizeof) self.__order = collections.OrderedDict() def __getitem__(self, key, cache_getitem=Cache.__getitem__): value = cache_getitem(self, key) self.__update(key) return value def __setitem__(self, key, value, cache_setitem=Cache.__setitem__): cache_setitem(self, key, value) self.__update(key) def __delitem__(self, key, cache_delitem=Cache.__delitem__): cache_delitem(self, key) del self.__order[key] def popitem(self): """Remove and return the `(key, value)` pair least recently used.""" try: key = next(iter(self.__order)) except StopIteration: msg = '%s is empty' % self.__class__.__name__ raise KeyError(msg) from None else: return (key, self.pop(key)) def __update(self, key): try: self.__order.move_to_end(key) except KeyError: self.__order[key] = None napari-0.5.0a1/napari/_vendor/experimental/cachetools/cachetools/rr.py000066400000000000000000000017161437041365600260760ustar00rootroot00000000000000import random from .cache import Cache # random.choice cannot be pickled in Python 2.7 def _choice(seq): return random.choice(seq) class RRCache(Cache): """Random Replacement (RR) cache implementation.""" def __init__(self, maxsize, choice=random.choice, getsizeof=None): Cache.__init__(self, maxsize, getsizeof) # TODO: use None as default, assing to self.choice directly? if choice is random.choice: self.__choice = _choice else: self.__choice = choice @property def choice(self): """The `choice` function used by the cache.""" return self.__choice def popitem(self): """Remove and return a random `(key, value)` pair.""" try: key = self.__choice(list(self)) except IndexError: msg = '%s is empty' % self.__class__.__name__ raise KeyError(msg) from None else: return (key, self.pop(key)) napari-0.5.0a1/napari/_vendor/experimental/cachetools/cachetools/ttl.py000066400000000000000000000133061437041365600262540ustar00rootroot00000000000000import collections import time from .cache import Cache class _Link(object): __slots__ = ('key', 'expire', 'next', 'prev') def __init__(self, key=None, expire=None): self.key = key self.expire = expire def __reduce__(self): return _Link, (self.key, self.expire) def unlink(self): next = self.next prev = self.prev prev.next = next next.prev = prev class _Timer(object): def __init__(self, timer): self.__timer = timer self.__nesting = 0 def __call__(self): if self.__nesting == 0: return self.__timer() else: return self.__time def __enter__(self): if self.__nesting == 0: self.__time = time = self.__timer() else: time = self.__time self.__nesting += 1 return time def __exit__(self, *exc): self.__nesting -= 1 def __reduce__(self): return _Timer, (self.__timer,) def __getattr__(self, name): return getattr(self.__timer, name) class TTLCache(Cache): """LRU Cache implementation with per-item time-to-live (TTL) value.""" def __init__(self, maxsize, ttl, timer=time.monotonic, getsizeof=None): Cache.__init__(self, maxsize, getsizeof) self.__root = root = _Link() root.prev = root.next = root self.__links = collections.OrderedDict() self.__timer = _Timer(timer) self.__ttl = ttl def __contains__(self, key): try: link = self.__links[key] # no reordering except KeyError: return False else: return not (link.expire < self.__timer()) def __getitem__(self, key, cache_getitem=Cache.__getitem__): try: link = self.__getlink(key) except KeyError: expired = False else: expired = link.expire < self.__timer() if expired: return self.__missing__(key) else: return cache_getitem(self, key) def __setitem__(self, key, value, cache_setitem=Cache.__setitem__): with self.__timer as time: self.expire(time) cache_setitem(self, key, value) try: link = self.__getlink(key) except KeyError: self.__links[key] = link = _Link(key) else: link.unlink() link.expire = time + self.__ttl link.next = root = self.__root link.prev = prev = root.prev prev.next = root.prev = link def __delitem__(self, key, cache_delitem=Cache.__delitem__): cache_delitem(self, key) link = self.__links.pop(key) link.unlink() if link.expire < self.__timer(): raise KeyError(key) def __iter__(self): root = self.__root curr = root.next while curr is not root: # "freeze" time for iterator access with self.__timer as time: if not (curr.expire < time): yield curr.key curr = curr.next def __len__(self): root = self.__root curr = root.next time = self.__timer() count = len(self.__links) while curr is not root and curr.expire < time: count -= 1 curr = curr.next return count def __setstate__(self, state): self.__dict__.update(state) root = self.__root root.prev = root.next = root for link in sorted(self.__links.values(), key=lambda obj: obj.expire): link.next = root link.prev = prev = root.prev prev.next = root.prev = link self.expire(self.__timer()) def __repr__(self, cache_repr=Cache.__repr__): with self.__timer as time: self.expire(time) return cache_repr(self) @property def currsize(self): with self.__timer as time: self.expire(time) return super(TTLCache, self).currsize @property def timer(self): """The timer function used by the cache.""" return self.__timer @property def ttl(self): """The time-to-live value of the cache's items.""" return self.__ttl def expire(self, time=None): """Remove expired items from the cache.""" if time is None: time = self.__timer() root = self.__root curr = root.next links = self.__links cache_delitem = Cache.__delitem__ while curr is not root and curr.expire < time: cache_delitem(self, curr.key) del links[curr.key] next = curr.next curr.unlink() curr = next def clear(self): with self.__timer as time: self.expire(time) Cache.clear(self) def get(self, *args, **kwargs): with self.__timer: return Cache.get(self, *args, **kwargs) def pop(self, *args, **kwargs): with self.__timer: return Cache.pop(self, *args, **kwargs) def setdefault(self, *args, **kwargs): with self.__timer: return Cache.setdefault(self, *args, **kwargs) def popitem(self): """Remove and return the `(key, value)` pair least recently used that has not already expired. """ with self.__timer as time: self.expire(time) try: key = next(iter(self.__links)) except StopIteration: msg = '%s is empty' % self.__class__.__name__ raise KeyError(msg) from None else: return (key, self.pop(key)) def __getlink(self, key): value = self.__links[key] self.__links.move_to_end(key) return value napari-0.5.0a1/napari/_vendor/experimental/cachetools/docs/000077500000000000000000000000001437041365600237005ustar00rootroot00000000000000napari-0.5.0a1/napari/_vendor/experimental/cachetools/docs/.gitignore000066400000000000000000000000071437041365600256650ustar00rootroot00000000000000_build napari-0.5.0a1/napari/_vendor/experimental/cachetools/docs/Makefile000066400000000000000000000127141437041365600253450ustar00rootroot00000000000000# Makefile for Sphinx documentation # # You can set these variables from the command line. SPHINXOPTS = SPHINXBUILD = sphinx-build PAPER = BUILDDIR = _build # Internal variables. PAPEROPT_a4 = -D latex_paper_size=a4 PAPEROPT_letter = -D latex_paper_size=letter ALLSPHINXOPTS = -d $(BUILDDIR)/doctrees $(PAPEROPT_$(PAPER)) $(SPHINXOPTS) . # the i18n builder cannot share the environment and doctrees with the others I18NSPHINXOPTS = $(PAPEROPT_$(PAPER)) $(SPHINXOPTS) . .PHONY: help clean html dirhtml singlehtml pickle json htmlhelp qthelp devhelp epub latex latexpdf text man changes linkcheck doctest gettext help: @echo "Please use \`make ' where is one of" @echo " html to make standalone HTML files" @echo " dirhtml to make HTML files named index.html in directories" @echo " singlehtml to make a single large HTML file" @echo " pickle to make pickle files" @echo " json to make JSON files" @echo " htmlhelp to make HTML files and a HTML help project" @echo " qthelp to make HTML files and a qthelp project" @echo " devhelp to make HTML files and a Devhelp project" @echo " epub to make an epub" @echo " latex to make LaTeX files, you can set PAPER=a4 or PAPER=letter" @echo " latexpdf to make LaTeX files and run them through pdflatex" @echo " text to make text files" @echo " man to make manual pages" @echo " texinfo to make Texinfo files" @echo " info to make Texinfo files and run them through makeinfo" @echo " gettext to make PO message catalogs" @echo " changes to make an overview of all changed/added/deprecated items" @echo " linkcheck to check all external links for integrity" @echo " doctest to run all doctests embedded in the documentation (if enabled)" clean: -rm -rf $(BUILDDIR)/* html: $(SPHINXBUILD) -b html $(ALLSPHINXOPTS) $(BUILDDIR)/html @echo @echo "Build finished. The HTML pages are in $(BUILDDIR)/html." dirhtml: $(SPHINXBUILD) -b dirhtml $(ALLSPHINXOPTS) $(BUILDDIR)/dirhtml @echo @echo "Build finished. The HTML pages are in $(BUILDDIR)/dirhtml." singlehtml: $(SPHINXBUILD) -b singlehtml $(ALLSPHINXOPTS) $(BUILDDIR)/singlehtml @echo @echo "Build finished. The HTML page is in $(BUILDDIR)/singlehtml." pickle: $(SPHINXBUILD) -b pickle $(ALLSPHINXOPTS) $(BUILDDIR)/pickle @echo @echo "Build finished; now you can process the pickle files." json: $(SPHINXBUILD) -b json $(ALLSPHINXOPTS) $(BUILDDIR)/json @echo @echo "Build finished; now you can process the JSON files." htmlhelp: $(SPHINXBUILD) -b htmlhelp $(ALLSPHINXOPTS) $(BUILDDIR)/htmlhelp @echo @echo "Build finished; now you can run HTML Help Workshop with the" \ ".hhp project file in $(BUILDDIR)/htmlhelp." qthelp: $(SPHINXBUILD) -b qthelp $(ALLSPHINXOPTS) $(BUILDDIR)/qthelp @echo @echo "Build finished; now you can run "qcollectiongenerator" with the" \ ".qhcp project file in $(BUILDDIR)/qthelp, like this:" @echo "# qcollectiongenerator $(BUILDDIR)/qthelp/cachetools.qhcp" @echo "To view the help file:" @echo "# assistant -collectionFile $(BUILDDIR)/qthelp/cachetools.qhc" devhelp: $(SPHINXBUILD) -b devhelp $(ALLSPHINXOPTS) $(BUILDDIR)/devhelp @echo @echo "Build finished." @echo "To view the help file:" @echo "# mkdir -p $$HOME/.local/share/devhelp/cachetools" @echo "# ln -s $(BUILDDIR)/devhelp $$HOME/.local/share/devhelp/cachetools" @echo "# devhelp" epub: $(SPHINXBUILD) -b epub $(ALLSPHINXOPTS) $(BUILDDIR)/epub @echo @echo "Build finished. The epub file is in $(BUILDDIR)/epub." latex: $(SPHINXBUILD) -b latex $(ALLSPHINXOPTS) $(BUILDDIR)/latex @echo @echo "Build finished; the LaTeX files are in $(BUILDDIR)/latex." @echo "Run \`make' in that directory to run these through (pdf)latex" \ "(use \`make latexpdf' here to do that automatically)." latexpdf: $(SPHINXBUILD) -b latex $(ALLSPHINXOPTS) $(BUILDDIR)/latex @echo "Running LaTeX files through pdflatex..." $(MAKE) -C $(BUILDDIR)/latex all-pdf @echo "pdflatex finished; the PDF files are in $(BUILDDIR)/latex." text: $(SPHINXBUILD) -b text $(ALLSPHINXOPTS) $(BUILDDIR)/text @echo @echo "Build finished. The text files are in $(BUILDDIR)/text." man: $(SPHINXBUILD) -b man $(ALLSPHINXOPTS) $(BUILDDIR)/man @echo @echo "Build finished. The manual pages are in $(BUILDDIR)/man." texinfo: $(SPHINXBUILD) -b texinfo $(ALLSPHINXOPTS) $(BUILDDIR)/texinfo @echo @echo "Build finished. The Texinfo files are in $(BUILDDIR)/texinfo." @echo "Run \`make' in that directory to run these through makeinfo" \ "(use \`make info' here to do that automatically)." info: $(SPHINXBUILD) -b texinfo $(ALLSPHINXOPTS) $(BUILDDIR)/texinfo @echo "Running Texinfo files through makeinfo..." make -C $(BUILDDIR)/texinfo info @echo "makeinfo finished; the Info files are in $(BUILDDIR)/texinfo." gettext: $(SPHINXBUILD) -b gettext $(I18NSPHINXOPTS) $(BUILDDIR)/locale @echo @echo "Build finished. The message catalogs are in $(BUILDDIR)/locale." changes: $(SPHINXBUILD) -b changes $(ALLSPHINXOPTS) $(BUILDDIR)/changes @echo @echo "The overview file is in $(BUILDDIR)/changes." linkcheck: $(SPHINXBUILD) -b linkcheck $(ALLSPHINXOPTS) $(BUILDDIR)/linkcheck @echo @echo "Link check complete; look for any errors in the above output " \ "or in $(BUILDDIR)/linkcheck/output.txt." doctest: $(SPHINXBUILD) -b doctest $(ALLSPHINXOPTS) $(BUILDDIR)/doctest @echo "Testing of doctests in the sources finished, look at the " \ "results in $(BUILDDIR)/doctest/output.txt." napari-0.5.0a1/napari/_vendor/experimental/cachetools/docs/conf.py000066400000000000000000000010671437041365600252030ustar00rootroot00000000000000def get_version(): import configparser import pathlib cp = configparser.ConfigParser() # Python 3.5 ConfigParser does not accept Path as filename cp.read(str(pathlib.Path(__file__).parent.parent / "setup.cfg")) return cp["metadata"]["version"] project = 'cachetools' copyright = '2014-2020 Thomas Kemmer' version = get_version() release = version extensions = [ 'sphinx.ext.autodoc', 'sphinx.ext.coverage', 'sphinx.ext.doctest', 'sphinx.ext.todo' ] exclude_patterns = ['_build'] master_doc = 'index' html_theme = 'default' napari-0.5.0a1/napari/_vendor/experimental/cachetools/docs/index.rst000066400000000000000000000433431437041365600255500ustar00rootroot00000000000000********************************************************************* :mod:`cachetools` --- Extensible memoizing collections and decorators ********************************************************************* .. module:: cachetools This module provides various memoizing collections and decorators, including variants of the Python Standard Library's `@lru_cache`_ function decorator. For the purpose of this module, a *cache* is a mutable_ mapping_ of a fixed maximum size. When the cache is full, i.e. by adding another item the cache would exceed its maximum size, the cache must choose which item(s) to discard based on a suitable `cache algorithm`_. In general, a cache's size is the total size of its items, and an item's size is a property or function of its value, e.g. the result of ``sys.getsizeof(value)``. For the trivial but common case that each item counts as :const:`1`, a cache's size is equal to the number of its items, or ``len(cache)``. Multiple cache classes based on different caching algorithms are implemented, and decorators for easily memoizing function and method calls are provided, too. .. testsetup:: * import operator from cachetools import cached, cachedmethod, LRUCache from unittest import mock urllib = mock.MagicMock() Cache implementations ===================== This module provides several classes implementing caches using different cache algorithms. All these classes derive from class :class:`Cache`, which in turn derives from :class:`collections.MutableMapping`, and provide :attr:`maxsize` and :attr:`currsize` properties to retrieve the maximum and current size of the cache. When a cache is full, :meth:`Cache.__setitem__()` calls :meth:`self.popitem()` repeatedly until there is enough room for the item to be added. :class:`Cache` also features a :meth:`getsizeof` method, which returns the size of a given `value`. The default implementation of :meth:`getsizeof` returns :const:`1` irrespective of its argument, making the cache's size equal to the number of its items, or ``len(cache)``. For convenience, all cache classes accept an optional named constructor parameter `getsizeof`, which may specify a function of one argument used to retrieve the size of an item's value. .. note:: Please be aware that all these classes are *not* thread-safe. Access to a shared cache from multiple threads must be properly synchronized, e.g. by using one of the memoizing decorators with a suitable `lock` object. .. autoclass:: Cache(maxsize, getsizeof=None) :members: This class discards arbitrary items using :meth:`popitem` to make space when necessary. Derived classes may override :meth:`popitem` to implement specific caching strategies. If a subclass has to keep track of item access, insertion or deletion, it may additionally need to override :meth:`__getitem__`, :meth:`__setitem__` and :meth:`__delitem__`. .. autoclass:: LFUCache(maxsize, getsizeof=None) :members: This class counts how often an item is retrieved, and discards the items used least often to make space when necessary. .. autoclass:: LRUCache(maxsize, getsizeof=None) :members: This class discards the least recently used items first to make space when necessary. .. autoclass:: RRCache(maxsize, choice=random.choice, getsizeof=None) :members: This class randomly selects candidate items and discards them to make space when necessary. By default, items are selected from the list of cache keys using :func:`random.choice`. The optional argument `choice` may specify an alternative function that returns an arbitrary element from a non-empty sequence. .. autoclass:: TTLCache(maxsize, ttl, timer=time.monotonic, getsizeof=None) :members: popitem, timer, ttl This class associates a time-to-live value with each item. Items that expire because they have exceeded their time-to-live will be no longer accessible, and will be removed eventually. If no expired items are there to remove, the least recently used items will be discarded first to make space when necessary. By default, the time-to-live is specified in seconds and :func:`time.monotonic` is used to retrieve the current time. A custom `timer` function can be supplied if needed. .. method:: expire(self, time=None) Expired items will be removed from a cache only at the next mutating operation, e.g. :meth:`__setitem__` or :meth:`__delitem__`, and therefore may still claim memory. Calling this method removes all items whose time-to-live would have expired by `time`, so garbage collection is free to reuse their memory. If `time` is :const:`None`, this removes all items that have expired by the current value returned by :attr:`timer`. Extending cache classes ----------------------- Sometimes it may be desirable to notice when and what cache items are evicted, i.e. removed from a cache to make room for new items. Since all cache implementations call :meth:`popitem` to evict items from the cache, this can be achieved by overriding this method in a subclass: .. doctest:: :pyversion: >= 3 >>> class MyCache(LRUCache): ... def popitem(self): ... key, value = super().popitem() ... print('Key "%s" evicted with value "%s"' % (key, value)) ... return key, value >>> c = MyCache(maxsize=2) >>> c['a'] = 1 >>> c['b'] = 2 >>> c['c'] = 3 Key "a" evicted with value "1" Similar to the standard library's :class:`collections.defaultdict`, subclasses of :class:`Cache` may implement a :meth:`__missing__` method which is called by :meth:`Cache.__getitem__` if the requested key is not found: .. doctest:: :pyversion: >= 3 >>> class PepStore(LRUCache): ... def __missing__(self, key): ... """Retrieve text of a Python Enhancement Proposal""" ... url = 'http://www.python.org/dev/peps/pep-%04d/' % key ... try: ... with urllib.request.urlopen(url) as s: ... pep = s.read() ... self[key] = pep # store text in cache ... return pep ... except urllib.error.HTTPError: ... return 'Not Found' # do not store in cache >>> peps = PepStore(maxsize=4) >>> for n in 8, 9, 290, 308, 320, 8, 218, 320, 279, 289, 320: ... pep = peps[n] >>> print(sorted(peps.keys())) [218, 279, 289, 320] Note, though, that such a class does not really behave like a *cache* any more, and will lead to surprising results when used with any of the memoizing decorators described below. However, it may be useful in its own right. Memoizing decorators ==================== The :mod:`cachetools` module provides decorators for memoizing function and method calls. This can save time when a function is often called with the same arguments: .. doctest:: >>> @cached(cache={}) ... def fib(n): ... 'Compute the nth number in the Fibonacci sequence' ... return n if n < 2 else fib(n - 1) + fib(n - 2) >>> fib(42) 267914296 .. decorator:: cached(cache, key=cachetools.keys.hashkey, lock=None) Decorator to wrap a function with a memoizing callable that saves results in a cache. The `cache` argument specifies a cache object to store previous function arguments and return values. Note that `cache` need not be an instance of the cache implementations provided by the :mod:`cachetools` module. :func:`cached` will work with any mutable mapping type, including plain :class:`dict` and :class:`weakref.WeakValueDictionary`. `key` specifies a function that will be called with the same positional and keyword arguments as the wrapped function itself, and which has to return a suitable cache key. Since caches are mappings, the object returned by `key` must be hashable. The default is to call :func:`cachetools.keys.hashkey`. If `lock` is not :const:`None`, it must specify an object implementing the `context manager`_ protocol. Any access to the cache will then be nested in a ``with lock:`` statement. This can be used for synchronizing thread access to the cache by providing a :class:`threading.RLock` instance, for example. .. note:: The `lock` context manager is used only to guard access to the cache object. The underlying wrapped function will be called outside the `with` statement, and must be thread-safe by itself. The original underlying function is accessible through the :attr:`__wrapped__` attribute of the memoizing wrapper function. This can be used for introspection or for bypassing the cache. To perform operations on the cache object, for example to clear the cache during runtime, the cache should be assigned to a variable. When a `lock` object is used, any access to the cache from outside the function wrapper should also be performed within an appropriate `with` statement: .. testcode:: from threading import RLock cache = LRUCache(maxsize=32) lock = RLock() @cached(cache, lock=lock) def get_pep(num): 'Retrieve text of a Python Enhancement Proposal' url = 'http://www.python.org/dev/peps/pep-%04d/' % num with urllib.request.urlopen(url) as s: return s.read() # make sure access to cache is synchronized with lock: cache.clear() It is also possible to use a single shared cache object with multiple functions. However, care must be taken that different cache keys are generated for each function, even for identical function arguments: .. doctest:: :options: +ELLIPSIS >>> from cachetools.keys import hashkey >>> from functools import partial >>> # shared cache for integer sequences >>> numcache = {} >>> # compute Fibonacci numbers >>> @cached(numcache, key=partial(hashkey, 'fib')) ... def fib(n): ... return n if n < 2 else fib(n - 1) + fib(n - 2) >>> # compute Lucas numbers >>> @cached(numcache, key=partial(hashkey, 'luc')) ... def luc(n): ... return 2 - n if n < 2 else luc(n - 1) + luc(n - 2) >>> fib(42) 267914296 >>> luc(42) 599074578 >>> list(sorted(numcache.items())) [..., (('fib', 42), 267914296), ..., (('luc', 42), 599074578)] .. decorator:: cachedmethod(cache, key=cachetools.keys.hashkey, lock=None) Decorator to wrap a class or instance method with a memoizing callable that saves results in a (possibly shared) cache. The main difference between this and the :func:`cached` function decorator is that `cache` and `lock` are not passed objects, but functions. Both will be called with :const:`self` (or :const:`cls` for class methods) as their sole argument to retrieve the cache or lock object for the method's respective instance or class. .. note:: As with :func:`cached`, the context manager obtained by calling ``lock(self)`` will only guard access to the cache itself. It is the user's responsibility to handle concurrent calls to the underlying wrapped method in a multithreaded environment. One advantage of :func:`cachedmethod` over the :func:`cached` function decorator is that cache properties such as `maxsize` can be set at runtime: .. testcode:: class CachedPEPs(object): def __init__(self, cachesize): self.cache = LRUCache(maxsize=cachesize) @cachedmethod(operator.attrgetter('cache')) def get(self, num): """Retrieve text of a Python Enhancement Proposal""" url = 'http://www.python.org/dev/peps/pep-%04d/' % num with urllib.request.urlopen(url) as s: return s.read() peps = CachedPEPs(cachesize=10) print("PEP #1: %s" % peps.get(1)) .. testoutput:: :hide: :options: +ELLIPSIS PEP #1: ... When using a shared cache for multiple methods, be aware that different cache keys must be created for each method even when function arguments are the same, just as with the `@cached` decorator: .. testcode:: class CachedReferences(object): def __init__(self, cachesize): self.cache = LRUCache(maxsize=cachesize) @cachedmethod(lambda self: self.cache, key=partial(hashkey, 'pep')) def get_pep(self, num): """Retrieve text of a Python Enhancement Proposal""" url = 'http://www.python.org/dev/peps/pep-%04d/' % num with urllib.request.urlopen(url) as s: return s.read() @cachedmethod(lambda self: self.cache, key=partial(hashkey, 'rfc')) def get_rfc(self, num): """Retrieve text of an IETF Request for Comments""" url = 'https://tools.ietf.org/rfc/rfc%d.txt' % num with urllib.request.urlopen(url) as s: return s.read() docs = CachedReferences(cachesize=100) print("PEP #1: %s" % docs.get_pep(1)) print("RFC #1: %s" % docs.get_rfc(1)) .. testoutput:: :hide: :options: +ELLIPSIS PEP #1: ... RFC #1: ... ***************************************************************** :mod:`cachetools.keys` --- Key functions for memoizing decorators ***************************************************************** .. module:: cachetools.keys This module provides several functions that can be used as key functions with the :func:`cached` and :func:`cachedmethod` decorators: .. autofunction:: hashkey This function returns a :class:`tuple` instance suitable as a cache key, provided the positional and keywords arguments are hashable. .. autofunction:: typedkey This function is similar to :func:`hashkey`, but arguments of different types will yield distinct cache keys. For example, ``typedkey(3)`` and ``typedkey(3.0)`` will return different results. These functions can also be helpful when implementing custom key functions for handling some non-hashable arguments. For example, calling the following function with a dictionary as its `env` argument will raise a :class:`TypeError`, since :class:`dict` is not hashable:: @cached(LRUCache(maxsize=128)) def foo(x, y, z, env={}): pass However, if `env` always holds only hashable values itself, a custom key function can be written that handles the `env` keyword argument specially:: def envkey(*args, env={}, **kwargs): key = hashkey(*args, **kwargs) key += tuple(sorted(env.items())) return key The :func:`envkey` function can then be used in decorator declarations like this:: @cached(LRUCache(maxsize=128), key=envkey) def foo(x, y, z, env={}): pass foo(1, 2, 3, env=dict(a='a', b='b')) **************************************************************************** :mod:`cachetools.func` --- :func:`functools.lru_cache` compatible decorators **************************************************************************** .. module:: cachetools.func To ease migration from (or to) Python 3's :func:`functools.lru_cache`, this module provides several memoizing function decorators with a similar API. All these decorators wrap a function with a memoizing callable that saves up to the `maxsize` most recent calls, using different caching strategies. If `maxsize` is set to :const:`None`, the caching strategy is effectively disabled and the cache can grow without bound. If the optional argument `typed` is set to :const:`True`, function arguments of different types will be cached separately. For example, ``f(3)`` and ``f(3.0)`` will be treated as distinct calls with distinct results. If a `user_function` is specified instead, it must be a callable. This allows the decorator to be applied directly to a user function, leaving the `maxsize` at its default value of 128:: @cachetools.func.lru_cache def count_vowels(sentence): sentence = sentence.casefold() return sum(sentence.count(vowel) for vowel in 'aeiou') The wrapped function is instrumented with a :func:`cache_parameters` function that returns a new :class:`dict` showing the values for `maxsize` and `typed`. This is for information purposes only. Mutating the values has no effect. The wrapped function is also instrumented with :func:`cache_info` and :func:`cache_clear` functions to provide information about cache performance and clear the cache. Please see the :func:`functools.lru_cache` documentation for details. Also note that all the decorators in this module are thread-safe by default. .. decorator:: lfu_cache(user_function) lfu_cache(maxsize=128, typed=False) Decorator that wraps a function with a memoizing callable that saves up to `maxsize` results based on a Least Frequently Used (LFU) algorithm. .. decorator:: lru_cache(user_function) lru_cache(maxsize=128, typed=False) Decorator that wraps a function with a memoizing callable that saves up to `maxsize` results based on a Least Recently Used (LRU) algorithm. .. decorator:: rr_cache(user_function) rr_cache(maxsize=128, choice=random.choice, typed=False) Decorator that wraps a function with a memoizing callable that saves up to `maxsize` results based on a Random Replacement (RR) algorithm. .. decorator:: ttl_cache(user_function) ttl_cache(maxsize=128, ttl=600, timer=time.monotonic, typed=False) Decorator to wrap a function with a memoizing callable that saves up to `maxsize` results based on a Least Recently Used (LRU) algorithm with a per-item time-to-live (TTL) value. .. _@lru_cache: http://docs.python.org/3/library/functools.html#functools.lru_cache .. _cache algorithm: http://en.wikipedia.org/wiki/Cache_algorithms .. _context manager: http://docs.python.org/dev/glossary.html#term-context-manager .. _mapping: http://docs.python.org/dev/glossary.html#term-mapping .. _mutable: http://docs.python.org/dev/glossary.html#term-mutable napari-0.5.0a1/napari/_vendor/experimental/humanize/000077500000000000000000000000001437041365600224445ustar00rootroot00000000000000napari-0.5.0a1/napari/_vendor/experimental/humanize/LICENCE000066400000000000000000000020661437041365600234350ustar00rootroot00000000000000Copyright (c) 2010-2020 Jason Moiron and Contributors 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. napari-0.5.0a1/napari/_vendor/experimental/humanize/README.md000066400000000000000000000133461437041365600237320ustar00rootroot00000000000000# humanize [![PyPI version](https://img.shields.io/pypi/v/humanize.svg?logo=pypi&logoColor=FFE873)](https://pypi.org/project/humanize/) [![Supported Python versions](https://img.shields.io/pypi/pyversions/humanize.svg?logo=python&logoColor=FFE873)](https://pypi.org/project/humanize/) [![Documentation Status](https://readthedocs.org/projects/python-humanize/badge/?version=latest)](https://python-humanize.readthedocs.io/en/latest/?badge=latest) [![PyPI downloads](https://img.shields.io/pypi/dm/humanize.svg)](https://pypistats.org/packages/humanize) [![Travis CI Status](https://travis-ci.com/hugovk/humanize.svg?branch=master)](https://travis-ci.com/hugovk/humanize) [![GitHub Actions status](https://github.com/jmoiron/humanize/workflows/Test/badge.svg)](https://github.com/jmoiron/humanize/actions) [![codecov](https://codecov.io/gh/hugovk/humanize/branch/master/graph/badge.svg)](https://codecov.io/gh/hugovk/humanize) [![MIT License](https://img.shields.io/github/license/jmoiron/humanize.svg)](LICENCE) [![Tidelift](https://tidelift.com/badges/package/pypi/humanize)](https://tidelift.com/subscription/pkg/pypi-humanize?utm_source=pypi-humanize&utm_medium=badge) This modest package contains various common humanization utilities, like turning a number into a fuzzy human readable duration ("3 minutes ago") or into a human readable size or throughput. It is localized to: * Brazilian Portuguese * Dutch * European Portuguese * Finnish * French * German * Indonesian * Italian * Japanese * Korean * Persian * Polish * Russian * Simplified Chinese * Slovak * Spanish * Turkish * Ukrainian * Vietnamese ## Usage ### Integer humanization ```pycon >>> import humanize >>> humanize.intcomma(12345) '12,345' >>> humanize.intword(123455913) '123.5 million' >>> humanize.intword(12345591313) '12.3 billion' >>> humanize.apnumber(4) 'four' >>> humanize.apnumber(41) '41' ``` ### Date & time humanization ```pycon >>> import humanize >>> import datetime as dt >>> humanize.naturalday(dt.datetime.now()) 'today' >>> humanize.naturaldelta(dt.timedelta(seconds=1001)) '16 minutes' >>> humanize.naturalday(dt.datetime.now() - dt.timedelta(days=1)) 'yesterday' >>> humanize.naturalday(dt.date(2007, 6, 5)) 'Jun 05' >>> humanize.naturaldate(dt.date(2007, 6, 5)) 'Jun 05 2007' >>> humanize.naturaltime(dt.datetime.now() - dt.timedelta(seconds=1)) 'a second ago' >>> humanize.naturaltime(dt.datetime.now() - dt.timedelta(seconds=3600)) 'an hour ago' ``` ### Precise time delta ```pycon >>> import humanize >>> import datetime as dt >>> delta = dt.timedelta(seconds=3633, days=2, microseconds=123000) >>> humanize.precisedelta(delta) '2 days, 1 hour and 33.12 seconds' >>> humanize.precisedelta(delta, minimum_unit="microseconds") '2 days, 1 hour, 33 seconds and 123 milliseconds' >>> humanize.precisedelta(delta, suppress=["days"], format="%0.4f") '49 hours and 33.1230 seconds' ``` #### Smaller units If seconds are too large, set `minimum_unit` to milliseconds or microseconds: ```pycon >>> import humanize >>> import datetime as dt >>> humanize.naturaldelta(dt.timedelta(seconds=2)) '2 seconds' ``` ```pycon >>> delta = dt.timedelta(milliseconds=4) >>> humanize.naturaldelta(delta) 'a moment' >>> humanize.naturaldelta(delta, minimum_unit="milliseconds") '4 milliseconds' >>> humanize.naturaldelta(delta, minimum_unit="microseconds") '4000 microseconds' ``` ```pycon >>> humanize.naturaltime(delta) 'now' >>> humanize.naturaltime(delta, minimum_unit="milliseconds") '4 milliseconds ago' >>> humanize.naturaltime(delta, minimum_unit="microseconds") '4000 microseconds ago' ``` ### File size humanization ```pycon >>> import humanize >>> humanize.naturalsize(1_000_000) '1.0 MB' >>> humanize.naturalsize(1_000_000, binary=True) '976.6 KiB' >>> humanize.naturalsize(1_000_000, gnu=True) '976.6K' ``` ### Human-readable floating point numbers ```pycon >>> import humanize >>> humanize.fractional(1/3) '1/3' >>> humanize.fractional(1.5) '1 1/2' >>> humanize.fractional(0.3) '3/10' >>> humanize.fractional(0.333) '333/1000' >>> humanize.fractional(1) '1' ``` ### Scientific notation ```pycon >>> import humanize >>> humanize.scientific(0.3) '3.00 x 10⁻¹' >>> humanize.scientific(500) '5.00 x 10²' >>> humanize.scientific("20000") '2.00 x 10⁴' >>> humanize.scientific(1**10) '1.00 x 10⁰' >>> humanize.scientific(1**10, precision=1) '1.0 x 10⁰' >>> humanize.scientific(1**10, precision=0) '1 x 10⁰' ``` ## Localization How to change locale at runtime: ```pycon >>> import humanize >>> import datetime as dt >>> humanize.naturaltime(dt.timedelta(seconds=3)) '3 seconds ago' >>> _t = humanize.i18n.activate("ru_RU") >>> humanize.naturaltime(dt.timedelta(seconds=3)) '3 секунды назад' >>> humanize.i18n.deactivate() >>> humanize.naturaltime(dt.timedelta(seconds=3)) '3 seconds ago' ``` You can pass additional parameter `path` to `activate` to specify a path to search locales in. ```pycon >>> import humanize >>> humanize.i18n.activate("xx_XX") <...> FileNotFoundError: [Errno 2] No translation file found for domain: 'humanize' >>> humanize.i18n.activate("pt_BR", path="path/to/my/portuguese/translation/") ``` How to add new phrases to existing locale files: ```console $ xgettext --from-code=UTF-8 -o humanize.pot -k'_' -k'N_' -k'P_:1c,2' -l python src/humanize/*.py # extract new phrases $ msgmerge -U src/humanize/locale/ru_RU/LC_MESSAGES/humanize.po humanize.pot # add them to locale files $ msgfmt --check -o src/humanize/locale/ru_RU/LC_MESSAGES/humanize{.mo,.po} # compile to binary .mo ``` How to add a new locale: ```console $ msginit -i humanize.pot -o humanize/locale//LC_MESSAGES/humanize.po --locale ``` Where `` is a locale abbreviation, eg. `en_GB`, `pt_BR` or just `ru`, `fr` etc. List the language at the top of this README. napari-0.5.0a1/napari/_vendor/experimental/humanize/__init__.py000066400000000000000000000000001437041365600245430ustar00rootroot00000000000000napari-0.5.0a1/napari/_vendor/experimental/humanize/src/000077500000000000000000000000001437041365600232335ustar00rootroot00000000000000napari-0.5.0a1/napari/_vendor/experimental/humanize/src/__init__.py000066400000000000000000000000001437041365600253320ustar00rootroot00000000000000napari-0.5.0a1/napari/_vendor/experimental/humanize/src/humanize/000077500000000000000000000000001437041365600250535ustar00rootroot00000000000000napari-0.5.0a1/napari/_vendor/experimental/humanize/src/humanize/__init__.py000066400000000000000000000011451437041365600271650ustar00rootroot00000000000000import pkg_resources from .filesize import naturalsize from .i18n import activate, deactivate from .number import apnumber, fractional, intcomma, intword, ordinal, scientific from .time import ( naturaldate, naturalday, naturaldelta, naturaltime, precisedelta, ) __version__ = VERSION = "2.5.0" __all__ = [ "__version__", "activate", "apnumber", "deactivate", "fractional", "intcomma", "intword", "naturaldate", "naturalday", "naturaldelta", "naturalsize", "naturaltime", "ordinal", "precisedelta", "scientific", "VERSION", ] napari-0.5.0a1/napari/_vendor/experimental/humanize/src/humanize/filesize.py000066400000000000000000000033441437041365600272430ustar00rootroot00000000000000#!/usr/bin/env python """Bits and bytes related humanization.""" suffixes = { "decimal": ("kB", "MB", "GB", "TB", "PB", "EB", "ZB", "YB"), "binary": ("KiB", "MiB", "GiB", "TiB", "PiB", "EiB", "ZiB", "YiB"), "gnu": "KMGTPEZY", } def naturalsize(value, binary=False, gnu=False, format="%.1f"): """Format a number of bytes like a human readable filesize (e.g. 10 kB). By default, decimal suffixes (kB, MB) are used. Non-GNU modes are compatible with jinja2's `filesizeformat` filter. Args: value (int, float, str): Integer to convert. binary (bool): If `True`, uses binary suffixes (KiB, MiB) with base 210 instead of 103. gnu (bool): If `True`, the binary argument is ignored and GNU-style (`ls -sh` style) prefixes are used (K, M) with the 2**10 definition. format (str): Custom formatter. """ if gnu: suffix = suffixes["gnu"] elif binary: suffix = suffixes["binary"] else: suffix = suffixes["decimal"] base = 1024 if (gnu or binary) else 1000 bytes = float(value) abs_bytes = abs(bytes) if abs_bytes == 1 and not gnu: return "%d Byte" % bytes elif abs_bytes < base and not gnu: return "%d Bytes" % bytes elif abs_bytes < base and gnu: return "%dB" % bytes for i, s in enumerate(suffix): unit = base ** (i + 2) if abs_bytes < unit and not gnu: return (format + " %s") % ((base * bytes / unit), s) elif abs_bytes < unit and gnu: return (format + "%s") % ((base * bytes / unit), s) if gnu: return (format + "%s") % ((base * bytes / unit), s) return (format + " %s") % ((base * bytes / unit), s) napari-0.5.0a1/napari/_vendor/experimental/humanize/src/humanize/i18n.py000066400000000000000000000063101437041365600262040ustar00rootroot00000000000000import gettext as gettext_module import os.path from threading import local __all__ = ["activate", "deactivate", "gettext", "ngettext"] _TRANSLATIONS = {None: gettext_module.NullTranslations()} _CURRENT = local() def _get_default_locale_path(): try: if __file__ is None: return None return os.path.join(os.path.dirname(__file__), "locale") except NameError: return None def get_translation(): try: return _TRANSLATIONS[_CURRENT.locale] except (AttributeError, KeyError): return _TRANSLATIONS[None] def activate(locale, path=None): """Activate internationalisation. Set `locale` as current locale. Search for locale in directory `path`. Args: locale (str): Language name, e.g. `en_GB`. path (str): Path to search for locales. Returns: dict: Translations. """ if path is None: path = _get_default_locale_path() if path is None: raise Exception( "Humanize cannot determinate the default location of the 'locale' folder. " "You need to pass the path explicitly." ) if locale not in _TRANSLATIONS: translation = gettext_module.translation("humanize", path, [locale]) _TRANSLATIONS[locale] = translation _CURRENT.locale = locale return _TRANSLATIONS[locale] def deactivate(): """Deactivate internationalisation.""" _CURRENT.locale = None def gettext(message): """Get translation. Args: message (str): Text to translate. Returns: str: Translated text. """ return get_translation().gettext(message) def pgettext(msgctxt, message): """'Particular gettext' function. It works with `msgctxt` .po modifiers and allows duplicate keys with different translations. Args: msgctxt (str): Context of the translation. message (str): Text to translate. Returns: str: Translated text. """ # This GNU gettext function was added in Python 3.8, so for older versions we # reimplement it. It works by joining `msgctx` and `message` by '4' byte. try: # Python 3.8+ return get_translation().pgettext(msgctxt, message) except AttributeError: # Python 3.7 and older key = msgctxt + "\x04" + message translation = get_translation().gettext(key) return message if translation == key else translation def ngettext(message, plural, num): """Plural version of gettext. Args: message (str): Singlular text to translate. plural (str): Plural text to translate. num (str): The number (e.g. item count) to determine translation for the respective grammatical number. Returns: str: Translated text. """ return get_translation().ngettext(message, plural, num) def gettext_noop(message): """Mark a string as a translation string without translating it. Example usage: ```python CONSTANTS = [gettext_noop('first'), gettext_noop('second')] def num_name(n): return gettext(CONSTANTS[n]) ``` Args: message (str): Text to translate in the future. Returns: str: Original text, unchanged. """ return message napari-0.5.0a1/napari/_vendor/experimental/humanize/src/humanize/locale/000077500000000000000000000000001437041365600263125ustar00rootroot00000000000000napari-0.5.0a1/napari/_vendor/experimental/humanize/src/humanize/locale/de_DE/000077500000000000000000000000001437041365600272525ustar00rootroot00000000000000napari-0.5.0a1/napari/_vendor/experimental/humanize/src/humanize/locale/de_DE/LC_MESSAGES/000077500000000000000000000000001437041365600310375ustar00rootroot00000000000000humanize.po000066400000000000000000000117531437041365600331470ustar00rootroot00000000000000napari-0.5.0a1/napari/_vendor/experimental/humanize/src/humanize/locale/de_DE/LC_MESSAGES# German translation for humanize. # Copyright (C) 2016 THE PACKAGE'S COPYRIGHT HOLDER # This file is distributed under the same license as the humanize package. # Christian Klein , 2016. # msgid "" msgstr "" "Project-Id-Version: humanize\n" "Report-Msgid-Bugs-To: \n" "POT-Creation-Date: 2020-03-22 16:30+0200\n" "PO-Revision-Date: 2016-12-18 11:50+0100\n" "Last-Translator: Christian Klein \n" "Language-Team: German\n" "Language: de\n" "MIME-Version: 1.0\n" "Content-Type: text/plain; charset=utf-8\n" "Content-Transfer-Encoding: 8bit\n" "Plural-Forms: nplurals=2; plural=(n != 1);\n" "Generated-By: Christian Klein\n" "X-Generator: Sublime Text 3\n" #: src/humanize/number.py:22 msgctxt "0" msgid "th" msgstr "." #: src/humanize/number.py:23 msgctxt "1" msgid "st" msgstr "." #: src/humanize/number.py:24 msgctxt "2" msgid "nd" msgstr "." #: src/humanize/number.py:25 msgctxt "3" msgid "rd" msgstr "." #: src/humanize/number.py:26 msgctxt "4" msgid "th" msgstr "." #: src/humanize/number.py:27 msgctxt "5" msgid "th" msgstr "." #: src/humanize/number.py:28 msgctxt "6" msgid "th" msgstr "." #: src/humanize/number.py:29 msgctxt "7" msgid "th" msgstr "." #: src/humanize/number.py:30 msgctxt "8" msgid "th" msgstr "." #: src/humanize/number.py:31 msgctxt "9" msgid "th" msgstr "." #: src/humanize/number.py:73 msgid "million" msgstr "Million" #: src/humanize/number.py:74 msgid "billion" msgstr "Milliarde" #: src/humanize/number.py:75 msgid "trillion" msgstr "Billion" #: src/humanize/number.py:76 msgid "quadrillion" msgstr "Billiarde" #: src/humanize/number.py:77 msgid "quintillion" msgstr "Trillion" #: src/humanize/number.py:78 msgid "sextillion" msgstr "Trilliarde" #: src/humanize/number.py:79 msgid "septillion" msgstr "Quadrillion" #: src/humanize/number.py:80 msgid "octillion" msgstr "Quadrillarde" #: src/humanize/number.py:81 msgid "nonillion" msgstr "Quintillion" #: src/humanize/number.py:82 msgid "decillion" msgstr "Quintilliarde" #: src/humanize/number.py:83 msgid "googol" msgstr "Googol" #: src/humanize/number.py:138 msgid "zero" msgstr "null" #: src/humanize/number.py:139 msgid "one" msgstr "eins" #: src/humanize/number.py:140 msgid "two" msgstr "zwei" #: src/humanize/number.py:141 msgid "three" msgstr "drei" #: src/humanize/number.py:142 msgid "four" msgstr "vier" #: src/humanize/number.py:143 msgid "five" msgstr "fünf" #: src/humanize/number.py:144 msgid "six" msgstr "sechs" #: src/humanize/number.py:145 msgid "seven" msgstr "sieben" #: src/humanize/number.py:146 msgid "eight" msgstr "acht" #: src/humanize/number.py:147 msgid "nine" msgstr "neun" #: src/humanize/time.py:87 #, fuzzy, python-format msgid "%d microsecond" msgid_plural "%d microseconds" msgstr[0] "%d Mikrosekunde" msgstr[1] "%d Mikrosekunden" #: src/humanize/time.py:93 #, fuzzy, python-format msgid "%d millisecond" msgid_plural "%d milliseconds" msgstr[0] "%d Millisekunde" msgstr[1] "%d Millisekunden" #: src/humanize/time.py:96 src/humanize/time.py:170 msgid "a moment" msgstr "ein Moment" #: src/humanize/time.py:98 msgid "a second" msgstr "eine Sekunde" #: src/humanize/time.py:100 #, python-format msgid "%d second" msgid_plural "%d seconds" msgstr[0] "%d Sekunde" msgstr[1] "%d Sekunden" #: src/humanize/time.py:102 msgid "a minute" msgstr "eine Minute" #: src/humanize/time.py:105 #, python-format msgid "%d minute" msgid_plural "%d minutes" msgstr[0] "%d Minute" msgstr[1] "%d Minuten" #: src/humanize/time.py:107 msgid "an hour" msgstr "eine Stunde" #: src/humanize/time.py:110 #, python-format msgid "%d hour" msgid_plural "%d hours" msgstr[0] "%d Stunde" msgstr[1] "%d Stunden" #: src/humanize/time.py:113 msgid "a day" msgstr "ein Tag" #: src/humanize/time.py:115 src/humanize/time.py:118 #, python-format msgid "%d day" msgid_plural "%d days" msgstr[0] "%d Tag" msgstr[1] "%d Tage" #: src/humanize/time.py:120 msgid "a month" msgstr "ein Monat" #: src/humanize/time.py:122 #, python-format msgid "%d month" msgid_plural "%d months" msgstr[0] "%d Monat" msgstr[1] "%d Monate" #: src/humanize/time.py:125 msgid "a year" msgstr "ein Jahr" #: src/humanize/time.py:127 src/humanize/time.py:136 #, python-format msgid "1 year, %d day" msgid_plural "1 year, %d days" msgstr[0] "ein Jahr und %d Tag" msgstr[1] "ein Jahr und %d Tage" #: src/humanize/time.py:130 msgid "1 year, 1 month" msgstr "ein Monat" #: src/humanize/time.py:133 #, python-format msgid "1 year, %d month" msgid_plural "1 year, %d months" msgstr[0] "ein Jahr und %d Monat" msgstr[1] "ein Jahr und %d Monate" #: src/humanize/time.py:138 #, python-format msgid "%d year" msgid_plural "%d years" msgstr[0] "%d Jahr" msgstr[1] "%d Jahre" #: src/humanize/time.py:167 #, python-format msgid "%s from now" msgstr "%s ab jetzt" #: src/humanize/time.py:167 #, python-format msgid "%s ago" msgstr "vor %s" #: src/humanize/time.py:171 msgid "now" msgstr "jetzt" #: src/humanize/time.py:190 msgid "today" msgstr "heute" #: src/humanize/time.py:192 msgid "tomorrow" msgstr "morgen" #: src/humanize/time.py:194 msgid "yesterday" msgstr "gestern" napari-0.5.0a1/napari/_vendor/experimental/humanize/src/humanize/locale/es_ES/000077500000000000000000000000001437041365600273105ustar00rootroot00000000000000napari-0.5.0a1/napari/_vendor/experimental/humanize/src/humanize/locale/es_ES/LC_MESSAGES/000077500000000000000000000000001437041365600310755ustar00rootroot00000000000000humanize.po000066400000000000000000000116441437041365600332040ustar00rootroot00000000000000napari-0.5.0a1/napari/_vendor/experimental/humanize/src/humanize/locale/es_ES/LC_MESSAGES# Spanish (Spain) translations for PROJECT. # Copyright (C) YEAR THE PACKAGE'S COPYRIGHT HOLDER # This file is distributed under the same license as the PACKAGE package. # Álvaro Mondéjar , 2020. # msgid "" msgstr "" "Project-Id-Version: \n" "Report-Msgid-Bugs-To: \n" "POT-Creation-Date: 2020-03-22 16:30+0200\n" "PO-Revision-Date: 2020-03-31 21:08+0200\n" "Last-Translator: Álvaro Mondéjar \n" "Language-Team: \n" "Language: es_ES\n" "MIME-Version: 1.0\n" "Content-Type: text/plain; charset=UTF-8\n" "Content-Transfer-Encoding: 8bit\n" "Plural-Forms: nplurals=2; plural=(n != 1);\n" "X-Generator: Poedit 2.3\n" #: src/humanize/number.py:22 msgctxt "0" msgid "th" msgstr "º" #: src/humanize/number.py:23 msgctxt "1" msgid "st" msgstr "º" #: src/humanize/number.py:24 msgctxt "2" msgid "nd" msgstr "º" #: src/humanize/number.py:25 msgctxt "3" msgid "rd" msgstr "º" #: src/humanize/number.py:26 msgctxt "4" msgid "th" msgstr "º" #: src/humanize/number.py:27 msgctxt "5" msgid "th" msgstr "º" #: src/humanize/number.py:28 msgctxt "6" msgid "th" msgstr "º" #: src/humanize/number.py:29 msgctxt "7" msgid "th" msgstr "º" #: src/humanize/number.py:30 msgctxt "8" msgid "th" msgstr "º" #: src/humanize/number.py:31 msgctxt "9" msgid "th" msgstr "º" #: src/humanize/number.py:73 msgid "million" msgstr "millón" #: src/humanize/number.py:74 msgid "billion" msgstr "billón" #: src/humanize/number.py:75 msgid "trillion" msgstr "trillón" #: src/humanize/number.py:76 msgid "quadrillion" msgstr "quatrillón" #: src/humanize/number.py:77 msgid "quintillion" msgstr "quintillón" #: src/humanize/number.py:78 msgid "sextillion" msgstr "sextillón" #: src/humanize/number.py:79 msgid "septillion" msgstr "septillón" #: src/humanize/number.py:80 msgid "octillion" msgstr "octillón" #: src/humanize/number.py:81 msgid "nonillion" msgstr "nonillón" #: src/humanize/number.py:82 msgid "decillion" msgstr "decillón" #: src/humanize/number.py:83 msgid "googol" msgstr "gúgol" #: src/humanize/number.py:138 msgid "zero" msgstr "cero" #: src/humanize/number.py:139 msgid "one" msgstr "uno" #: src/humanize/number.py:140 msgid "two" msgstr "dos" #: src/humanize/number.py:141 msgid "three" msgstr "tres" #: src/humanize/number.py:142 msgid "four" msgstr "cuatro" #: src/humanize/number.py:143 msgid "five" msgstr "cinco" #: src/humanize/number.py:144 msgid "six" msgstr "seis" #: src/humanize/number.py:145 msgid "seven" msgstr "siete" #: src/humanize/number.py:146 msgid "eight" msgstr "ocho" #: src/humanize/number.py:147 msgid "nine" msgstr "nueve" #: src/humanize/time.py:87 #, python-format msgid "%d microsecond" msgid_plural "%d microseconds" msgstr[0] "%d microsegundo" msgstr[1] "%d microsegundos" #: src/humanize/time.py:93 #, python-format msgid "%d millisecond" msgid_plural "%d milliseconds" msgstr[0] "%d milisegundo" msgstr[1] "%d milisegundos" #: src/humanize/time.py:96 src/humanize/time.py:170 msgid "a moment" msgstr "un momento" #: src/humanize/time.py:98 msgid "a second" msgstr "un segundo" #: src/humanize/time.py:100 #, python-format msgid "%d second" msgid_plural "%d seconds" msgstr[0] "%d segundo" msgstr[1] "%d segundos" #: src/humanize/time.py:102 msgid "a minute" msgstr "un minuto" #: src/humanize/time.py:105 #, python-format msgid "%d minute" msgid_plural "%d minutes" msgstr[0] "%d minuto" msgstr[1] "%d minutos" #: src/humanize/time.py:107 msgid "an hour" msgstr "una hora" #: src/humanize/time.py:110 #, python-format msgid "%d hour" msgid_plural "%d hours" msgstr[0] "%d hora" msgstr[1] "%d horas" #: src/humanize/time.py:113 msgid "a day" msgstr "un día" #: src/humanize/time.py:115 src/humanize/time.py:118 #, python-format msgid "%d day" msgid_plural "%d days" msgstr[0] "%d día" msgstr[1] "%d días" #: src/humanize/time.py:120 msgid "a month" msgstr "un mes" #: src/humanize/time.py:122 #, python-format msgid "%d month" msgid_plural "%d months" msgstr[0] "%d mes" msgstr[1] "%d meses" #: src/humanize/time.py:125 msgid "a year" msgstr "un año" #: src/humanize/time.py:127 src/humanize/time.py:136 #, python-format msgid "1 year, %d day" msgid_plural "1 year, %d days" msgstr[0] "1 año y %d día" msgstr[1] "1 año y %d días" #: src/humanize/time.py:130 msgid "1 year, 1 month" msgstr "1 año y 1 mes" #: src/humanize/time.py:133 #, python-format msgid "1 year, %d month" msgid_plural "1 year, %d months" msgstr[0] "1 año y %d mes" msgstr[1] "1 año y %d meses" #: src/humanize/time.py:138 #, python-format msgid "%d year" msgid_plural "%d years" msgstr[0] "%d año" msgstr[1] "%d años" #: src/humanize/time.py:167 #, python-format msgid "%s from now" msgstr "en %s" #: src/humanize/time.py:167 #, python-format msgid "%s ago" msgstr "hace %s" #: src/humanize/time.py:171 msgid "now" msgstr "ahora" #: src/humanize/time.py:190 msgid "today" msgstr "hoy" #: src/humanize/time.py:192 msgid "tomorrow" msgstr "mañana" #: src/humanize/time.py:194 msgid "yesterday" msgstr "ayer" napari-0.5.0a1/napari/_vendor/experimental/humanize/src/humanize/locale/fa_IR/000077500000000000000000000000001437041365600272725ustar00rootroot00000000000000napari-0.5.0a1/napari/_vendor/experimental/humanize/src/humanize/locale/fa_IR/LC_MESSAGES/000077500000000000000000000000001437041365600310575ustar00rootroot00000000000000humanize.po000066400000000000000000000115371437041365600331670ustar00rootroot00000000000000napari-0.5.0a1/napari/_vendor/experimental/humanize/src/humanize/locale/fa_IR/LC_MESSAGES# German translation for humanize. # Copyright (C) 2016 THE PACKAGE'S COPYRIGHT HOLDER # This file is distributed under the same license as the humanize package. # Christian Klein , 2016. # msgid "" msgstr "" "Project-Id-Version: humanize\n" "Report-Msgid-Bugs-To: \n" "POT-Creation-Date: 2020-02-08 20:21+0200\n" "PO-Revision-Date: 2017-01-10 02:44+0330\n" "Last-Translator: Christian Klein \n" "Language-Team: German\n" "Language: de\n" "MIME-Version: 1.0\n" "Content-Type: text/plain; charset=utf-8\n" "Content-Transfer-Encoding: 8bit\n" "Plural-Forms: nplurals=2; plural=(n != 1);\n" "Generated-By: Christian Klein\n" "X-Generator: Poedit 1.5.4\n" #: src/humanize/number.py:24 msgctxt "0" msgid "th" msgstr "." #: src/humanize/number.py:25 msgctxt "1" msgid "st" msgstr "اولین" #: src/humanize/number.py:26 msgctxt "2" msgid "nd" msgstr "دومین" #: src/humanize/number.py:27 msgctxt "3" msgid "rd" msgstr "سومین" #: src/humanize/number.py:28 msgctxt "4" msgid "th" msgstr "چهارمین" #: src/humanize/number.py:29 msgctxt "5" msgid "th" msgstr "پنجمین" #: src/humanize/number.py:30 msgctxt "6" msgid "th" msgstr "ششمین" #: src/humanize/number.py:31 msgctxt "7" msgid "th" msgstr "هفتمین" #: src/humanize/number.py:32 msgctxt "8" msgid "th" msgstr "هشتمین" #: src/humanize/number.py:33 msgctxt "9" msgid "th" msgstr "نهمین" #: src/humanize/number.py:62 msgid "million" msgstr "میلیون" #: src/humanize/number.py:63 msgid "billion" msgstr "میلیارد" #: src/humanize/number.py:64 msgid "trillion" msgstr "ترلیون" #: src/humanize/number.py:65 msgid "quadrillion" msgstr "کوادریلیون" #: src/humanize/number.py:66 msgid "quintillion" msgstr "کوانتیلیون" #: src/humanize/number.py:67 msgid "sextillion" msgstr "سکستیلیون" #: src/humanize/number.py:68 msgid "septillion" msgstr "سپتیلیون" #: src/humanize/number.py:69 msgid "octillion" msgstr "اوکتیلیون" #: src/humanize/number.py:70 msgid "nonillion" msgstr "نونیلیون" #: src/humanize/number.py:71 msgid "decillion" msgstr "دسیلیون" #: src/humanize/number.py:72 msgid "googol" msgstr "گوگول" #: src/humanize/number.py:108 msgid "one" msgstr "یک" #: src/humanize/number.py:109 msgid "two" msgstr "دو" #: src/humanize/number.py:110 msgid "three" msgstr "سه" #: src/humanize/number.py:111 msgid "four" msgstr "چهار" #: src/humanize/number.py:112 msgid "five" msgstr "پنج" #: src/humanize/number.py:113 msgid "six" msgstr "شش" #: src/humanize/number.py:114 msgid "seven" msgstr "هفت" #: src/humanize/number.py:115 msgid "eight" msgstr "هشت" #: src/humanize/number.py:116 msgid "nine" msgstr "نه" #: src/humanize/time.py:68 src/humanize/time.py:131 msgid "a moment" msgstr "یک لحظه" #: src/humanize/time.py:70 msgid "a second" msgstr "یک ثانیه" #: src/humanize/time.py:72 #, python-format msgid "%d second" msgid_plural "%d seconds" msgstr[0] "%d ثانیه" msgstr[1] "%d ثانیه" #: src/humanize/time.py:74 msgid "a minute" msgstr "یک دقیقه" #: src/humanize/time.py:77 #, python-format msgid "%d minute" msgid_plural "%d minutes" msgstr[0] "%d دقیقه" msgstr[1] "%d دقیقه" #: src/humanize/time.py:79 msgid "an hour" msgstr "یک ساعت" #: src/humanize/time.py:82 #, python-format msgid "%d hour" msgid_plural "%d hours" msgstr[0] "%d ساعت" msgstr[1] "%d ساعت" #: src/humanize/time.py:85 msgid "a day" msgstr "یک روز" #: src/humanize/time.py:87 src/humanize/time.py:90 #, python-format msgid "%d day" msgid_plural "%d days" msgstr[0] "%d روز" msgstr[1] "%d روز" #: src/humanize/time.py:92 msgid "a month" msgstr "یک ماه" #: src/humanize/time.py:94 #, python-format msgid "%d month" msgid_plural "%d months" msgstr[0] "%d ماه" msgstr[1] "%d ماه" #: src/humanize/time.py:97 msgid "a year" msgstr "یک سال" #: src/humanize/time.py:99 src/humanize/time.py:108 #, python-format msgid "1 year, %d day" msgid_plural "1 year, %d days" msgstr[0] "۱ سال و %d روز" msgstr[1] "۱ سال و %d روز" #: src/humanize/time.py:102 msgid "1 year, 1 month" msgstr "۱ سال و ۱ ماه" #: src/humanize/time.py:105 #, python-format msgid "1 year, %d month" msgid_plural "1 year, %d months" msgstr[0] "۱ سال و %d ماه" msgstr[1] "۱ سال و %d ماه" #: src/humanize/time.py:110 #, python-format msgid "%d year" msgid_plural "%d years" msgstr[0] "%d سال" msgstr[1] "%d سال" #: src/humanize/time.py:128 #, python-format msgid "%s from now" msgstr "%s تا به اکنون" #: src/humanize/time.py:128 #, python-format msgid "%s ago" msgstr "%s پیش" #: src/humanize/time.py:132 msgid "now" msgstr "اکنون" #: src/humanize/time.py:151 msgid "today" msgstr "امروز" #: src/humanize/time.py:153 msgid "tomorrow" msgstr "فردا" #: src/humanize/time.py:155 msgid "yesterday" msgstr "دیروز" napari-0.5.0a1/napari/_vendor/experimental/humanize/src/humanize/locale/fi_FI/000077500000000000000000000000001437041365600272665ustar00rootroot00000000000000napari-0.5.0a1/napari/_vendor/experimental/humanize/src/humanize/locale/fi_FI/LC_MESSAGES/000077500000000000000000000000001437041365600310535ustar00rootroot00000000000000humanize.po000066400000000000000000000120021437041365600331470ustar00rootroot00000000000000napari-0.5.0a1/napari/_vendor/experimental/humanize/src/humanize/locale/fi_FI/LC_MESSAGES# Finnish translations for humanize package # Copyright (C) 2017 THE PACKAGE'S COPYRIGHT HOLDER # This file is distributed under the same license as the humanize package. # Ville Skyttä , 2017. # msgid "" msgstr "" "Project-Id-Version: humanize\n" "Report-Msgid-Bugs-To: \n" "POT-Creation-Date: 2020-03-22 16:30+0200\n" "PO-Revision-Date: 2017-03-02 11:26+0200\n" "Last-Translator: Ville Skyttä \n" "Language-Team: Finnish\n" "Language: fi\n" "MIME-Version: 1.0\n" "Content-Type: text/plain; charset=UTF-8\n" "Content-Transfer-Encoding: 8bit\n" "Plural-Forms: nplurals=2; plural=(n != 1);\n" "X-Generator: Poedit 1.8.12\n" #: src/humanize/number.py:22 msgctxt "0" msgid "th" msgstr "." #: src/humanize/number.py:23 msgctxt "1" msgid "st" msgstr "." #: src/humanize/number.py:24 msgctxt "2" msgid "nd" msgstr "." #: src/humanize/number.py:25 msgctxt "3" msgid "rd" msgstr "." #: src/humanize/number.py:26 msgctxt "4" msgid "th" msgstr "." #: src/humanize/number.py:27 msgctxt "5" msgid "th" msgstr "." #: src/humanize/number.py:28 msgctxt "6" msgid "th" msgstr "." #: src/humanize/number.py:29 msgctxt "7" msgid "th" msgstr "." #: src/humanize/number.py:30 msgctxt "8" msgid "th" msgstr "." #: src/humanize/number.py:31 msgctxt "9" msgid "th" msgstr "." #: src/humanize/number.py:73 msgid "million" msgstr "miljoonaa" #: src/humanize/number.py:74 msgid "billion" msgstr "miljardia" #: src/humanize/number.py:75 msgid "trillion" msgstr "biljoonaa" #: src/humanize/number.py:76 msgid "quadrillion" msgstr "kvadriljoonaa" #: src/humanize/number.py:77 msgid "quintillion" msgstr "kvintiljoonaa" #: src/humanize/number.py:78 msgid "sextillion" msgstr "sekstiljoonaa" #: src/humanize/number.py:79 msgid "septillion" msgstr "septiljoonaa" #: src/humanize/number.py:80 msgid "octillion" msgstr "oktiljoonaa" #: src/humanize/number.py:81 msgid "nonillion" msgstr "noniljoonaa" #: src/humanize/number.py:82 msgid "decillion" msgstr "dekiljoonaa" #: src/humanize/number.py:83 msgid "googol" msgstr "googol" #: src/humanize/number.py:138 msgid "zero" msgstr "nolla" #: src/humanize/number.py:139 msgid "one" msgstr "yksi" #: src/humanize/number.py:140 msgid "two" msgstr "kaksi" #: src/humanize/number.py:141 msgid "three" msgstr "kolme" #: src/humanize/number.py:142 msgid "four" msgstr "neljä" #: src/humanize/number.py:143 msgid "five" msgstr "viisi" #: src/humanize/number.py:144 msgid "six" msgstr "kuusi" #: src/humanize/number.py:145 msgid "seven" msgstr "seitsemän" #: src/humanize/number.py:146 msgid "eight" msgstr "kahdeksan" #: src/humanize/number.py:147 msgid "nine" msgstr "yhdeksän" #: src/humanize/time.py:87 #, fuzzy, python-format msgid "%d microsecond" msgid_plural "%d microseconds" msgstr[0] "%d mikrosekunti" msgstr[1] "%d mikrosekuntia" #: src/humanize/time.py:93 #, fuzzy, python-format msgid "%d millisecond" msgid_plural "%d milliseconds" msgstr[0] "%d millisekunti" msgstr[1] "%d millisekuntia" #: src/humanize/time.py:96 src/humanize/time.py:170 msgid "a moment" msgstr "hetki" #: src/humanize/time.py:98 msgid "a second" msgstr "sekunti" #: src/humanize/time.py:100 #, python-format msgid "%d second" msgid_plural "%d seconds" msgstr[0] "%d sekunti" msgstr[1] "%d sekuntia" #: src/humanize/time.py:102 msgid "a minute" msgstr "minuutti" #: src/humanize/time.py:105 #, python-format msgid "%d minute" msgid_plural "%d minutes" msgstr[0] "%d minuutti" msgstr[1] "%d minuuttia" #: src/humanize/time.py:107 msgid "an hour" msgstr "tunti" #: src/humanize/time.py:110 #, python-format msgid "%d hour" msgid_plural "%d hours" msgstr[0] "%d tunti" msgstr[1] "%d tuntia" #: src/humanize/time.py:113 msgid "a day" msgstr "päivä" #: src/humanize/time.py:115 src/humanize/time.py:118 #, python-format msgid "%d day" msgid_plural "%d days" msgstr[0] "%d päivä" msgstr[1] "%d päivää" #: src/humanize/time.py:120 msgid "a month" msgstr "kuukausi" #: src/humanize/time.py:122 #, python-format msgid "%d month" msgid_plural "%d months" msgstr[0] "%d kuukausi" msgstr[1] "%d kuukautta" #: src/humanize/time.py:125 msgid "a year" msgstr "vuosi" #: src/humanize/time.py:127 src/humanize/time.py:136 #, python-format msgid "1 year, %d day" msgid_plural "1 year, %d days" msgstr[0] "1 vuosi, %d päivä" msgstr[1] "1 vuosi, %d päivää" #: src/humanize/time.py:130 msgid "1 year, 1 month" msgstr "1 vuosi, 1 kuukausi" #: src/humanize/time.py:133 #, python-format msgid "1 year, %d month" msgid_plural "1 year, %d months" msgstr[0] "1 vuosi, %d kuukausi" msgstr[1] "1 vuosi, %d kuukautta" #: src/humanize/time.py:138 #, python-format msgid "%d year" msgid_plural "%d years" msgstr[0] "%d vuosi" msgstr[1] "%d vuotta" #: src/humanize/time.py:167 #, python-format msgid "%s from now" msgstr "%s tästä" #: src/humanize/time.py:167 #, python-format msgid "%s ago" msgstr "%s sitten" #: src/humanize/time.py:171 msgid "now" msgstr "nyt" #: src/humanize/time.py:190 msgid "today" msgstr "tänään" #: src/humanize/time.py:192 msgid "tomorrow" msgstr "huomenna" #: src/humanize/time.py:194 msgid "yesterday" msgstr "eilen" napari-0.5.0a1/napari/_vendor/experimental/humanize/src/humanize/locale/fr_FR/000077500000000000000000000000001437041365600273105ustar00rootroot00000000000000napari-0.5.0a1/napari/_vendor/experimental/humanize/src/humanize/locale/fr_FR/LC_MESSAGES/000077500000000000000000000000001437041365600310755ustar00rootroot00000000000000humanize.po000066400000000000000000000123441437041365600332020ustar00rootroot00000000000000napari-0.5.0a1/napari/_vendor/experimental/humanize/src/humanize/locale/fr_FR/LC_MESSAGES# French (France) translations for PROJECT. # Copyright (C) 2013 ORGANIZATION # This file is distributed under the same license as the PROJECT project. # FIRST AUTHOR , 2013. # msgid "" msgstr "" "Project-Id-Version: PROJECT VERSION\n" "Report-Msgid-Bugs-To: \n" "POT-Creation-Date: 2020-03-22 16:30+0200\n" "PO-Revision-Date: 2013-06-22 08:52+0100\n" "Last-Translator: Olivier Cortès \n" "Language-Team: fr_FR \n" "Language: fr\n" "MIME-Version: 1.0\n" "Content-Type: text/plain; charset=utf-8\n" "Content-Transfer-Encoding: 8bit\n" "Plural-Forms: nplurals=2; plural=(n > 1);\n" "Generated-By: Babel 0.9.6\n" "X-Generator: Poedit 1.5.5\n" #: src/humanize/number.py:22 #, fuzzy msgctxt "0" msgid "th" msgstr "e" #: src/humanize/number.py:23 #, fuzzy msgctxt "1" msgid "st" msgstr "er" #: src/humanize/number.py:24 #, fuzzy msgctxt "2" msgid "nd" msgstr "e" #: src/humanize/number.py:25 #, fuzzy msgctxt "3" msgid "rd" msgstr "e" #: src/humanize/number.py:26 #, fuzzy msgctxt "4" msgid "th" msgstr "e" #: src/humanize/number.py:27 #, fuzzy msgctxt "5" msgid "th" msgstr "e" #: src/humanize/number.py:28 #, fuzzy msgctxt "6" msgid "th" msgstr "e" #: src/humanize/number.py:29 #, fuzzy msgctxt "7" msgid "th" msgstr "e" #: src/humanize/number.py:30 #, fuzzy msgctxt "8" msgid "th" msgstr "e" #: src/humanize/number.py:31 #, fuzzy msgctxt "9" msgid "th" msgstr "e" #: src/humanize/number.py:73 #, fuzzy msgid "million" msgstr "%(value)s million" #: src/humanize/number.py:74 msgid "billion" msgstr "milliard" #: src/humanize/number.py:75 #, fuzzy msgid "trillion" msgstr "%(value)s billion" #: src/humanize/number.py:76 #, fuzzy msgid "quadrillion" msgstr "%(value)s billiard" #: src/humanize/number.py:77 #, fuzzy msgid "quintillion" msgstr "%(value)s trillion" #: src/humanize/number.py:78 #, fuzzy msgid "sextillion" msgstr "%(value)s trilliard" #: src/humanize/number.py:79 #, fuzzy msgid "septillion" msgstr "%(value)s quatrillion" #: src/humanize/number.py:80 #, fuzzy msgid "octillion" msgstr "%(value)s quadrilliard" #: src/humanize/number.py:81 #, fuzzy msgid "nonillion" msgstr "%(value)s quintillion" #: src/humanize/number.py:82 #, fuzzy msgid "decillion" msgstr "%(value)s quintilliard" #: src/humanize/number.py:83 #, fuzzy msgid "googol" msgstr "%(value)s gogol" #: src/humanize/number.py:138 msgid "zero" msgstr "zéro" #: src/humanize/number.py:139 msgid "one" msgstr "un" #: src/humanize/number.py:140 msgid "two" msgstr "deux" #: src/humanize/number.py:141 msgid "three" msgstr "trois" #: src/humanize/number.py:142 msgid "four" msgstr "quatre" #: src/humanize/number.py:143 msgid "five" msgstr "cinq" #: src/humanize/number.py:144 msgid "six" msgstr "six" #: src/humanize/number.py:145 msgid "seven" msgstr "sept" #: src/humanize/number.py:146 msgid "eight" msgstr "huit" #: src/humanize/number.py:147 msgid "nine" msgstr "neuf" #: src/humanize/time.py:87 #, fuzzy, python-format msgid "%d microsecond" msgid_plural "%d microseconds" msgstr[0] "%d microseconde" msgstr[1] "%d microsecondes" #: src/humanize/time.py:93 #, fuzzy, python-format msgid "%d millisecond" msgid_plural "%d milliseconds" msgstr[0] "%d milliseconde" msgstr[1] "%d millisecondes" #: src/humanize/time.py:96 src/humanize/time.py:170 msgid "a moment" msgstr "un moment" #: src/humanize/time.py:98 msgid "a second" msgstr "une seconde" #: src/humanize/time.py:100 #, python-format msgid "%d second" msgid_plural "%d seconds" msgstr[0] "%d seconde" msgstr[1] "%d secondes" #: src/humanize/time.py:102 msgid "a minute" msgstr "une minute" #: src/humanize/time.py:105 #, python-format msgid "%d minute" msgid_plural "%d minutes" msgstr[0] "%d minute" msgstr[1] "%d minutes" #: src/humanize/time.py:107 msgid "an hour" msgstr "une heure" #: src/humanize/time.py:110 #, python-format msgid "%d hour" msgid_plural "%d hours" msgstr[0] "%d heure" msgstr[1] "%d heures" #: src/humanize/time.py:113 msgid "a day" msgstr "un jour" #: src/humanize/time.py:115 src/humanize/time.py:118 #, python-format msgid "%d day" msgid_plural "%d days" msgstr[0] "%d jour" msgstr[1] "%d jours" #: src/humanize/time.py:120 msgid "a month" msgstr "un mois" #: src/humanize/time.py:122 #, python-format msgid "%d month" msgid_plural "%d months" msgstr[0] "%d mois" msgstr[1] "%d mois" #: src/humanize/time.py:125 msgid "a year" msgstr "un an" #: src/humanize/time.py:127 src/humanize/time.py:136 #, python-format msgid "1 year, %d day" msgid_plural "1 year, %d days" msgstr[0] "un an et %d jour" msgstr[1] "un an et %d jours" #: src/humanize/time.py:130 msgid "1 year, 1 month" msgstr "un an et un mois" #: src/humanize/time.py:133 #, python-format msgid "1 year, %d month" msgid_plural "1 year, %d months" msgstr[0] "un an et %d mois" msgstr[1] "un an et %d mois" #: src/humanize/time.py:138 #, python-format msgid "%d year" msgid_plural "%d years" msgstr[0] "%d an" msgstr[1] "%d ans" #: src/humanize/time.py:167 #, python-format msgid "%s from now" msgstr "dans %s" #: src/humanize/time.py:167 #, python-format msgid "%s ago" msgstr "il y a %s" #: src/humanize/time.py:171 msgid "now" msgstr "maintenant" #: src/humanize/time.py:190 msgid "today" msgstr "aujourd'hui" #: src/humanize/time.py:192 msgid "tomorrow" msgstr "demain" #: src/humanize/time.py:194 msgid "yesterday" msgstr "hier" napari-0.5.0a1/napari/_vendor/experimental/humanize/src/humanize/locale/id_ID/000077500000000000000000000000001437041365600272625ustar00rootroot00000000000000napari-0.5.0a1/napari/_vendor/experimental/humanize/src/humanize/locale/id_ID/LC_MESSAGES/000077500000000000000000000000001437041365600310475ustar00rootroot00000000000000humanize.po000066400000000000000000000105231437041365600331510ustar00rootroot00000000000000napari-0.5.0a1/napari/_vendor/experimental/humanize/src/humanize/locale/id_ID/LC_MESSAGES# Indonesian translations for PACKAGE package. # Copyright (C) 2017 THE PACKAGE'S COPYRIGHT HOLDER # This file is distributed under the same license as the PACKAGE package. # , 2017. # msgid "" msgstr "" "Project-Id-Version: \n" "Report-Msgid-Bugs-To: \n" "POT-Creation-Date: 2020-02-08 20:05+0200\n" "PO-Revision-Date: 2017-03-18 15:41+0700\n" "Last-Translator: adie.rebel@gmail.com\n" "Language-Team: Indonesian\n" "Language: id\n" "MIME-Version: 1.0\n" "Content-Type: text/plain; charset=ASCII\n" "Content-Transfer-Encoding: 8bit\n" "Plural-Forms: nplurals=1; plural=0;\n" "X-Generator: Poedit 1.8.11\n" #: src/humanize/number.py:24 msgctxt "0" msgid "th" msgstr "." #: src/humanize/number.py:25 msgctxt "1" msgid "st" msgstr "." #: src/humanize/number.py:26 msgctxt "2" msgid "nd" msgstr "." #: src/humanize/number.py:27 msgctxt "3" msgid "rd" msgstr "." #: src/humanize/number.py:28 msgctxt "4" msgid "th" msgstr "." #: src/humanize/number.py:29 msgctxt "5" msgid "th" msgstr "." #: src/humanize/number.py:30 msgctxt "6" msgid "th" msgstr "." #: src/humanize/number.py:31 msgctxt "7" msgid "th" msgstr "." #: src/humanize/number.py:32 msgctxt "8" msgid "th" msgstr "." #: src/humanize/number.py:33 msgctxt "9" msgid "th" msgstr "." #: src/humanize/number.py:62 msgid "million" msgstr "juta" #: src/humanize/number.py:63 msgid "billion" msgstr "miliar" #: src/humanize/number.py:64 msgid "trillion" msgstr "triliun" #: src/humanize/number.py:65 msgid "quadrillion" msgstr "quadrillion" #: src/humanize/number.py:66 msgid "quintillion" msgstr "quintillion" #: src/humanize/number.py:67 msgid "sextillion" msgstr "sextillion" #: src/humanize/number.py:68 msgid "septillion" msgstr "septillion" #: src/humanize/number.py:69 msgid "octillion" msgstr "octillion" #: src/humanize/number.py:70 msgid "nonillion" msgstr "nonillion" #: src/humanize/number.py:71 msgid "decillion" msgstr "decillion" #: src/humanize/number.py:72 msgid "googol" msgstr "googol" #: src/humanize/number.py:108 msgid "one" msgstr "satu" #: src/humanize/number.py:109 msgid "two" msgstr "dua" #: src/humanize/number.py:110 msgid "three" msgstr "tiga" #: src/humanize/number.py:111 msgid "four" msgstr "empat" #: src/humanize/number.py:112 msgid "five" msgstr "lima" #: src/humanize/number.py:113 msgid "six" msgstr "enam" #: src/humanize/number.py:114 msgid "seven" msgstr "tujuh" #: src/humanize/number.py:115 msgid "eight" msgstr "delapan" #: src/humanize/number.py:116 msgid "nine" msgstr "sembilan" #: src/humanize/time.py:68 src/humanize/time.py:131 msgid "a moment" msgstr "beberapa saat" #: src/humanize/time.py:70 msgid "a second" msgstr "sedetik" #: src/humanize/time.py:72 #, python-format msgid "%d second" msgid_plural "%d seconds" msgstr[0] "%d detik" #: src/humanize/time.py:74 msgid "a minute" msgstr "semenit" #: src/humanize/time.py:77 #, python-format msgid "%d minute" msgid_plural "%d minutes" msgstr[0] "%d menit" #: src/humanize/time.py:79 msgid "an hour" msgstr "sejam" #: src/humanize/time.py:82 #, python-format msgid "%d hour" msgid_plural "%d hours" msgstr[0] "%d jam" #: src/humanize/time.py:85 msgid "a day" msgstr "sehari" #: src/humanize/time.py:87 src/humanize/time.py:90 #, python-format msgid "%d day" msgid_plural "%d days" msgstr[0] "%d hari" #: src/humanize/time.py:92 msgid "a month" msgstr "sebulan" #: src/humanize/time.py:94 #, python-format msgid "%d month" msgid_plural "%d months" msgstr[0] "%d bulan" #: src/humanize/time.py:97 msgid "a year" msgstr "setahun" #: src/humanize/time.py:99 src/humanize/time.py:108 #, python-format msgid "1 year, %d day" msgid_plural "1 year, %d days" msgstr[0] "1 tahun, %d hari" #: src/humanize/time.py:102 msgid "1 year, 1 month" msgstr "1 tahun, 1 bulan" #: src/humanize/time.py:105 #, python-format msgid "1 year, %d month" msgid_plural "1 year, %d months" msgstr[0] "1 tahun, %d bulan" #: src/humanize/time.py:110 #, python-format msgid "%d year" msgid_plural "%d years" msgstr[0] "%d tahun" #: src/humanize/time.py:128 #, python-format msgid "%s from now" msgstr "%s dari sekarang" #: src/humanize/time.py:128 #, python-format msgid "%s ago" msgstr "%s yang lalu" #: src/humanize/time.py:132 msgid "now" msgstr "sekarang" #: src/humanize/time.py:151 msgid "today" msgstr "hari ini" #: src/humanize/time.py:153 msgid "tomorrow" msgstr "besok" #: src/humanize/time.py:155 msgid "yesterday" msgstr "kemarin" napari-0.5.0a1/napari/_vendor/experimental/humanize/src/humanize/locale/it_IT/000077500000000000000000000000001437041365600273225ustar00rootroot00000000000000napari-0.5.0a1/napari/_vendor/experimental/humanize/src/humanize/locale/it_IT/LC_MESSAGES/000077500000000000000000000000001437041365600311075ustar00rootroot00000000000000humanize.po000066400000000000000000000116341437041365600332150ustar00rootroot00000000000000napari-0.5.0a1/napari/_vendor/experimental/humanize/src/humanize/locale/it_IT/LC_MESSAGES# Italian translations for PACKAGE package. # Copyright (C) 2018 THE PACKAGE'S COPYRIGHT HOLDER # This file is distributed under the same license as the PACKAGE package. # derfel , 2018. # msgid "" msgstr "" "Project-Id-Version: \n" "Report-Msgid-Bugs-To: \n" "POT-Creation-Date: 2020-03-22 16:30+0200\n" "PO-Revision-Date: 2018-10-27 22:52+0200\n" "Last-Translator: derfel \n" "Language-Team: Italian\n" "Language: it\n" "MIME-Version: 1.0\n" "Content-Type: text/plain; charset=UTF-8\n" "Content-Transfer-Encoding: 8bit\n" "Plural-Forms: nplurals=2; plural=(n != 1);\n" "X-Generator: Poedit 2.2\n" #: src/humanize/number.py:22 msgctxt "0" msgid "th" msgstr "º" #: src/humanize/number.py:23 msgctxt "1" msgid "st" msgstr "º" #: src/humanize/number.py:24 msgctxt "2" msgid "nd" msgstr "º" #: src/humanize/number.py:25 msgctxt "3" msgid "rd" msgstr "º" #: src/humanize/number.py:26 msgctxt "4" msgid "th" msgstr "º" #: src/humanize/number.py:27 msgctxt "5" msgid "th" msgstr "º" #: src/humanize/number.py:28 msgctxt "6" msgid "th" msgstr "º" #: src/humanize/number.py:29 msgctxt "7" msgid "th" msgstr "º" #: src/humanize/number.py:30 msgctxt "8" msgid "th" msgstr "º" #: src/humanize/number.py:31 msgctxt "9" msgid "th" msgstr "º" #: src/humanize/number.py:73 msgid "million" msgstr "milioni" #: src/humanize/number.py:74 msgid "billion" msgstr "miliardi" #: src/humanize/number.py:75 msgid "trillion" msgstr "bilioni" #: src/humanize/number.py:76 msgid "quadrillion" msgstr "biliardi" #: src/humanize/number.py:77 msgid "quintillion" msgstr "trilioni" #: src/humanize/number.py:78 msgid "sextillion" msgstr "triliardi" #: src/humanize/number.py:79 msgid "septillion" msgstr "quadrilioni" #: src/humanize/number.py:80 msgid "octillion" msgstr "quadriliardi" #: src/humanize/number.py:81 msgid "nonillion" msgstr "quintilioni" #: src/humanize/number.py:82 msgid "decillion" msgstr "quintiliardi" #: src/humanize/number.py:83 msgid "googol" msgstr "googol" #: src/humanize/number.py:138 msgid "zero" msgstr "zero" #: src/humanize/number.py:139 msgid "one" msgstr "uno" #: src/humanize/number.py:140 msgid "two" msgstr "due" #: src/humanize/number.py:141 msgid "three" msgstr "tre" #: src/humanize/number.py:142 msgid "four" msgstr "quattro" #: src/humanize/number.py:143 msgid "five" msgstr "cinque" #: src/humanize/number.py:144 msgid "six" msgstr "sei" #: src/humanize/number.py:145 msgid "seven" msgstr "sette" #: src/humanize/number.py:146 msgid "eight" msgstr "otto" #: src/humanize/number.py:147 msgid "nine" msgstr "nove" #: src/humanize/time.py:87 #, fuzzy, python-format msgid "%d microsecond" msgid_plural "%d microseconds" msgstr[0] "%d microsecondo" msgstr[1] "%d microsecondi" #: src/humanize/time.py:93 #, fuzzy, python-format msgid "%d millisecond" msgid_plural "%d milliseconds" msgstr[0] "%d millisecondo" msgstr[1] "%d millisecondi" #: src/humanize/time.py:96 src/humanize/time.py:170 msgid "a moment" msgstr "un momento" #: src/humanize/time.py:98 msgid "a second" msgstr "un secondo" #: src/humanize/time.py:100 #, python-format msgid "%d second" msgid_plural "%d seconds" msgstr[0] "%d secondo" msgstr[1] "%d secondi" #: src/humanize/time.py:102 msgid "a minute" msgstr "un minuto" #: src/humanize/time.py:105 #, python-format msgid "%d minute" msgid_plural "%d minutes" msgstr[0] "%d minuto" msgstr[1] "%d minuti" #: src/humanize/time.py:107 msgid "an hour" msgstr "un'ora" #: src/humanize/time.py:110 #, python-format msgid "%d hour" msgid_plural "%d hours" msgstr[0] "%d ora" msgstr[1] "%d ore" #: src/humanize/time.py:113 msgid "a day" msgstr "un giorno" #: src/humanize/time.py:115 src/humanize/time.py:118 #, python-format msgid "%d day" msgid_plural "%d days" msgstr[0] "%d giorno" msgstr[1] "%d giorni" #: src/humanize/time.py:120 msgid "a month" msgstr "un mese" #: src/humanize/time.py:122 #, python-format msgid "%d month" msgid_plural "%d months" msgstr[0] "%d mese" msgstr[1] "%d mesi" #: src/humanize/time.py:125 msgid "a year" msgstr "un anno" #: src/humanize/time.py:127 src/humanize/time.py:136 #, python-format msgid "1 year, %d day" msgid_plural "1 year, %d days" msgstr[0] "un anno e %d giorno" msgstr[1] "un anno e %d giorni" #: src/humanize/time.py:130 msgid "1 year, 1 month" msgstr "un anno ed un mese" #: src/humanize/time.py:133 #, python-format msgid "1 year, %d month" msgid_plural "1 year, %d months" msgstr[0] "un anno e %d mese" msgstr[1] "un anno e %d mesi" #: src/humanize/time.py:138 #, python-format msgid "%d year" msgid_plural "%d years" msgstr[0] "%d anno" msgstr[1] "%d anni" #: src/humanize/time.py:167 #, python-format msgid "%s from now" msgstr "fra %s" #: src/humanize/time.py:167 #, python-format msgid "%s ago" msgstr "%s fa" #: src/humanize/time.py:171 msgid "now" msgstr "adesso" #: src/humanize/time.py:190 msgid "today" msgstr "oggi" #: src/humanize/time.py:192 msgid "tomorrow" msgstr "domani" #: src/humanize/time.py:194 msgid "yesterday" msgstr "ieri" napari-0.5.0a1/napari/_vendor/experimental/humanize/src/humanize/locale/ja_JP/000077500000000000000000000000001437041365600272755ustar00rootroot00000000000000napari-0.5.0a1/napari/_vendor/experimental/humanize/src/humanize/locale/ja_JP/LC_MESSAGES/000077500000000000000000000000001437041365600310625ustar00rootroot00000000000000humanize.po000066400000000000000000000106071437041365600331670ustar00rootroot00000000000000napari-0.5.0a1/napari/_vendor/experimental/humanize/src/humanize/locale/ja_JP/LC_MESSAGES# Japanese (Japan) translations for humanize. # Copyright (C) 2018 # This file is distributed under the same license as the humanize project. # @qoolloop, 2018. # msgid "" msgstr "" "Project-Id-Version: humanize\n" "Report-Msgid-Bugs-To: \n" "POT-Creation-Date: 2020-02-08 20:05+0200\n" "PO-Revision-Date: 2018-01-22 10:48+0900\n" "Last-Translator: Kan Torii \n" "Language-Team: Japanese\n" "Language: ja\n" "MIME-Version: 1.0\n" "Content-Type: text/plain; charset=utf-8\n" "Content-Transfer-Encoding: 8bit\n" "Plural-Forms: nplurals=1; plural=0;\n" "Generated-By: Babel 0.9.6\n" "X-Generator: Poedit 2.0.6\n" #: src/humanize/number.py:24 msgctxt "0" msgid "th" msgstr "番目" #: src/humanize/number.py:25 msgctxt "1" msgid "st" msgstr "番目" #: src/humanize/number.py:26 msgctxt "2" msgid "nd" msgstr "番目" #: src/humanize/number.py:27 msgctxt "3" msgid "rd" msgstr "番目" #: src/humanize/number.py:28 msgctxt "4" msgid "th" msgstr "番目" #: src/humanize/number.py:29 msgctxt "5" msgid "th" msgstr "番目" #: src/humanize/number.py:30 msgctxt "6" msgid "th" msgstr "番目" #: src/humanize/number.py:31 msgctxt "7" msgid "th" msgstr "番目" #: src/humanize/number.py:32 msgctxt "8" msgid "th" msgstr "番目" #: src/humanize/number.py:33 msgctxt "9" msgid "th" msgstr "番目" #: src/humanize/number.py:62 msgid "million" msgstr "百万" #: src/humanize/number.py:63 #, fuzzy msgid "billion" msgstr "十億" #: src/humanize/number.py:64 msgid "trillion" msgstr "兆" #: src/humanize/number.py:65 #, fuzzy msgid "quadrillion" msgstr "千兆" #: src/humanize/number.py:66 #, fuzzy msgid "quintillion" msgstr "百京" #: src/humanize/number.py:67 #, fuzzy msgid "sextillion" msgstr "十垓" #: src/humanize/number.py:68 #, fuzzy msgid "septillion" msgstr "じょ" #: src/humanize/number.py:69 #, fuzzy msgid "octillion" msgstr "千じょ" #: src/humanize/number.py:70 #, fuzzy msgid "nonillion" msgstr "百穣" #: src/humanize/number.py:71 #, fuzzy msgid "decillion" msgstr "十溝" #: src/humanize/number.py:72 #, fuzzy msgid "googol" msgstr "溝無量大数" #: src/humanize/number.py:108 msgid "one" msgstr "一" #: src/humanize/number.py:109 msgid "two" msgstr "二" #: src/humanize/number.py:110 msgid "three" msgstr "三" #: src/humanize/number.py:111 msgid "four" msgstr "四" #: src/humanize/number.py:112 msgid "five" msgstr "五" #: src/humanize/number.py:113 msgid "six" msgstr "六" #: src/humanize/number.py:114 msgid "seven" msgstr "七" #: src/humanize/number.py:115 msgid "eight" msgstr "八" #: src/humanize/number.py:116 msgid "nine" msgstr "九" #: src/humanize/time.py:68 src/humanize/time.py:131 #, fuzzy msgid "a moment" msgstr "短時間" #: src/humanize/time.py:70 msgid "a second" msgstr "1秒" #: src/humanize/time.py:72 #, python-format msgid "%d second" msgid_plural "%d seconds" msgstr[0] "%d秒" #: src/humanize/time.py:74 msgid "a minute" msgstr "1分" #: src/humanize/time.py:77 #, python-format msgid "%d minute" msgid_plural "%d minutes" msgstr[0] "%d分" #: src/humanize/time.py:79 msgid "an hour" msgstr "1時間" #: src/humanize/time.py:82 #, python-format msgid "%d hour" msgid_plural "%d hours" msgstr[0] "%d時間" #: src/humanize/time.py:85 msgid "a day" msgstr "1日" #: src/humanize/time.py:87 src/humanize/time.py:90 #, python-format msgid "%d day" msgid_plural "%d days" msgstr[0] "%d日" #: src/humanize/time.py:92 msgid "a month" msgstr "1ヶ月" #: src/humanize/time.py:94 #, python-format msgid "%d month" msgid_plural "%d months" msgstr[0] "%dヶ月" #: src/humanize/time.py:97 msgid "a year" msgstr "1年" #: src/humanize/time.py:99 src/humanize/time.py:108 #, python-format msgid "1 year, %d day" msgid_plural "1 year, %d days" msgstr[0] "1年 %d日" #: src/humanize/time.py:102 msgid "1 year, 1 month" msgstr "1年 1ヶ月" #: src/humanize/time.py:105 #, python-format msgid "1 year, %d month" msgid_plural "1 year, %d months" msgstr[0] "1年 %dヶ月" #: src/humanize/time.py:110 #, python-format msgid "%d year" msgid_plural "%d years" msgstr[0] "%d年" #: src/humanize/time.py:128 #, python-format msgid "%s from now" msgstr "%s後" #: src/humanize/time.py:128 #, python-format msgid "%s ago" msgstr "%s前" #: src/humanize/time.py:132 #, fuzzy msgid "now" msgstr "今" #: src/humanize/time.py:151 msgid "today" msgstr "本日" #: src/humanize/time.py:153 msgid "tomorrow" msgstr "明日" #: src/humanize/time.py:155 msgid "yesterday" msgstr "昨日" napari-0.5.0a1/napari/_vendor/experimental/humanize/src/humanize/locale/ko_KR/000077500000000000000000000000001437041365600273175ustar00rootroot00000000000000napari-0.5.0a1/napari/_vendor/experimental/humanize/src/humanize/locale/ko_KR/LC_MESSAGES/000077500000000000000000000000001437041365600311045ustar00rootroot00000000000000humanize.po000066400000000000000000000114111437041365600332030ustar00rootroot00000000000000napari-0.5.0a1/napari/_vendor/experimental/humanize/src/humanize/locale/ko_KR/LC_MESSAGES# Korean (Korea) translations for humanize. # Copyright (C) 2013 # This file is distributed under the same license as the humanize project. # @youngrok, 2013. # msgid "" msgstr "" "Project-Id-Version: PROJECT VERSION\n" "Report-Msgid-Bugs-To: \n" "POT-Creation-Date: 2020-02-08 20:05+0200\n" "PO-Revision-Date: 2013-07-10 11:38+0900\n" "Last-Translator: @youngrok\n" "Language-Team: ko_KR \n" "Language: ko\n" "MIME-Version: 1.0\n" "Content-Type: text/plain; charset=utf-8\n" "Content-Transfer-Encoding: 8bit\n" "Plural-Forms: nplurals=2; plural=(n > 1);\n" "Generated-By: Babel 0.9.6\n" "X-Generator: Poedit 1.5.7\n" #: src/humanize/number.py:24 #, fuzzy msgctxt "0" msgid "th" msgstr "번째" #: src/humanize/number.py:25 #, fuzzy msgctxt "1" msgid "st" msgstr "번째" #: src/humanize/number.py:26 #, fuzzy msgctxt "2" msgid "nd" msgstr "번째" #: src/humanize/number.py:27 #, fuzzy msgctxt "3" msgid "rd" msgstr "번째" #: src/humanize/number.py:28 #, fuzzy msgctxt "4" msgid "th" msgstr "번째" #: src/humanize/number.py:29 #, fuzzy msgctxt "5" msgid "th" msgstr "번째" #: src/humanize/number.py:30 #, fuzzy msgctxt "6" msgid "th" msgstr "번째" #: src/humanize/number.py:31 #, fuzzy msgctxt "7" msgid "th" msgstr "번째" #: src/humanize/number.py:32 #, fuzzy msgctxt "8" msgid "th" msgstr "번째" #: src/humanize/number.py:33 #, fuzzy msgctxt "9" msgid "th" msgstr "번째" #: src/humanize/number.py:62 msgid "million" msgstr "%(value)s million" #: src/humanize/number.py:63 msgid "billion" msgstr "milliard" #: src/humanize/number.py:64 #, fuzzy msgid "trillion" msgstr "%(value)s billion" #: src/humanize/number.py:65 #, fuzzy msgid "quadrillion" msgstr "%(value)s quadrillion" #: src/humanize/number.py:66 #, fuzzy msgid "quintillion" msgstr "%(value)s quintillion" #: src/humanize/number.py:67 #, fuzzy msgid "sextillion" msgstr "%(value)s sextillion" #: src/humanize/number.py:68 #, fuzzy msgid "septillion" msgstr "%(value)s septillion" #: src/humanize/number.py:69 #, fuzzy msgid "octillion" msgstr "%(value)s octillion" #: src/humanize/number.py:70 #, fuzzy msgid "nonillion" msgstr "%(value)s nonillion" #: src/humanize/number.py:71 #, fuzzy msgid "decillion" msgstr "%(value)s décillion" #: src/humanize/number.py:72 #, fuzzy msgid "googol" msgstr "%(value)s gogol" #: src/humanize/number.py:108 msgid "one" msgstr "하나" #: src/humanize/number.py:109 msgid "two" msgstr "둘" #: src/humanize/number.py:110 msgid "three" msgstr "셋" #: src/humanize/number.py:111 msgid "four" msgstr "넷" #: src/humanize/number.py:112 msgid "five" msgstr "다섯" #: src/humanize/number.py:113 msgid "six" msgstr "여섯" #: src/humanize/number.py:114 msgid "seven" msgstr "일곱" #: src/humanize/number.py:115 msgid "eight" msgstr "여덟" #: src/humanize/number.py:116 msgid "nine" msgstr "아홉" #: src/humanize/time.py:68 src/humanize/time.py:131 msgid "a moment" msgstr "잠깐" #: src/humanize/time.py:70 msgid "a second" msgstr "1초" #: src/humanize/time.py:72 #, python-format msgid "%d second" msgid_plural "%d seconds" msgstr[0] "%d초" msgstr[1] "%d초" #: src/humanize/time.py:74 msgid "a minute" msgstr "1분" #: src/humanize/time.py:77 #, python-format msgid "%d minute" msgid_plural "%d minutes" msgstr[0] "%d분" msgstr[1] "%d분" #: src/humanize/time.py:79 msgid "an hour" msgstr "1시간" #: src/humanize/time.py:82 #, python-format msgid "%d hour" msgid_plural "%d hours" msgstr[0] "%d heure" msgstr[1] "%d시간" #: src/humanize/time.py:85 msgid "a day" msgstr "하루" #: src/humanize/time.py:87 src/humanize/time.py:90 #, python-format msgid "%d day" msgid_plural "%d days" msgstr[0] "%d jour" msgstr[1] "%d일" #: src/humanize/time.py:92 msgid "a month" msgstr "한달" #: src/humanize/time.py:94 #, python-format msgid "%d month" msgid_plural "%d months" msgstr[0] "%d mois" msgstr[1] "%d개월" #: src/humanize/time.py:97 msgid "a year" msgstr "1년" #: src/humanize/time.py:99 src/humanize/time.py:108 #, python-format msgid "1 year, %d day" msgid_plural "1 year, %d days" msgstr[0] "1년 %d일" msgstr[1] "1년 %d일" #: src/humanize/time.py:102 msgid "1 year, 1 month" msgstr "1년, 1개월" #: src/humanize/time.py:105 #, python-format msgid "1 year, %d month" msgid_plural "1 year, %d months" msgstr[0] "1년, %d개월" msgstr[1] "1년, %d개월" #: src/humanize/time.py:110 #, python-format msgid "%d year" msgid_plural "%d years" msgstr[0] "%d 년" msgstr[1] "%d ans" #: src/humanize/time.py:128 #, python-format msgid "%s from now" msgstr "%s 후" #: src/humanize/time.py:128 #, python-format msgid "%s ago" msgstr "%s 전" #: src/humanize/time.py:132 msgid "now" msgstr "방금" #: src/humanize/time.py:151 msgid "today" msgstr "오늘" #: src/humanize/time.py:153 msgid "tomorrow" msgstr "내일" #: src/humanize/time.py:155 msgid "yesterday" msgstr "어제" napari-0.5.0a1/napari/_vendor/experimental/humanize/src/humanize/locale/nl_NL/000077500000000000000000000000001437041365600273145ustar00rootroot00000000000000napari-0.5.0a1/napari/_vendor/experimental/humanize/src/humanize/locale/nl_NL/LC_MESSAGES/000077500000000000000000000000001437041365600311015ustar00rootroot00000000000000humanize.po000066400000000000000000000116741437041365600332130ustar00rootroot00000000000000napari-0.5.0a1/napari/_vendor/experimental/humanize/src/humanize/locale/nl_NL/LC_MESSAGES# French (France) translations for PROJECT. # Copyright (C) 2013 ORGANIZATION # This file is distributed under the same license as the PROJECT project. # FIRST AUTHOR , 2013. # msgid "" msgstr "" "Project-Id-Version: PROJECT VERSION\n" "Report-Msgid-Bugs-To: \n" "POT-Creation-Date: 2020-03-22 16:30+0200\n" "PO-Revision-Date: 2015-03-25 21:08+0100\n" "Last-Translator: Martin van Wingerden\n" "Language-Team: nl_NL\n" "Language: nl_NL\n" "MIME-Version: 1.0\n" "Content-Type: text/plain; charset=UTF-8\n" "Content-Transfer-Encoding: 8bit\n" "Plural-Forms: nplurals=2; plural=(n != 1);\n" "Generated-By: Babel 0.9.6\n" "X-Generator: Poedit 1.7.5\n" #: src/humanize/number.py:22 msgctxt "0" msgid "th" msgstr "de" #: src/humanize/number.py:23 msgctxt "1" msgid "st" msgstr "ste" #: src/humanize/number.py:24 msgctxt "2" msgid "nd" msgstr "de" #: src/humanize/number.py:25 msgctxt "3" msgid "rd" msgstr "de" #: src/humanize/number.py:26 msgctxt "4" msgid "th" msgstr "de" #: src/humanize/number.py:27 msgctxt "5" msgid "th" msgstr "de" #: src/humanize/number.py:28 msgctxt "6" msgid "th" msgstr "de" #: src/humanize/number.py:29 msgctxt "7" msgid "th" msgstr "de" #: src/humanize/number.py:30 msgctxt "8" msgid "th" msgstr "de" #: src/humanize/number.py:31 msgctxt "9" msgid "th" msgstr "de" #: src/humanize/number.py:73 msgid "million" msgstr "miljoen" #: src/humanize/number.py:74 msgid "billion" msgstr "miljard" #: src/humanize/number.py:75 msgid "trillion" msgstr "biljoen" #: src/humanize/number.py:76 msgid "quadrillion" msgstr "biljard" #: src/humanize/number.py:77 msgid "quintillion" msgstr "triljoen" #: src/humanize/number.py:78 msgid "sextillion" msgstr "triljard" #: src/humanize/number.py:79 msgid "septillion" msgstr "quadriljoen" #: src/humanize/number.py:80 msgid "octillion" msgstr "quadriljard" #: src/humanize/number.py:81 msgid "nonillion" msgstr "quintiljoen" #: src/humanize/number.py:82 msgid "decillion" msgstr "quintiljard" #: src/humanize/number.py:83 msgid "googol" msgstr "googol" #: src/humanize/number.py:138 msgid "zero" msgstr "nul" #: src/humanize/number.py:139 msgid "one" msgstr "één" #: src/humanize/number.py:140 msgid "two" msgstr "twee" #: src/humanize/number.py:141 msgid "three" msgstr "drie" #: src/humanize/number.py:142 msgid "four" msgstr "vier" #: src/humanize/number.py:143 msgid "five" msgstr "vijf" #: src/humanize/number.py:144 msgid "six" msgstr "zes" #: src/humanize/number.py:145 msgid "seven" msgstr "zeven" #: src/humanize/number.py:146 msgid "eight" msgstr "acht" #: src/humanize/number.py:147 msgid "nine" msgstr "negen" #: src/humanize/time.py:87 #, fuzzy, python-format msgid "%d microsecond" msgid_plural "%d microseconds" msgstr[0] "%d microseconde" msgstr[1] "%d microseconden" #: src/humanize/time.py:93 #, fuzzy, python-format msgid "%d millisecond" msgid_plural "%d milliseconds" msgstr[0] "%d milliseconde" msgstr[1] "%d milliseconden" #: src/humanize/time.py:96 src/humanize/time.py:170 msgid "a moment" msgstr "een moment" #: src/humanize/time.py:98 msgid "a second" msgstr "een seconde" #: src/humanize/time.py:100 #, python-format msgid "%d second" msgid_plural "%d seconds" msgstr[0] "%d seconde" msgstr[1] "%d seconden" #: src/humanize/time.py:102 msgid "a minute" msgstr "een minuut" #: src/humanize/time.py:105 #, python-format msgid "%d minute" msgid_plural "%d minutes" msgstr[0] "%d minuut" msgstr[1] "%d minuten" #: src/humanize/time.py:107 msgid "an hour" msgstr "een uur" #: src/humanize/time.py:110 #, python-format msgid "%d hour" msgid_plural "%d hours" msgstr[0] "%d uur" msgstr[1] "%d uren" #: src/humanize/time.py:113 msgid "a day" msgstr "een dag" #: src/humanize/time.py:115 src/humanize/time.py:118 #, python-format msgid "%d day" msgid_plural "%d days" msgstr[0] "%d dag" msgstr[1] "%d dagen" #: src/humanize/time.py:120 msgid "a month" msgstr "een maand" #: src/humanize/time.py:122 #, python-format msgid "%d month" msgid_plural "%d months" msgstr[0] "%d maand" msgstr[1] "%d maanden" #: src/humanize/time.py:125 msgid "a year" msgstr "een jaar" #: src/humanize/time.py:127 src/humanize/time.py:136 #, python-format msgid "1 year, %d day" msgid_plural "1 year, %d days" msgstr[0] "1 jaar, %d dag" msgstr[1] "1 jaar, %d dagen" #: src/humanize/time.py:130 msgid "1 year, 1 month" msgstr "1 jaar, 1 maand" #: src/humanize/time.py:133 #, python-format msgid "1 year, %d month" msgid_plural "1 year, %d months" msgstr[0] "1 jaar, %d maand" msgstr[1] "1 jaar, %d maanden" #: src/humanize/time.py:138 #, python-format msgid "%d year" msgid_plural "%d years" msgstr[0] "%d jaar" msgstr[1] "%d jaar" #: src/humanize/time.py:167 #, python-format msgid "%s from now" msgstr "over %s" #: src/humanize/time.py:167 #, python-format msgid "%s ago" msgstr "%s geleden" #: src/humanize/time.py:171 msgid "now" msgstr "nu" #: src/humanize/time.py:190 msgid "today" msgstr "vandaag" #: src/humanize/time.py:192 msgid "tomorrow" msgstr "morgen" #: src/humanize/time.py:194 msgid "yesterday" msgstr "gisteren" napari-0.5.0a1/napari/_vendor/experimental/humanize/src/humanize/locale/pl_PL/000077500000000000000000000000001437041365600273205ustar00rootroot00000000000000napari-0.5.0a1/napari/_vendor/experimental/humanize/src/humanize/locale/pl_PL/LC_MESSAGES/000077500000000000000000000000001437041365600311055ustar00rootroot00000000000000humanize.po000066400000000000000000000122451437041365600332120ustar00rootroot00000000000000napari-0.5.0a1/napari/_vendor/experimental/humanize/src/humanize/locale/pl_PL/LC_MESSAGES# Polish translations for PACKAGE package. # Copyright (C) 2020 THE PACKAGE'S COPYRIGHT HOLDER # This file is distributed under the same license as the PACKAGE package. # Bartosz Bubak , 2020. # msgid "" msgstr "" "Project-Id-Version: 0.0.1\n" "Report-Msgid-Bugs-To: \n" "POT-Creation-Date: 2020-04-22 10:01+0200\n" "PO-Revision-Date: 2020-04-22 10:02+0200\n" "Last-Translator: Bartosz Bubak \n" "Language-Team: Polish\n" "Language: pl\n" "MIME-Version: 1.0\n" "Content-Type: text/plain; charset=UTF-8\n" "Content-Transfer-Encoding: 8bit\n" "Plural-Forms: nplurals=3; plural=(n==1 ? 0 : n%10>=2 && n%10<=4 && (n%100<10 " "|| n%100>=20) ? 1 : 2);\n" #: src/humanize/number.py:22 msgctxt "0" msgid "th" msgstr "." #: src/humanize/number.py:23 msgctxt "1" msgid "st" msgstr "." #: src/humanize/number.py:24 msgctxt "2" msgid "nd" msgstr "." #: src/humanize/number.py:25 msgctxt "3" msgid "rd" msgstr "." #: src/humanize/number.py:26 msgctxt "4" msgid "th" msgstr "." #: src/humanize/number.py:27 msgctxt "5" msgid "th" msgstr "." #: src/humanize/number.py:28 msgctxt "6" msgid "th" msgstr "." #: src/humanize/number.py:29 msgctxt "7" msgid "th" msgstr "." #: src/humanize/number.py:30 msgctxt "8" msgid "th" msgstr "." #: src/humanize/number.py:31 msgctxt "9" msgid "th" msgstr "." #: src/humanize/number.py:73 msgid "million" msgstr "milion" #: src/humanize/number.py:74 msgid "billion" msgstr "bilion" #: src/humanize/number.py:75 msgid "trillion" msgstr "trylion" #: src/humanize/number.py:76 msgid "quadrillion" msgstr "kwadrylion" #: src/humanize/number.py:77 msgid "quintillion" msgstr "kwintylion" #: src/humanize/number.py:78 msgid "sextillion" msgstr "sekstylion" #: src/humanize/number.py:79 msgid "septillion" msgstr "septylion" #: src/humanize/number.py:80 msgid "octillion" msgstr "oktylion" #: src/humanize/number.py:81 msgid "nonillion" msgstr "nonilion" #: src/humanize/number.py:82 msgid "decillion" msgstr "decylion" #: src/humanize/number.py:83 msgid "googol" msgstr "googol" #: src/humanize/number.py:138 msgid "zero" msgstr "zero" #: src/humanize/number.py:139 msgid "one" msgstr "jeden" #: src/humanize/number.py:140 msgid "two" msgstr "dwa" #: src/humanize/number.py:141 msgid "three" msgstr "trzy" #: src/humanize/number.py:142 msgid "four" msgstr "cztery" #: src/humanize/number.py:143 msgid "five" msgstr "pięć" #: src/humanize/number.py:144 msgid "six" msgstr "sześć" #: src/humanize/number.py:145 msgid "seven" msgstr "siedem" #: src/humanize/number.py:146 msgid "eight" msgstr "osiem" #: src/humanize/number.py:147 msgid "nine" msgstr "dziewięć" #: src/humanize/time.py:87 #, python-format msgid "%d microsecond" msgid_plural "%d microseconds" msgstr[0] "%d mikrosekunda" msgstr[1] "%d mikrosekundy" msgstr[2] "%d mikrosekund" #: src/humanize/time.py:93 #, python-format msgid "%d millisecond" msgid_plural "%d milliseconds" msgstr[0] "%d milisekunda" msgstr[1] "%d milisekundy" msgstr[2] "%d milisekund" #: src/humanize/time.py:96 src/humanize/time.py:170 msgid "a moment" msgstr "chwila" #: src/humanize/time.py:98 msgid "a second" msgstr "sekunda" #: src/humanize/time.py:100 #, python-format msgid "%d second" msgid_plural "%d seconds" msgstr[0] "%d sekunda" msgstr[1] "%d sekundy" msgstr[2] "%d sekund" #: src/humanize/time.py:102 msgid "a minute" msgstr "minuta" #: src/humanize/time.py:105 #, python-format msgid "%d minute" msgid_plural "%d minutes" msgstr[0] "%d minuta" msgstr[1] "%d minuty" msgstr[2] "%d minut" #: src/humanize/time.py:107 msgid "an hour" msgstr "godzina" #: src/humanize/time.py:110 #, python-format msgid "%d hour" msgid_plural "%d hours" msgstr[0] "%d godzina" msgstr[1] "%d godziny" msgstr[2] "%d godzin" #: src/humanize/time.py:113 msgid "a day" msgstr "dzień" #: src/humanize/time.py:115 src/humanize/time.py:118 #, python-format msgid "%d day" msgid_plural "%d days" msgstr[0] "%d dzień" msgstr[1] "%d dni" msgstr[2] "%d dni" #: src/humanize/time.py:120 msgid "a month" msgstr "miesiąc" #: src/humanize/time.py:122 #, python-format msgid "%d month" msgid_plural "%d months" msgstr[0] "%d miesiąc" msgstr[1] "%d miesiące" msgstr[2] "%d miesięcy" #: src/humanize/time.py:125 msgid "a year" msgstr "rok" #: src/humanize/time.py:127 src/humanize/time.py:136 #, python-format msgid "1 year, %d day" msgid_plural "1 year, %d days" msgstr[0] "1 rok, %d dzień" msgstr[1] "1 rok, %d dni" msgstr[2] "1 rok, %d dni" #: src/humanize/time.py:130 msgid "1 year, 1 month" msgstr "" #: src/humanize/time.py:133 #, python-format msgid "1 year, %d month" msgid_plural "1 year, %d months" msgstr[0] "1 rok, %d miesiąc" msgstr[1] "1 rok, %d miesiące" msgstr[2] "1 rok, %d miesięcy" #: src/humanize/time.py:138 #, python-format msgid "%d year" msgid_plural "%d years" msgstr[0] "%d rok" msgstr[1] "%d lat" msgstr[2] "%d lata" #: src/humanize/time.py:167 #, python-format msgid "%s from now" msgstr "%s od teraz" #: src/humanize/time.py:167 #, python-format msgid "%s ago" msgstr "%s temu" #: src/humanize/time.py:171 msgid "now" msgstr "teraz" #: src/humanize/time.py:190 msgid "today" msgstr "dziś" #: src/humanize/time.py:192 msgid "tomorrow" msgstr "jutro" #: src/humanize/time.py:194 msgid "yesterday" msgstr "wczoraj" napari-0.5.0a1/napari/_vendor/experimental/humanize/src/humanize/locale/pt_BR/000077500000000000000000000000001437041365600273205ustar00rootroot00000000000000napari-0.5.0a1/napari/_vendor/experimental/humanize/src/humanize/locale/pt_BR/LC_MESSAGES/000077500000000000000000000000001437041365600311055ustar00rootroot00000000000000humanize.po000066400000000000000000000115511437041365600332110ustar00rootroot00000000000000napari-0.5.0a1/napari/_vendor/experimental/humanize/src/humanize/locale/pt_BR/LC_MESSAGES# SOME DESCRIPTIVE TITLE. # Copyright (C) YEAR THE PACKAGE'S COPYRIGHT HOLDER # This file is distributed under the same license as the PACKAGE package. # FIRST AUTHOR , YEAR. # msgid "" msgstr "" "Project-Id-Version: \n" "Report-Msgid-Bugs-To: \n" "POT-Creation-Date: 2020-06-20 23:04-0300\n" "PO-Revision-Date: 2016-06-15 15:58-0300\n" "Last-Translator: \n" "Language-Team: \n" "Language: pt_BR\n" "MIME-Version: 1.0\n" "Content-Type: text/plain; charset=UTF-8\n" "Content-Transfer-Encoding: 8bit\n" "Plural-Forms: nplurals=2; plural=(n > 1);\n" "X-Generator: Poedit 1.8.5\n" #: src/humanize/number.py:22 msgctxt "0" msgid "th" msgstr "º" #: src/humanize/number.py:23 msgctxt "1" msgid "st" msgstr "º" #: src/humanize/number.py:24 msgctxt "2" msgid "nd" msgstr "º" #: src/humanize/number.py:25 msgctxt "3" msgid "rd" msgstr "º" #: src/humanize/number.py:26 msgctxt "4" msgid "th" msgstr "º" #: src/humanize/number.py:27 msgctxt "5" msgid "th" msgstr "º" #: src/humanize/number.py:28 msgctxt "6" msgid "th" msgstr "º" #: src/humanize/number.py:29 msgctxt "7" msgid "th" msgstr "º" #: src/humanize/number.py:30 msgctxt "8" msgid "th" msgstr "º" #: src/humanize/number.py:31 msgctxt "9" msgid "th" msgstr "º" #: src/humanize/number.py:73 msgid "million" msgstr "milhão" #: src/humanize/number.py:74 msgid "billion" msgstr "bilhão" #: src/humanize/number.py:75 msgid "trillion" msgstr "trilhão" #: src/humanize/number.py:76 msgid "quadrillion" msgstr "quatrilhão" #: src/humanize/number.py:77 msgid "quintillion" msgstr "quintilhão" #: src/humanize/number.py:78 msgid "sextillion" msgstr "sextilhão" #: src/humanize/number.py:79 msgid "septillion" msgstr "septilhão" #: src/humanize/number.py:80 msgid "octillion" msgstr "octilhão" #: src/humanize/number.py:81 msgid "nonillion" msgstr "nonilhão" #: src/humanize/number.py:82 msgid "decillion" msgstr "decilhão" #: src/humanize/number.py:83 msgid "googol" msgstr "undecilhão" #: src/humanize/number.py:138 msgid "zero" msgstr "zero" #: src/humanize/number.py:139 msgid "one" msgstr "um" #: src/humanize/number.py:140 msgid "two" msgstr "dois" #: src/humanize/number.py:141 msgid "three" msgstr "três" #: src/humanize/number.py:142 msgid "four" msgstr "quatro" #: src/humanize/number.py:143 msgid "five" msgstr "cinco" #: src/humanize/number.py:144 msgid "six" msgstr "seis" #: src/humanize/number.py:145 msgid "seven" msgstr "sete" #: src/humanize/number.py:146 msgid "eight" msgstr "oito" #: src/humanize/number.py:147 msgid "nine" msgstr "nove" #: src/humanize/time.py:87 #, fuzzy, python-format msgid "%d microsecond" msgid_plural "%d microseconds" msgstr[0] "%d microssegundo" msgstr[1] "%d microssegundos" #: src/humanize/time.py:93 #, fuzzy, python-format msgid "%d millisecond" msgid_plural "%d milliseconds" msgstr[0] "%d milissegundo" msgstr[1] "%d milissegundos" #: src/humanize/time.py:96 src/humanize/time.py:170 msgid "a moment" msgstr "um momento" #: src/humanize/time.py:98 msgid "a second" msgstr "um segundo" #: src/humanize/time.py:100 #, python-format msgid "%d second" msgid_plural "%d seconds" msgstr[0] "%d segundo" msgstr[1] "%d segundos" #: src/humanize/time.py:102 msgid "a minute" msgstr "um minuto" #: src/humanize/time.py:105 #, python-format msgid "%d minute" msgid_plural "%d minutes" msgstr[0] "%d minuto" msgstr[1] "%d minutos" #: src/humanize/time.py:107 msgid "an hour" msgstr "uma hora" #: src/humanize/time.py:110 #, python-format msgid "%d hour" msgid_plural "%d hours" msgstr[0] "%d hora" msgstr[1] "%d horas" #: src/humanize/time.py:113 msgid "a day" msgstr "um dia" #: src/humanize/time.py:115 src/humanize/time.py:118 #, python-format msgid "%d day" msgid_plural "%d days" msgstr[0] "%d dia" msgstr[1] "%d dias" #: src/humanize/time.py:120 msgid "a month" msgstr "um mês" #: src/humanize/time.py:122 #, python-format msgid "%d month" msgid_plural "%d months" msgstr[0] "%d mês" msgstr[1] "%d meses" #: src/humanize/time.py:125 msgid "a year" msgstr "um ano" #: src/humanize/time.py:127 src/humanize/time.py:136 #, python-format msgid "1 year, %d day" msgid_plural "1 year, %d days" msgstr[0] "1 ano e %d dia" msgstr[1] "1 ano e %d dias" #: src/humanize/time.py:130 msgid "1 year, 1 month" msgstr "1 ano e 1 mês" #: src/humanize/time.py:133 #, python-format msgid "1 year, %d month" msgid_plural "1 year, %d months" msgstr[0] "1 ano e %d mês" msgstr[1] "1 ano e %d meses" #: src/humanize/time.py:138 #, python-format msgid "%d year" msgid_plural "%d years" msgstr[0] "%d ano" msgstr[1] "%d anos" #: src/humanize/time.py:167 #, python-format msgid "%s from now" msgstr "em %s" #: src/humanize/time.py:167 #, python-format msgid "%s ago" msgstr "há %s" #: src/humanize/time.py:171 msgid "now" msgstr "agora" #: src/humanize/time.py:190 msgid "today" msgstr "hoje" #: src/humanize/time.py:192 msgid "tomorrow" msgstr "amanhã" #: src/humanize/time.py:194 msgid "yesterday" msgstr "ontem" napari-0.5.0a1/napari/_vendor/experimental/humanize/src/humanize/locale/pt_PT/000077500000000000000000000000001437041365600273405ustar00rootroot00000000000000napari-0.5.0a1/napari/_vendor/experimental/humanize/src/humanize/locale/pt_PT/LC_MESSAGES/000077500000000000000000000000001437041365600311255ustar00rootroot00000000000000humanize.po000066400000000000000000000115721437041365600332340ustar00rootroot00000000000000napari-0.5.0a1/napari/_vendor/experimental/humanize/src/humanize/locale/pt_PT/LC_MESSAGES# SOME DESCRIPTIVE TITLE. # Copyright (C) YEAR THE PACKAGE'S COPYRIGHT HOLDER # This file is distributed under the same license as the PACKAGE package. # FIRST AUTHOR , YEAR. # msgid "" msgstr "" "Project-Id-Version: \n" "Report-Msgid-Bugs-To: \n" "POT-Creation-Date: 2020-06-20 23:04-0300\n" "PO-Revision-Date: 2020-07-05 18:17+0100\n" "Last-Translator: \n" "Language-Team: \n" "Language: pt_PT\n" "MIME-Version: 1.0\n" "Content-Type: text/plain; charset=UTF-8\n" "Content-Transfer-Encoding: 8bit\n" "Plural-Forms: nplurals=2; plural=(n > 1);\n" "X-Generator: Poedit 2.3.1\n" #: src/humanize/number.py:22 msgctxt "0" msgid "th" msgstr "º" #: src/humanize/number.py:23 msgctxt "1" msgid "st" msgstr "º" #: src/humanize/number.py:24 msgctxt "2" msgid "nd" msgstr "º" #: src/humanize/number.py:25 msgctxt "3" msgid "rd" msgstr "º" #: src/humanize/number.py:26 msgctxt "4" msgid "th" msgstr "º" #: src/humanize/number.py:27 msgctxt "5" msgid "th" msgstr "º" #: src/humanize/number.py:28 msgctxt "6" msgid "th" msgstr "º" #: src/humanize/number.py:29 msgctxt "7" msgid "th" msgstr "º" #: src/humanize/number.py:30 msgctxt "8" msgid "th" msgstr "º" #: src/humanize/number.py:31 msgctxt "9" msgid "th" msgstr "º" #: src/humanize/number.py:73 msgid "million" msgstr "milhão" #: src/humanize/number.py:74 msgid "billion" msgstr "milhar de milhão" #: src/humanize/number.py:75 msgid "trillion" msgstr "bilião" #: src/humanize/number.py:76 msgid "quadrillion" msgstr "mil biliões" #: src/humanize/number.py:77 msgid "quintillion" msgstr "trilião" #: src/humanize/number.py:78 msgid "sextillion" msgstr "mil triliões" #: src/humanize/number.py:79 msgid "septillion" msgstr "quatrilião" #: src/humanize/number.py:80 msgid "octillion" msgstr "mil quatriliões" #: src/humanize/number.py:81 msgid "nonillion" msgstr "quintilhão" #: src/humanize/number.py:82 msgid "decillion" msgstr "mil quintilhões" #: src/humanize/number.py:83 msgid "googol" msgstr "sextilhão" #: src/humanize/number.py:138 msgid "zero" msgstr "zero" #: src/humanize/number.py:139 msgid "one" msgstr "um" #: src/humanize/number.py:140 msgid "two" msgstr "dois" #: src/humanize/number.py:141 msgid "three" msgstr "três" #: src/humanize/number.py:142 msgid "four" msgstr "quatro" #: src/humanize/number.py:143 msgid "five" msgstr "cinco" #: src/humanize/number.py:144 msgid "six" msgstr "seis" #: src/humanize/number.py:145 msgid "seven" msgstr "sete" #: src/humanize/number.py:146 msgid "eight" msgstr "oito" #: src/humanize/number.py:147 msgid "nine" msgstr "nove" #: src/humanize/time.py:87 #, python-format msgid "%d microsecond" msgid_plural "%d microseconds" msgstr[0] "%d microssegundo" msgstr[1] "%d microssegundos" #: src/humanize/time.py:93 #, python-format msgid "%d millisecond" msgid_plural "%d milliseconds" msgstr[0] "%d milissegundo" msgstr[1] "%d milissegundos" #: src/humanize/time.py:96 src/humanize/time.py:170 msgid "a moment" msgstr "um momento" #: src/humanize/time.py:98 msgid "a second" msgstr "um segundo" #: src/humanize/time.py:100 #, python-format msgid "%d second" msgid_plural "%d seconds" msgstr[0] "%d segundo" msgstr[1] "%d segundos" #: src/humanize/time.py:102 msgid "a minute" msgstr "um minuto" #: src/humanize/time.py:105 #, python-format msgid "%d minute" msgid_plural "%d minutes" msgstr[0] "%d minuto" msgstr[1] "%d minutos" #: src/humanize/time.py:107 msgid "an hour" msgstr "uma hora" #: src/humanize/time.py:110 #, python-format msgid "%d hour" msgid_plural "%d hours" msgstr[0] "%d hora" msgstr[1] "%d horas" #: src/humanize/time.py:113 msgid "a day" msgstr "um dia" #: src/humanize/time.py:115 src/humanize/time.py:118 #, python-format msgid "%d day" msgid_plural "%d days" msgstr[0] "%d dia" msgstr[1] "%d dias" #: src/humanize/time.py:120 msgid "a month" msgstr "um mês" #: src/humanize/time.py:122 #, python-format msgid "%d month" msgid_plural "%d months" msgstr[0] "%d mês" msgstr[1] "%d meses" #: src/humanize/time.py:125 msgid "a year" msgstr "um ano" #: src/humanize/time.py:127 src/humanize/time.py:136 #, python-format msgid "1 year, %d day" msgid_plural "1 year, %d days" msgstr[0] "1 ano e %d dia" msgstr[1] "1 ano e %d dias" #: src/humanize/time.py:130 msgid "1 year, 1 month" msgstr "1 ano e 1 mês" #: src/humanize/time.py:133 #, python-format msgid "1 year, %d month" msgid_plural "1 year, %d months" msgstr[0] "1 ano e %d mês" msgstr[1] "1 ano e %d meses" #: src/humanize/time.py:138 #, python-format msgid "%d year" msgid_plural "%d years" msgstr[0] "%d ano" msgstr[1] "%d anos" #: src/humanize/time.py:167 #, python-format msgid "%s from now" msgstr "daqui a %s" #: src/humanize/time.py:167 #, python-format msgid "%s ago" msgstr "há %s" #: src/humanize/time.py:171 msgid "now" msgstr "agora" #: src/humanize/time.py:190 msgid "today" msgstr "hoje" #: src/humanize/time.py:192 msgid "tomorrow" msgstr "amanhã" #: src/humanize/time.py:194 msgid "yesterday" msgstr "ontem" napari-0.5.0a1/napari/_vendor/experimental/humanize/src/humanize/locale/ru_RU/000077500000000000000000000000001437041365600273465ustar00rootroot00000000000000napari-0.5.0a1/napari/_vendor/experimental/humanize/src/humanize/locale/ru_RU/LC_MESSAGES/000077500000000000000000000000001437041365600311335ustar00rootroot00000000000000humanize.po000066400000000000000000000136521437041365600332430ustar00rootroot00000000000000napari-0.5.0a1/napari/_vendor/experimental/humanize/src/humanize/locale/ru_RU/LC_MESSAGES# Russian (Russia) translations for PROJECT. # Copyright (C) 2013 ORGANIZATION # This file is distributed under the same license as the PROJECT project. # FIRST AUTHOR , 2013. # msgid "" msgstr "" "Project-Id-Version: PROJECT VERSION\n" "Report-Msgid-Bugs-To: \n" "POT-Creation-Date: 2020-02-08 19:58+0200\n" "PO-Revision-Date: 2014-03-24 20:32+0300\n" "Last-Translator: Sergey Prokhorov \n" "Language-Team: ru_RU \n" "Language: ru\n" "MIME-Version: 1.0\n" "Content-Type: text/plain; charset=utf-8\n" "Content-Transfer-Encoding: 8bit\n" "Plural-Forms: nplurals=3; plural=(n%10==1 && n%100!=11 ? 0 : n%10>=2 && n" "%10<=4 && (n%100<10 || n%100>=20) ? 1 : 2);\n" "Generated-By: Babel 0.9.6\n" "X-Generator: Poedit 1.5.4\n" # в Django тут "ий" но на самом деле оба варианта работают плохо #: src/humanize/number.py:24 msgctxt "0" msgid "th" msgstr "ой" #: src/humanize/number.py:25 msgctxt "1" msgid "st" msgstr "ый" #: src/humanize/number.py:26 msgctxt "2" msgid "nd" msgstr "ой" #: src/humanize/number.py:27 msgctxt "3" msgid "rd" msgstr "ий" # в Django тут "ий" но на самом деле оба варианта работают плохо #: src/humanize/number.py:28 msgctxt "4" msgid "th" msgstr "ый" # в Django тут "ий" но на самом деле оба варианта работают плохо #: src/humanize/number.py:29 msgctxt "5" msgid "th" msgstr "ый" # в Django тут "ий" но на самом деле оба варианта работают плохо #: src/humanize/number.py:30 msgctxt "6" msgid "th" msgstr "ой" # в Django тут "ий" но на самом деле оба варианта работают плохо #: src/humanize/number.py:31 msgctxt "7" msgid "th" msgstr "ой" # в Django тут "ий" но на самом деле оба варианта работают плохо #: src/humanize/number.py:32 msgctxt "8" msgid "th" msgstr "ой" # в Django тут "ий" но на самом деле оба варианта работают плохо #: src/humanize/number.py:33 msgctxt "9" msgid "th" msgstr "ый" #: src/humanize/number.py:62 msgid "million" msgstr "миллиона" #: src/humanize/number.py:63 msgid "billion" msgstr "миллиарда" #: src/humanize/number.py:64 msgid "trillion" msgstr "триллиона" #: src/humanize/number.py:65 msgid "quadrillion" msgstr "квадриллиона" #: src/humanize/number.py:66 msgid "quintillion" msgstr "квинтиллиона" #: src/humanize/number.py:67 msgid "sextillion" msgstr "сикстиллиона" #: src/humanize/number.py:68 msgid "septillion" msgstr "септиллиона" #: src/humanize/number.py:69 msgid "octillion" msgstr "октиллиона" #: src/humanize/number.py:70 msgid "nonillion" msgstr "нониллиона" #: src/humanize/number.py:71 msgid "decillion" msgstr "децилиона" #: src/humanize/number.py:72 msgid "googol" msgstr "гогола" #: src/humanize/number.py:108 msgid "one" msgstr "один" #: src/humanize/number.py:109 msgid "two" msgstr "два" #: src/humanize/number.py:110 msgid "three" msgstr "три" #: src/humanize/number.py:111 msgid "four" msgstr "четыре" #: src/humanize/number.py:112 msgid "five" msgstr "пять" #: src/humanize/number.py:113 msgid "six" msgstr "шесть" #: src/humanize/number.py:114 msgid "seven" msgstr "семь" #: src/humanize/number.py:115 msgid "eight" msgstr "восемь" #: src/humanize/number.py:116 msgid "nine" msgstr "девять" #: src/humanize/time.py:68 src/humanize/time.py:131 msgid "a moment" msgstr "только что" #: src/humanize/time.py:70 msgid "a second" msgstr "секунду" #: src/humanize/time.py:72 #, python-format msgid "%d second" msgid_plural "%d seconds" msgstr[0] "%d секунда" msgstr[1] "%d секунды" msgstr[2] "%d секунд" #: src/humanize/time.py:74 msgid "a minute" msgstr "минуту" #: src/humanize/time.py:77 #, python-format msgid "%d minute" msgid_plural "%d minutes" msgstr[0] "%d минута" msgstr[1] "%d минуты" msgstr[2] "%d минут" #: src/humanize/time.py:79 msgid "an hour" msgstr "час" #: src/humanize/time.py:82 #, python-format msgid "%d hour" msgid_plural "%d hours" msgstr[0] "%d час" msgstr[1] "%d часа" msgstr[2] "%d часов" #: src/humanize/time.py:85 msgid "a day" msgstr "день" #: src/humanize/time.py:87 src/humanize/time.py:90 #, python-format msgid "%d day" msgid_plural "%d days" msgstr[0] "%d день" msgstr[1] "%d дня" msgstr[2] "%d дней" #: src/humanize/time.py:92 msgid "a month" msgstr "месяц" #: src/humanize/time.py:94 #, python-format msgid "%d month" msgid_plural "%d months" msgstr[0] "%d месяц" msgstr[1] "%d месяца" msgstr[2] "%d месяцев" #: src/humanize/time.py:97 msgid "a year" msgstr "год" #: src/humanize/time.py:99 src/humanize/time.py:108 #, python-format msgid "1 year, %d day" msgid_plural "1 year, %d days" msgstr[0] "1 год, %d день" msgstr[1] "1 год, %d дня" msgstr[2] "1 год, %d дней" #: src/humanize/time.py:102 msgid "1 year, 1 month" msgstr "1 год, 1 месяц" #: src/humanize/time.py:105 #, python-format msgid "1 year, %d month" msgid_plural "1 year, %d months" msgstr[0] "1 год, %d месяц" msgstr[1] "1 год, %d месяца" msgstr[2] "1 год, %d месяцев" #: src/humanize/time.py:110 #, python-format msgid "%d year" msgid_plural "%d years" msgstr[0] "%d год" msgstr[1] "%d года" msgstr[2] "%d лет" #: src/humanize/time.py:128 #, python-format msgid "%s from now" msgstr "через %s" #: src/humanize/time.py:128 #, python-format msgid "%s ago" msgstr "%s назад" #: src/humanize/time.py:132 msgid "now" msgstr "сейчас" #: src/humanize/time.py:151 msgid "today" msgstr "сегодня" #: src/humanize/time.py:153 msgid "tomorrow" msgstr "завтра" #: src/humanize/time.py:155 msgid "yesterday" msgstr "вчера" napari-0.5.0a1/napari/_vendor/experimental/humanize/src/humanize/locale/sk_SK/000077500000000000000000000000001437041365600273245ustar00rootroot00000000000000napari-0.5.0a1/napari/_vendor/experimental/humanize/src/humanize/locale/sk_SK/LC_MESSAGES/000077500000000000000000000000001437041365600311115ustar00rootroot00000000000000humanize.po000066400000000000000000000122571437041365600332210ustar00rootroot00000000000000napari-0.5.0a1/napari/_vendor/experimental/humanize/src/humanize/locale/sk_SK/LC_MESSAGES# Slovak translation of humanize # Copyright (C) 2016 # This file is distributed under the same license as the PACKAGE package. # Jose Riha , 2016. # #, fuzzy msgid "" msgstr "" "Project-Id-Version: PACKAGE VERSION\n" "Report-Msgid-Bugs-To: \n" "POT-Creation-Date: 2020-03-22 16:30+0200\n" "PO-Revision-Date: YEAR-MO-DA HO:MI+ZONE\n" "Last-Translator: Jose Riha \n" "Language-Team: sk \n" "Language: Slovak\n" "MIME-Version: 1.0\n" "Content-Type: text/plain; charset=UTF-8\n" "Content-Transfer-Encoding: 8bit\n" "Plural-Forms: nplurals=3; plural=(n==1) ? 0 : (n>=2 && n<=4) ? 1 : 2;\n" #: src/humanize/number.py:22 msgctxt "0" msgid "th" msgstr "." #: src/humanize/number.py:23 msgctxt "1" msgid "st" msgstr "." #: src/humanize/number.py:24 msgctxt "2" msgid "nd" msgstr "." #: src/humanize/number.py:25 msgctxt "3" msgid "rd" msgstr "." #: src/humanize/number.py:26 msgctxt "4" msgid "th" msgstr "." #: src/humanize/number.py:27 msgctxt "5" msgid "th" msgstr "." #: src/humanize/number.py:28 msgctxt "6" msgid "th" msgstr "." #: src/humanize/number.py:29 msgctxt "7" msgid "th" msgstr "." #: src/humanize/number.py:30 msgctxt "8" msgid "th" msgstr "." #: src/humanize/number.py:31 msgctxt "9" msgid "th" msgstr "." #: src/humanize/number.py:73 msgid "million" msgstr "milióna/ov" #: src/humanize/number.py:74 msgid "billion" msgstr "miliardy/árd" #: src/humanize/number.py:75 msgid "trillion" msgstr "bilióna/ov" #: src/humanize/number.py:76 msgid "quadrillion" msgstr "biliardy/árd" #: src/humanize/number.py:77 msgid "quintillion" msgstr "trilióna/árd" #: src/humanize/number.py:78 msgid "sextillion" msgstr "triliardy/árd" #: src/humanize/number.py:79 msgid "septillion" msgstr "kvadrilióna/ov" #: src/humanize/number.py:80 msgid "octillion" msgstr "kvadriliardy/árd" #: src/humanize/number.py:81 msgid "nonillion" msgstr "kvintilióna/ov" #: src/humanize/number.py:82 msgid "decillion" msgstr "kvintiliardy/árd" #: src/humanize/number.py:83 msgid "googol" msgstr "googola/ov" #: src/humanize/number.py:138 msgid "zero" msgstr "nula" #: src/humanize/number.py:139 msgid "one" msgstr "jedna" #: src/humanize/number.py:140 msgid "two" msgstr "dve" #: src/humanize/number.py:141 msgid "three" msgstr "tri" #: src/humanize/number.py:142 msgid "four" msgstr "štyri" #: src/humanize/number.py:143 msgid "five" msgstr "päť" #: src/humanize/number.py:144 msgid "six" msgstr "šesť" #: src/humanize/number.py:145 msgid "seven" msgstr "sedem" #: src/humanize/number.py:146 msgid "eight" msgstr "osem" #: src/humanize/number.py:147 msgid "nine" msgstr "deväť" #: src/humanize/time.py:87 #, fuzzy, python-format msgid "%d microsecond" msgid_plural "%d microseconds" msgstr[0] "%d mikrosekundu" msgstr[1] "%d mikrosekundy" msgstr[2] "%d mikrosekúnd" #: src/humanize/time.py:93 #, fuzzy, python-format msgid "%d millisecond" msgid_plural "%d milliseconds" msgstr[0] "%d milisekundu" msgstr[1] "%d milisekundy" msgstr[2] "%d milisekúnd" #: src/humanize/time.py:96 src/humanize/time.py:170 msgid "a moment" msgstr "chvíľku" #: src/humanize/time.py:98 msgid "a second" msgstr "sekundu" #: src/humanize/time.py:100 #, python-format msgid "%d second" msgid_plural "%d seconds" msgstr[0] "%d sekundu" msgstr[1] "%d sekundy" msgstr[2] "%d sekúnd" #: src/humanize/time.py:102 msgid "a minute" msgstr "minútu" #: src/humanize/time.py:105 #, python-format msgid "%d minute" msgid_plural "%d minutes" msgstr[0] "%d minútu" msgstr[1] "%d minúty" msgstr[2] "%d minút" #: src/humanize/time.py:107 msgid "an hour" msgstr "hodinu" #: src/humanize/time.py:110 #, python-format msgid "%d hour" msgid_plural "%d hours" msgstr[0] "%d hodina" msgstr[1] "%d hodiny" msgstr[2] "%d hodín" #: src/humanize/time.py:113 msgid "a day" msgstr "deň" #: src/humanize/time.py:115 src/humanize/time.py:118 #, python-format msgid "%d day" msgid_plural "%d days" msgstr[0] "%d deň" msgstr[1] "%d dni" msgstr[2] "%d dní" #: src/humanize/time.py:120 msgid "a month" msgstr "mesiac" #: src/humanize/time.py:122 #, python-format msgid "%d month" msgid_plural "%d months" msgstr[0] "%d mesiac" msgstr[1] "%d mesiace" msgstr[2] "%d mesiacov" #: src/humanize/time.py:125 msgid "a year" msgstr "rok" #: src/humanize/time.py:127 src/humanize/time.py:136 #, python-format msgid "1 year, %d day" msgid_plural "1 year, %d days" msgstr[0] "1 rok, %d deň" msgstr[1] "1 rok, %d dni" msgstr[2] "1 rok, %d dní" #: src/humanize/time.py:130 msgid "1 year, 1 month" msgstr "1 rok, 1 mesiac" #: src/humanize/time.py:133 #, python-format msgid "1 year, %d month" msgid_plural "1 year, %d months" msgstr[0] "1 rok, %d mesiac" msgstr[1] "1 rok, %d mesiace" msgstr[2] "1 rok, %d mesiacov" #: src/humanize/time.py:138 #, python-format msgid "%d year" msgid_plural "%d years" msgstr[0] "%d rok" msgstr[1] "%d roky" msgstr[2] "%d rokov" #: src/humanize/time.py:167 #, python-format msgid "%s from now" msgstr "o %s" #: src/humanize/time.py:167 #, python-format msgid "%s ago" msgstr "%s naspäť" #: src/humanize/time.py:171 msgid "now" msgstr "teraz" #: src/humanize/time.py:190 msgid "today" msgstr "dnes" #: src/humanize/time.py:192 msgid "tomorrow" msgstr "zajtra" #: src/humanize/time.py:194 msgid "yesterday" msgstr "včera" napari-0.5.0a1/napari/_vendor/experimental/humanize/src/humanize/locale/tr_TR/000077500000000000000000000000001437041365600273445ustar00rootroot00000000000000napari-0.5.0a1/napari/_vendor/experimental/humanize/src/humanize/locale/tr_TR/LC_MESSAGES/000077500000000000000000000000001437041365600311315ustar00rootroot00000000000000humanize.po000066400000000000000000000110411437041365600332270ustar00rootroot00000000000000napari-0.5.0a1/napari/_vendor/experimental/humanize/src/humanize/locale/tr_TR/LC_MESSAGES# Turkish translation for humanize. # Copyright (C) 2017 THE PACKAGE'S COPYRIGHT HOLDER # This file is distributed under the same license as the humanize package. # Emre Çintay , 2017. # msgid "" msgstr "" "Project-Id-Version: humanize\n" "Report-Msgid-Bugs-To: \n" "POT-Creation-Date: 2020-02-08 20:05+0200\n" "PO-Revision-Date: 2017-02-23 20:00+0300\n" "Last-Translator: Emre Çintay \n" "Language-Team: Turkish\n" "Language: tr_TR\n" "MIME-Version: 1.0\n" "Content-Type: text/plain; charset=UTF-8\n" "Content-Transfer-Encoding: 8bit\n" "Plural-Forms: nplurals=2; plural=(n != 1);\n" "X-Generator: Poedit 1.8.7.1\n" "Generated-By: Emre Çintay\n" #: src/humanize/number.py:24 msgctxt "0" msgid "th" msgstr "." #: src/humanize/number.py:25 msgctxt "1" msgid "st" msgstr "." #: src/humanize/number.py:26 msgctxt "2" msgid "nd" msgstr "." #: src/humanize/number.py:27 msgctxt "3" msgid "rd" msgstr "." #: src/humanize/number.py:28 msgctxt "4" msgid "th" msgstr "." #: src/humanize/number.py:29 msgctxt "5" msgid "th" msgstr "." #: src/humanize/number.py:30 msgctxt "6" msgid "th" msgstr "." #: src/humanize/number.py:31 msgctxt "7" msgid "th" msgstr "." #: src/humanize/number.py:32 msgctxt "8" msgid "th" msgstr "." #: src/humanize/number.py:33 msgctxt "9" msgid "th" msgstr "." #: src/humanize/number.py:62 msgid "million" msgstr "milyon" #: src/humanize/number.py:63 msgid "billion" msgstr "milyar" #: src/humanize/number.py:64 msgid "trillion" msgstr "trilyon" #: src/humanize/number.py:65 msgid "quadrillion" msgstr "katrilyon" #: src/humanize/number.py:66 msgid "quintillion" msgstr "kentilyon" #: src/humanize/number.py:67 msgid "sextillion" msgstr "sekstilyon" #: src/humanize/number.py:68 msgid "septillion" msgstr "septilyon" #: src/humanize/number.py:69 msgid "octillion" msgstr "oktilyon" #: src/humanize/number.py:70 msgid "nonillion" msgstr "nonilyon" #: src/humanize/number.py:71 msgid "decillion" msgstr "desilyon" #: src/humanize/number.py:72 msgid "googol" msgstr "googol" #: src/humanize/number.py:108 msgid "one" msgstr "bir" #: src/humanize/number.py:109 msgid "two" msgstr "iki" #: src/humanize/number.py:110 msgid "three" msgstr "üç" #: src/humanize/number.py:111 msgid "four" msgstr "dört" #: src/humanize/number.py:112 msgid "five" msgstr "beş" #: src/humanize/number.py:113 msgid "six" msgstr "altı" #: src/humanize/number.py:114 msgid "seven" msgstr "yedi" #: src/humanize/number.py:115 msgid "eight" msgstr "sekiz" #: src/humanize/number.py:116 msgid "nine" msgstr "dokuz" #: src/humanize/time.py:68 src/humanize/time.py:131 msgid "a moment" msgstr "biraz" #: src/humanize/time.py:70 msgid "a second" msgstr "bir saniye" #: src/humanize/time.py:72 #, python-format msgid "%d second" msgid_plural "%d seconds" msgstr[0] "%d saniye" msgstr[1] "%d saniye" #: src/humanize/time.py:74 msgid "a minute" msgstr "bir dakika" #: src/humanize/time.py:77 #, python-format msgid "%d minute" msgid_plural "%d minutes" msgstr[0] "%d dakika" msgstr[1] "%d dakika" #: src/humanize/time.py:79 msgid "an hour" msgstr "bir saat" #: src/humanize/time.py:82 #, python-format msgid "%d hour" msgid_plural "%d hours" msgstr[0] "%d saat" msgstr[1] "%d saat" #: src/humanize/time.py:85 msgid "a day" msgstr "bir gün" #: src/humanize/time.py:87 src/humanize/time.py:90 #, python-format msgid "%d day" msgid_plural "%d days" msgstr[0] "%d gün" msgstr[1] "%d gün" #: src/humanize/time.py:92 msgid "a month" msgstr "bir ay" #: src/humanize/time.py:94 #, python-format msgid "%d month" msgid_plural "%d months" msgstr[0] "%d ay" msgstr[1] "%d ay" #: src/humanize/time.py:97 msgid "a year" msgstr "bir yıl" #: src/humanize/time.py:99 src/humanize/time.py:108 #, python-format msgid "1 year, %d day" msgid_plural "1 year, %d days" msgstr[0] "1 yıl, %d gün" msgstr[1] "1 yıl, %d gün" #: src/humanize/time.py:102 msgid "1 year, 1 month" msgstr "1 yıl, 1 ay" #: src/humanize/time.py:105 #, python-format msgid "1 year, %d month" msgid_plural "1 year, %d months" msgstr[0] "1 yıl, %d ay" msgstr[1] "1 yıl, %d ay" #: src/humanize/time.py:110 #, python-format msgid "%d year" msgid_plural "%d years" msgstr[0] "%d yıl" msgstr[1] "%d yıl" #: src/humanize/time.py:128 #, python-format msgid "%s from now" msgstr "şu andan itibaren %s" #: src/humanize/time.py:128 #, python-format msgid "%s ago" msgstr "%s önce" #: src/humanize/time.py:132 msgid "now" msgstr "şimdi" #: src/humanize/time.py:151 msgid "today" msgstr "bugün" #: src/humanize/time.py:153 msgid "tomorrow" msgstr "yarın" #: src/humanize/time.py:155 msgid "yesterday" msgstr "dün" napari-0.5.0a1/napari/_vendor/experimental/humanize/src/humanize/locale/uk_UA/000077500000000000000000000000001437041365600273165ustar00rootroot00000000000000napari-0.5.0a1/napari/_vendor/experimental/humanize/src/humanize/locale/uk_UA/LC_MESSAGES/000077500000000000000000000000001437041365600311035ustar00rootroot00000000000000humanize.po000066400000000000000000000127251437041365600332130ustar00rootroot00000000000000napari-0.5.0a1/napari/_vendor/experimental/humanize/src/humanize/locale/uk_UA/LC_MESSAGESmsgid "" msgstr "" "Project-Id-Version: PROJECT VERSION\n" "Report-Msgid-Bugs-To: \n" "POT-Creation-Date: 2020-03-22 16:59+0200\n" "PO-Revision-Date: \n" "Last-Translator: TL\n" "Language-Team: uk_UA\n" "Language: uk\n" "MIME-Version: 1.0\n" "Content-Type: text/plain; charset=utf-8\n" "Content-Transfer-Encoding: 8bit\n" "Plural-Forms: nplurals=3; plural=(n%10==1 && n%100!=11 ? 0 : n%10>=2 && n" "%10<=4 && (n%100<10 || n%100>=20) ? 1 : 2);\n" "Generated-By:\n" "X-Generator: \n" #: src/humanize/number.py:22 msgctxt "0" msgid "th" msgstr "ий" #: src/humanize/number.py:23 msgctxt "1" msgid "st" msgstr "ий" #: src/humanize/number.py:24 msgctxt "2" msgid "nd" msgstr "ий" #: src/humanize/number.py:25 msgctxt "3" msgid "rd" msgstr "ій" #: src/humanize/number.py:26 msgctxt "4" msgid "th" msgstr "ий" #: src/humanize/number.py:27 msgctxt "5" msgid "th" msgstr "ий" #: src/humanize/number.py:28 msgctxt "6" msgid "th" msgstr "ий" #: src/humanize/number.py:29 msgctxt "7" msgid "th" msgstr "ий" #: src/humanize/number.py:30 msgctxt "8" msgid "th" msgstr "ий" #: src/humanize/number.py:31 msgctxt "9" msgid "th" msgstr "ий" #: src/humanize/number.py:73 msgid "million" msgstr "мільйонів" #: src/humanize/number.py:74 msgid "billion" msgstr "мільярдів" #: src/humanize/number.py:75 msgid "trillion" msgstr "трильйонів" #: src/humanize/number.py:76 msgid "quadrillion" msgstr "квадрильйонів" #: src/humanize/number.py:77 msgid "quintillion" msgstr "квинтиліонів" #: src/humanize/number.py:78 msgid "sextillion" msgstr "сикстильйонів" #: src/humanize/number.py:79 msgid "septillion" msgstr "септильйонів" #: src/humanize/number.py:80 msgid "octillion" msgstr "октильйонів" #: src/humanize/number.py:81 msgid "nonillion" msgstr "нонильйонів" #: src/humanize/number.py:82 msgid "decillion" msgstr "децильйонів" #: src/humanize/number.py:83 msgid "googol" msgstr "гугола" #: src/humanize/number.py:138 msgid "zero" msgstr "нуль" #: src/humanize/number.py:139 msgid "one" msgstr "один" #: src/humanize/number.py:140 msgid "two" msgstr "два" #: src/humanize/number.py:141 msgid "three" msgstr "три" #: src/humanize/number.py:142 msgid "four" msgstr "чотири" #: src/humanize/number.py:143 msgid "five" msgstr "п'ять" #: src/humanize/number.py:144 msgid "six" msgstr "шість" #: src/humanize/number.py:145 msgid "seven" msgstr "сім" #: src/humanize/number.py:146 msgid "eight" msgstr "вісім" #: src/humanize/number.py:147 msgid "nine" msgstr "дев'ять" #: src/humanize/time.py:87 #, fuzzy, python-format msgid "%d microsecond" msgid_plural "%d microseconds" msgstr[0] "%d мікросекунда" msgstr[1] "%d мікросекунд" msgstr[2] "%d мікросекунди" #: src/humanize/time.py:93 #, fuzzy, python-format msgid "%d millisecond" msgid_plural "%d milliseconds" msgstr[0] "%d мілісекунда" msgstr[1] "%d мілісекунди" msgstr[2] "%d мілісекунди" #: src/humanize/time.py:96 src/humanize/time.py:170 msgid "a moment" msgstr "у цей момент" #: src/humanize/time.py:98 msgid "a second" msgstr "секунду" #: src/humanize/time.py:100 #, python-format msgid "%d second" msgid_plural "%d seconds" msgstr[0] "%d секунда" msgstr[1] "%d секунд" msgstr[2] "%d секунд" #: src/humanize/time.py:102 msgid "a minute" msgstr "хвилина" #: src/humanize/time.py:105 #, python-format msgid "%d minute" msgid_plural "%d minutes" msgstr[0] "%d хвилина" msgstr[1] "%d хвилини" msgstr[2] "%d хвилин" #: src/humanize/time.py:107 msgid "an hour" msgstr "година" #: src/humanize/time.py:110 #, python-format msgid "%d hour" msgid_plural "%d hours" msgstr[0] "%d година" msgstr[1] "%d годин" msgstr[2] "%d годин" #: src/humanize/time.py:113 msgid "a day" msgstr "день" #: src/humanize/time.py:115 src/humanize/time.py:118 #, python-format msgid "%d day" msgid_plural "%d days" msgstr[0] "%d день" msgstr[1] "%d дня" msgstr[2] "%d дні" #: src/humanize/time.py:120 msgid "a month" msgstr "місяць" #: src/humanize/time.py:122 #, python-format msgid "%d month" msgid_plural "%d months" msgstr[0] "%d місяць" msgstr[1] "%d місяця" msgstr[2] "%d місяців" #: src/humanize/time.py:125 msgid "a year" msgstr "рік" #: src/humanize/time.py:127 src/humanize/time.py:136 #, python-format msgid "1 year, %d day" msgid_plural "1 year, %d days" msgstr[0] "1 рік, %d день" msgstr[1] "1 рік, %d дня" msgstr[2] "1 рік, %d днів" #: src/humanize/time.py:130 msgid "1 year, 1 month" msgstr "1 рік, 1 місяць" #: src/humanize/time.py:133 #, python-format msgid "1 year, %d month" msgid_plural "1 year, %d months" msgstr[0] "1 рік, %d місяць" msgstr[1] "1 рік, %d місяця" msgstr[2] "1 рік, %d місяців" #: src/humanize/time.py:138 #, python-format msgid "%d year" msgid_plural "%d years" msgstr[0] "%d рік" msgstr[1] "%d роки" msgstr[2] "%d років" #: src/humanize/time.py:167 #, python-format msgid "%s from now" msgstr "через %s" #: src/humanize/time.py:167 #, python-format msgid "%s ago" msgstr "%s назад" #: src/humanize/time.py:171 msgid "now" msgstr "зараз" #: src/humanize/time.py:190 msgid "today" msgstr "сьогодні" #: src/humanize/time.py:192 msgid "tomorrow" msgstr "завтра" #: src/humanize/time.py:194 msgid "yesterday" msgstr "вчора" napari-0.5.0a1/napari/_vendor/experimental/humanize/src/humanize/locale/vi_VI/000077500000000000000000000000001437041365600273265ustar00rootroot00000000000000napari-0.5.0a1/napari/_vendor/experimental/humanize/src/humanize/locale/vi_VI/LC_MESSAGES/000077500000000000000000000000001437041365600311135ustar00rootroot00000000000000humanize.po000066400000000000000000000114261437041365600332200ustar00rootroot00000000000000napari-0.5.0a1/napari/_vendor/experimental/humanize/src/humanize/locale/vi_VI/LC_MESSAGES# French (France) translations for PROJECT. # Copyright (C) 2013 ORGANIZATION # This file is distributed under the same license as the PROJECT project. # FIRST AUTHOR , 2013. # msgid "" msgstr "" "Project-Id-Version: PROJECT VERSION\n" "Report-Msgid-Bugs-To: \n" "POT-Creation-Date: 2020-02-08 20:05+0200\n" "PO-Revision-Date: 2017-05-30 11:51+0700\n" "Last-Translator: Olivier Cortès \n" "Language-Team: vi_VI \n" "Language: vi_VN\n" "MIME-Version: 1.0\n" "Content-Type: text/plain; charset=UTF-8\n" "Content-Transfer-Encoding: 8bit\n" "Plural-Forms: nplurals=2; plural=(n > 1);\n" "Generated-By: Babel 0.9.6\n" "X-Generator: Poedit 1.8.7.1\n" #: src/humanize/number.py:24 msgctxt "0" msgid "th" msgstr "." #: src/humanize/number.py:25 msgctxt "1" msgid "st" msgstr "." #: src/humanize/number.py:26 msgctxt "2" msgid "nd" msgstr "." #: src/humanize/number.py:27 msgctxt "3" msgid "rd" msgstr "." #: src/humanize/number.py:28 msgctxt "4" msgid "th" msgstr "." #: src/humanize/number.py:29 msgctxt "5" msgid "th" msgstr "." #: src/humanize/number.py:30 msgctxt "6" msgid "th" msgstr "." #: src/humanize/number.py:31 msgctxt "7" msgid "th" msgstr "." #: src/humanize/number.py:32 msgctxt "8" msgid "th" msgstr "." #: src/humanize/number.py:33 msgctxt "9" msgid "th" msgstr "." #: src/humanize/number.py:62 msgid "million" msgstr "%(value)s triệu" #: src/humanize/number.py:63 msgid "billion" msgstr "tỷ" #: src/humanize/number.py:64 msgid "trillion" msgstr "%(value)s nghìn tỷ" #: src/humanize/number.py:65 msgid "quadrillion" msgstr "%(value)s triệu tỷ" #: src/humanize/number.py:66 #, fuzzy msgid "quintillion" msgstr "%(value)s quintillion" #: src/humanize/number.py:67 #, fuzzy msgid "sextillion" msgstr "%(value)s sextillion" #: src/humanize/number.py:68 #, fuzzy msgid "septillion" msgstr "%(value)s septillion" #: src/humanize/number.py:69 #, fuzzy msgid "octillion" msgstr "%(value)s octillion" #: src/humanize/number.py:70 #, fuzzy msgid "nonillion" msgstr "%(value)s nonillion" #: src/humanize/number.py:71 #, fuzzy msgid "decillion" msgstr "%(value)s décillion" #: src/humanize/number.py:72 #, fuzzy msgid "googol" msgstr "%(value)s gogol" #: src/humanize/number.py:108 msgid "one" msgstr "một" #: src/humanize/number.py:109 msgid "two" msgstr "hai" #: src/humanize/number.py:110 msgid "three" msgstr "ba" #: src/humanize/number.py:111 msgid "four" msgstr "bốn" #: src/humanize/number.py:112 msgid "five" msgstr "năm" #: src/humanize/number.py:113 msgid "six" msgstr "sáu" #: src/humanize/number.py:114 msgid "seven" msgstr "bảy" #: src/humanize/number.py:115 msgid "eight" msgstr "tám" #: src/humanize/number.py:116 msgid "nine" msgstr "chín" #: src/humanize/time.py:68 src/humanize/time.py:131 msgid "a moment" msgstr "ngay lúc này" #: src/humanize/time.py:70 msgid "a second" msgstr "một giây" #: src/humanize/time.py:72 #, python-format msgid "%d second" msgid_plural "%d seconds" msgstr[0] "%d giây" msgstr[1] "%d giây" #: src/humanize/time.py:74 msgid "a minute" msgstr "một phút" #: src/humanize/time.py:77 #, python-format msgid "%d minute" msgid_plural "%d minutes" msgstr[0] "%d phút" msgstr[1] "%d phút" #: src/humanize/time.py:79 msgid "an hour" msgstr "một giờ" #: src/humanize/time.py:82 #, python-format msgid "%d hour" msgid_plural "%d hours" msgstr[0] "%d giờ" msgstr[1] "%d giờ" #: src/humanize/time.py:85 msgid "a day" msgstr "một ngày" #: src/humanize/time.py:87 src/humanize/time.py:90 #, python-format msgid "%d day" msgid_plural "%d days" msgstr[0] "%d ngày" msgstr[1] "%d ngày" #: src/humanize/time.py:92 msgid "a month" msgstr "một tháng" #: src/humanize/time.py:94 #, python-format msgid "%d month" msgid_plural "%d months" msgstr[0] "%d tháng" msgstr[1] "%d tháng" #: src/humanize/time.py:97 msgid "a year" msgstr "một năm" #: src/humanize/time.py:99 src/humanize/time.py:108 #, python-format msgid "1 year, %d day" msgid_plural "1 year, %d days" msgstr[0] "1 năm %d ngày" msgstr[1] "1 năm %d ngày" #: src/humanize/time.py:102 msgid "1 year, 1 month" msgstr "1 năm 1 tháng" #: src/humanize/time.py:105 #, python-format msgid "1 year, %d month" msgid_plural "1 year, %d months" msgstr[0] "1 năm %d tháng" msgstr[1] "un an et %d mois" #: src/humanize/time.py:110 #, python-format msgid "%d year" msgid_plural "%d years" msgstr[0] "%d năm" msgstr[1] "%d năm" #: src/humanize/time.py:128 #, python-format msgid "%s from now" msgstr "%s ngày tới" #: src/humanize/time.py:128 #, python-format msgid "%s ago" msgstr "%s trước" #: src/humanize/time.py:132 msgid "now" msgstr "ngay bây giờ" #: src/humanize/time.py:151 msgid "today" msgstr "hôm nay" #: src/humanize/time.py:153 msgid "tomorrow" msgstr "ngày mai" #: src/humanize/time.py:155 msgid "yesterday" msgstr "ngày hôm qua" napari-0.5.0a1/napari/_vendor/experimental/humanize/src/humanize/locale/zh_CN/000077500000000000000000000000001437041365600273135ustar00rootroot00000000000000napari-0.5.0a1/napari/_vendor/experimental/humanize/src/humanize/locale/zh_CN/LC_MESSAGES/000077500000000000000000000000001437041365600311005ustar00rootroot00000000000000humanize.po000066400000000000000000000106541437041365600332070ustar00rootroot00000000000000napari-0.5.0a1/napari/_vendor/experimental/humanize/src/humanize/locale/zh_CN/LC_MESSAGES# Simplified Chinese (China) translation for the project # Copyright (C) 2016 # This file is distributed under the same license as the PACKAGE package. # AZLisme , 2016. # Liwen SUN , 2019. # msgid "" msgstr "" "Project-Id-Version: 1.0\n" "Report-Msgid-Bugs-To: \n" "POT-Creation-Date: 2020-02-08 20:05+0200\n" "PO-Revision-Date: 2016-11-14 23:02+0000\n" "Last-Translator: Liwen SUN \n" "Language-Team: Chinese (simplified)\n" "Language: zh_CN\n" "MIME-Version: 1.0\n" "Content-Type: text/plain; charset=UTF-8\n" "Content-Transfer-Encoding: 8bit\n" "Plural-Forms: nplurals=2; plural=(n > 1);\n" #: src/humanize/number.py:24 msgctxt "0" msgid "th" msgstr "第" #: src/humanize/number.py:25 msgctxt "1" msgid "st" msgstr "第" #: src/humanize/number.py:26 msgctxt "2" msgid "nd" msgstr "第" #: src/humanize/number.py:27 msgctxt "3" msgid "rd" msgstr "第" #: src/humanize/number.py:28 msgctxt "4" msgid "th" msgstr "第" #: src/humanize/number.py:29 msgctxt "5" msgid "th" msgstr "第" #: src/humanize/number.py:30 msgctxt "6" msgid "th" msgstr "第" #: src/humanize/number.py:31 msgctxt "7" msgid "th" msgstr "第" #: src/humanize/number.py:32 msgctxt "8" msgid "th" msgstr "第" #: src/humanize/number.py:33 msgctxt "9" msgid "th" msgstr "第" #: src/humanize/number.py:62 msgid "million" msgstr "百万" #: src/humanize/number.py:63 msgid "billion" msgstr "十亿" #: src/humanize/number.py:64 msgid "trillion" msgstr "兆" #: src/humanize/number.py:65 msgid "quadrillion" msgstr "万亿" #: src/humanize/number.py:66 msgid "quintillion" msgstr "百京" #: src/humanize/number.py:67 msgid "sextillion" msgstr "十垓" #: src/humanize/number.py:68 msgid "septillion" msgstr "秭" #: src/humanize/number.py:69 msgid "octillion" msgstr "千秭" #: src/humanize/number.py:70 msgid "nonillion" msgstr "百穰" #: src/humanize/number.py:71 msgid "decillion" msgstr "十沟" #: src/humanize/number.py:72 msgid "googol" msgstr "古高尔" #: src/humanize/number.py:108 msgid "one" msgstr "一" #: src/humanize/number.py:109 msgid "two" msgstr "二" #: src/humanize/number.py:110 msgid "three" msgstr "三" #: src/humanize/number.py:111 msgid "four" msgstr "四" #: src/humanize/number.py:112 msgid "five" msgstr "五" #: src/humanize/number.py:113 msgid "six" msgstr "六" #: src/humanize/number.py:114 msgid "seven" msgstr "七" #: src/humanize/number.py:115 msgid "eight" msgstr "八" #: src/humanize/number.py:116 msgid "nine" msgstr "九" #: src/humanize/time.py:68 src/humanize/time.py:131 msgid "a moment" msgstr "一会儿" #: src/humanize/time.py:70 msgid "a second" msgstr "1秒" #: src/humanize/time.py:72 #, python-format msgid "%d second" msgid_plural "%d seconds" msgstr[0] "%d秒" msgstr[1] "%d秒" #: src/humanize/time.py:74 msgid "a minute" msgstr "1分" #: src/humanize/time.py:77 #, python-format msgid "%d minute" msgid_plural "%d minutes" msgstr[0] "%d分" msgstr[1] "%d分" #: src/humanize/time.py:79 msgid "an hour" msgstr "1小时" #: src/humanize/time.py:82 #, python-format msgid "%d hour" msgid_plural "%d hours" msgstr[0] "%d小时" msgstr[1] "%d小时" #: src/humanize/time.py:85 msgid "a day" msgstr "1天" #: src/humanize/time.py:87 src/humanize/time.py:90 #, python-format msgid "%d day" msgid_plural "%d days" msgstr[0] "%d天" msgstr[1] "%d天" #: src/humanize/time.py:92 msgid "a month" msgstr "1月" #: src/humanize/time.py:94 #, python-format msgid "%d month" msgid_plural "%d months" msgstr[0] "%d月" msgstr[1] "%d月" #: src/humanize/time.py:97 msgid "a year" msgstr "1年" #: src/humanize/time.py:99 src/humanize/time.py:108 #, python-format msgid "1 year, %d day" msgid_plural "1 year, %d days" msgstr[0] "%d年" msgstr[1] "%d年" #: src/humanize/time.py:102 msgid "1 year, 1 month" msgstr "1年又1月" #: src/humanize/time.py:105 #, python-format msgid "1 year, %d month" msgid_plural "1 year, %d months" msgstr[0] "1年又%d月" msgstr[1] "1年又%d月" #: src/humanize/time.py:110 #, python-format msgid "%d year" msgid_plural "%d years" msgstr[0] "%d年" msgstr[1] "%d年" #: src/humanize/time.py:128 #, python-format msgid "%s from now" msgstr "%s之后" #: src/humanize/time.py:128 #, python-format msgid "%s ago" msgstr "%s之前" #: src/humanize/time.py:132 msgid "now" msgstr "现在" #: src/humanize/time.py:151 msgid "today" msgstr "今天" #: src/humanize/time.py:153 msgid "tomorrow" msgstr "明天" #: src/humanize/time.py:155 msgid "yesterday" msgstr "昨天" napari-0.5.0a1/napari/_vendor/experimental/humanize/src/humanize/number.py000066400000000000000000000157401437041365600267240ustar00rootroot00000000000000#!/usr/bin/env python """Humanizing functions for numbers.""" import re from fractions import Fraction from .i18n import gettext as _ from .i18n import gettext_noop as N_ from .i18n import pgettext as P_ def ordinal(value): """Converts an integer to its ordinal as a string. For example, 1 is "1st", 2 is "2nd", 3 is "3rd", etc. Works for any integer or anything `int()` will turn into an integer. Anything other value will have nothing done to it. Args: value (int, str, float): Integer to convert. Returns: str: Ordinal string. """ try: value = int(value) except (TypeError, ValueError): return value t = ( P_("0", "th"), P_("1", "st"), P_("2", "nd"), P_("3", "rd"), P_("4", "th"), P_("5", "th"), P_("6", "th"), P_("7", "th"), P_("8", "th"), P_("9", "th"), ) if value % 100 in (11, 12, 13): # special case return f"{value}{t[0]}" return f"{value}{t[value % 10]}" def intcomma(value, ndigits=None): """Converts an integer to a string containing commas every three digits. For example, 3000 becomes "3,000" and 45000 becomes "45,000". To maintain some compatibility with Django's `intcomma`, this function also accepts floats. Args: value (int, float, str): Integer or float to convert. ndigits (int, None): Digits of precision for rounding after the decimal point. Returns: str: string containing commas every three digits. """ try: if isinstance(value, str): float(value.replace(",", "")) else: float(value) except (TypeError, ValueError): return value if ndigits: orig = "{0:.{1}f}".format(value, ndigits) else: orig = str(value) new = re.sub(r"^(-?\d+)(\d{3})", r"\g<1>,\g<2>", orig) if orig == new: return new else: return intcomma(new) powers = [10 ** x for x in (6, 9, 12, 15, 18, 21, 24, 27, 30, 33, 100)] human_powers = ( N_("million"), N_("billion"), N_("trillion"), N_("quadrillion"), N_("quintillion"), N_("sextillion"), N_("septillion"), N_("octillion"), N_("nonillion"), N_("decillion"), N_("googol"), ) def intword(value, format="%.1f"): """Converts a large integer to a friendly text representation. Works best for numbers over 1 million. For example, 1_000_000 becomes "1.0 million", 1200000 becomes "1.2 million" and "1_200_000_000" becomes "1.2 billion". Supports up to decillion (33 digits) and googol (100 digits). Args: value (int, float, str): Integer to convert. format (str): To change the number of decimal or general format of the number portion. Returns: str: Friendly text representation as a string, unless the value passed could not be coaxed into an `int`. """ try: value = int(value) except (TypeError, ValueError): return value if value < powers[0]: return str(value) for ordinal, power in enumerate(powers[1:], 1): if value < power: chopped = value / float(powers[ordinal - 1]) if float(format % chopped) == float(10 ** 3): chopped = value / float(powers[ordinal]) return (" ".join([format, _(human_powers[ordinal])])) % chopped else: return (" ".join([format, _(human_powers[ordinal - 1])])) % chopped return str(value) def apnumber(value): """Converts an integer to Associated Press style. Args: value (int, float, str): Integer to convert. Returns: str: For numbers 0-9, the number spelled out. Otherwise, the number. This always returns a string unless the value was not `int`-able, unlike the Django filter. """ try: value = int(value) except (TypeError, ValueError): return value if not 0 <= value < 10: return str(value) return ( _("zero"), _("one"), _("two"), _("three"), _("four"), _("five"), _("six"), _("seven"), _("eight"), _("nine"), )[value] def fractional(value): """Convert to fractional number. There will be some cases where one might not want to show ugly decimal places for floats and decimals. This function returns a human-readable fractional number in form of fractions and mixed fractions. Pass in a string, or a number or a float, and this function returns: * a string representation of a fraction * or a whole number * or a mixed fraction Examples: ```pycon >>> fractional(0.3) '3/10' >>> fractional(1.3) '1 3/10' >>> fractional(float(1/3)) '1/3' >>> fractional(1) '1' ``` Args: value (int, float, str): Integer to convert. Returns: str: Fractional number as a string. """ try: number = float(value) except (TypeError, ValueError): return value whole_number = int(number) frac = Fraction(number - whole_number).limit_denominator(1000) numerator = frac._numerator denominator = frac._denominator if whole_number and not numerator and denominator == 1: # this means that an integer was passed in # (or variants of that integer like 1.0000) return f"{whole_number:.0f}" elif not whole_number: return f"{numerator:.0f}/{denominator:.0f}" else: return f"{whole_number:.0f} {numerator:.0f}/{denominator:.0f}" def scientific(value, precision=2): """Return number in string scientific notation z.wq x 10ⁿ. Examples: ```pycon >>> scientific(float(0.3)) '3.00 x 10⁻¹' >>> scientific(int(500)) '5.00 x 10²' ``` Args: value (int, float, str): Input number. precision (int): Number of decimal for first part of the number. Returns: str: Number in scientific notation z.wq x 10ⁿ. """ exponents = { "0": "⁰", "1": "¹", "2": "²", "3": "³", "4": "⁴", "5": "⁵", "6": "⁶", "7": "⁷", "8": "⁸", "9": "⁹", "+": "⁺", "-": "⁻", } negative = False try: if "-" in str(value): value = str(value).replace("-", "") negative = True if isinstance(value, str): value = float(value) fmt = "{:.%se}" % str(int(precision)) n = fmt.format(value) except (ValueError, TypeError): return value part1, part2 = n.split("e") if "-0" in part2: part2 = part2.replace("-0", "-") if "+0" in part2: part2 = part2.replace("+0", "") new_part2 = [] if negative: new_part2.append(exponents["-"]) for char in part2: new_part2.append(exponents[char]) final_str = part1 + " x 10" + "".join(new_part2) return final_str napari-0.5.0a1/napari/_vendor/experimental/humanize/src/humanize/time.py000066400000000000000000000367031437041365600263740ustar00rootroot00000000000000#!/usr/bin/env python """Time humanizing functions. These are largely borrowed from Django's `contrib.humanize`.""" import datetime as dt import math from enum import Enum from functools import total_ordering from .i18n import gettext as _ from .i18n import ngettext __all__ = [ "naturaldelta", "naturaltime", "naturalday", "naturaldate", "precisedelta", ] @total_ordering class Unit(Enum): MICROSECONDS = 0 MILLISECONDS = 1 SECONDS = 2 MINUTES = 3 HOURS = 4 DAYS = 5 MONTHS = 6 YEARS = 7 def __lt__(self, other): if self.__class__ is other.__class__: return self.value < other.value return NotImplemented def _now(): return dt.datetime.now() def abs_timedelta(delta): """Return an "absolute" value for a timedelta, always representing a time distance. Args: delta (datetime.timedelta): Input timedelta. Returns: datetime.timedelta: Absolute timedelta. """ if delta.days < 0: now = _now() return now - (now + delta) return delta def date_and_delta(value, *, now=None): """Turn a value into a date and a timedelta which represents how long ago it was. If that's not possible, return `(None, value)`. """ if not now: now = _now() if isinstance(value, dt.datetime): date = value delta = now - value elif isinstance(value, dt.timedelta): date = now - value delta = value else: try: value = int(value) delta = dt.timedelta(seconds=value) date = now - delta except (ValueError, TypeError): return None, value return date, abs_timedelta(delta) def naturaldelta(value, months=True, minimum_unit="seconds"): """Return a natural representation of a timedelta or number of seconds. This is similar to `naturaltime`, but does not add tense to the result. Args: value (datetime.timedelta): A timedelta or a number of seconds. months (bool): If `True`, then a number of months (based on 30.5 days) will be used for fuzziness between years. minimum_unit (str): The lowest unit that can be used. Returns: str: A natural representation of the amount of time elapsed. """ tmp = Unit[minimum_unit.upper()] if tmp not in (Unit.SECONDS, Unit.MILLISECONDS, Unit.MICROSECONDS): raise ValueError(f"Minimum unit '{minimum_unit}' not supported") minimum_unit = tmp date, delta = date_and_delta(value) if date is None: return value use_months = months seconds = abs(delta.seconds) days = abs(delta.days) years = days // 365 days = days % 365 months = int(days // 30.5) if not years and days < 1: if seconds == 0: if minimum_unit == Unit.MICROSECONDS and delta.microseconds < 1000: return ( ngettext("%d microsecond", "%d microseconds", delta.microseconds) % delta.microseconds ) elif minimum_unit == Unit.MILLISECONDS or ( minimum_unit == Unit.MICROSECONDS and 1000 <= delta.microseconds < 1_000_000 ): milliseconds = delta.microseconds / 1000 return ( ngettext("%d millisecond", "%d milliseconds", milliseconds) % milliseconds ) return _("a moment") elif seconds == 1: return _("a second") elif seconds < 60: return ngettext("%d second", "%d seconds", seconds) % seconds elif 60 <= seconds < 120: return _("a minute") elif 120 <= seconds < 3600: minutes = seconds // 60 return ngettext("%d minute", "%d minutes", minutes) % minutes elif 3600 <= seconds < 3600 * 2: return _("an hour") elif 3600 < seconds: hours = seconds // 3600 return ngettext("%d hour", "%d hours", hours) % hours elif years == 0: if days == 1: return _("a day") if not use_months: return ngettext("%d day", "%d days", days) % days else: if not months: return ngettext("%d day", "%d days", days) % days elif months == 1: return _("a month") else: return ngettext("%d month", "%d months", months) % months elif years == 1: if not months and not days: return _("a year") elif not months: return ngettext("1 year, %d day", "1 year, %d days", days) % days elif use_months: if months == 1: return _("1 year, 1 month") else: return ( ngettext("1 year, %d month", "1 year, %d months", months) % months ) else: return ngettext("1 year, %d day", "1 year, %d days", days) % days else: return ngettext("%d year", "%d years", years) % years def naturaltime(value, future=False, months=True, minimum_unit="seconds"): """Return a natural representation of a time in a resolution that makes sense. This is more or less compatible with Django's `naturaltime` filter. Args: value (datetime.datetime, int): A `datetime` or a number of seconds. future (bool): Ignored for `datetime`s, where the tense is always figured out based on the current time. For integers, the return value will be past tense by default, unless future is `True`. months (bool): If `True`, then a number of months (based on 30.5 days) will be used for fuzziness between years. minimum_unit (str): The lowest unit that can be used. Returns: str: A natural representation of the input in a resolution that makes sense. """ now = _now() date, delta = date_and_delta(value, now=now) if date is None: return value # determine tense by value only if datetime/timedelta were passed if isinstance(value, (dt.datetime, dt.timedelta)): future = date > now ago = _("%s from now") if future else _("%s ago") delta = naturaldelta(delta, months, minimum_unit) if delta == _("a moment"): return _("now") return ago % delta def naturalday(value, format="%b %d"): """For date values that are tomorrow, today or yesterday compared to present day returns representing string. Otherwise, returns a string formatted according to `format`.""" try: value = dt.date(value.year, value.month, value.day) except AttributeError: # Passed value wasn't date-ish return value except (OverflowError, ValueError): # Date arguments out of range return value delta = value - dt.date.today() if delta.days == 0: return _("today") elif delta.days == 1: return _("tomorrow") elif delta.days == -1: return _("yesterday") return value.strftime(format) def naturaldate(value): """Like `naturalday`, but append a year for dates more than about five months away. """ try: value = dt.date(value.year, value.month, value.day) except AttributeError: # Passed value wasn't date-ish return value except (OverflowError, ValueError): # Date arguments out of range return value delta = abs_timedelta(value - dt.date.today()) if delta.days >= 5 * 365 / 12: return naturalday(value, "%b %d %Y") return naturalday(value) def _quotient_and_remainder(value, divisor, unit, minimum_unit, suppress): """Divide `value` by `divisor` returning the quotient and the remainder as follows: If `unit` is `minimum_unit`, makes the quotient a float number and the remainder will be zero. The rational is that if unit is the unit of the quotient, we cannot represent the remainder because it would require a unit smaller than the minimum_unit. >>> from humanize.time import _quotient_and_remainder, Unit >>> _quotient_and_remainder(36, 24, Unit.DAYS, Unit.DAYS, []) (1.5, 0) If unit is in suppress, the quotient will be zero and the remainder will be the initial value. The idea is that if we cannot use unit, we are forced to use a lower unit so we cannot do the division. >>> _quotient_and_remainder(36, 24, Unit.DAYS, Unit.HOURS, [Unit.DAYS]) (0, 36) In other case return quotient and remainder as `divmod` would do it. >>> _quotient_and_remainder(36, 24, Unit.DAYS, Unit.HOURS, []) (1, 12) """ if unit == minimum_unit: return (value / divisor, 0) elif unit in suppress: return (0, value) else: return divmod(value, divisor) def _carry(value1, value2, ratio, unit, min_unit, suppress): """Return a tuple with two values as follows: If the unit is in suppress multiplies value1 by ratio and add it to value2 (carry to right). The idea is that if we cannot represent value1 we need to represent it in a lower unit. >>> from humanize.time import _carry, Unit >>> _carry(2, 6, 24, Unit.DAYS, Unit.SECONDS, [Unit.DAYS]) (0, 54) If the unit is the minimum unit, value2 is divided by ratio and added to value1 (carry to left). We assume that value2 has a lower unit so we need to carry it to value1. >>> _carry(2, 6, 24, Unit.DAYS, Unit.DAYS, []) (2.25, 0) Otherwise, just return the same input: >>> _carry(2, 6, 24, Unit.DAYS, Unit.SECONDS, []) (2, 6) """ if unit == min_unit: return (value1 + value2 / ratio, 0) elif unit in suppress: return (0, value2 + value1 * ratio) else: return (value1, value2) def _suitable_minimum_unit(min_unit, suppress): """Return a minimum unit suitable that is not suppressed. If not suppressed, return the same unit: >>> from humanize.time import _suitable_minimum_unit, Unit >>> _suitable_minimum_unit(Unit.HOURS, []) But if suppressed, find a unit greather than the original one that is not suppressed: >>> _suitable_minimum_unit(Unit.HOURS, [Unit.HOURS]) >>> _suitable_minimum_unit(Unit.HOURS, [Unit.HOURS, Unit.DAYS]) """ if min_unit in suppress: for unit in Unit: if unit > min_unit and unit not in suppress: return unit raise ValueError( "Minimum unit is suppressed and no suitable replacement was found" ) return min_unit def _suppress_lower_units(min_unit, suppress): """Extend the suppressed units (if any) with all the units that are lower than the minimum unit. >>> from humanize.time import _suppress_lower_units, Unit >>> list(sorted(_suppress_lower_units(Unit.SECONDS, [Unit.DAYS]))) [, , ] """ suppress = set(suppress) for u in Unit: if u == min_unit: break suppress.add(u) return suppress def precisedelta(value, minimum_unit="seconds", suppress=(), format="%0.2f"): """Return a precise representation of a timedelta. ```pycon >>> import datetime as dt >>> from humanize.time import precisedelta >>> delta = dt.timedelta(seconds=3633, days=2, microseconds=123000) >>> precisedelta(delta) '2 days, 1 hour and 33.12 seconds' ``` A custom `format` can be specified to control how the fractional part is represented: ```pycon >>> precisedelta(delta, format="%0.4f") '2 days, 1 hour and 33.1230 seconds' ``` Instead, the `minimum_unit` can be changed to have a better resolution; the function will still readjust the unit to use the greatest of the units that does not lose precision. For example setting microseconds but still representing the date with milliseconds: ```pycon >>> precisedelta(delta, minimum_unit="microseconds") '2 days, 1 hour, 33 seconds and 123 milliseconds' ``` If desired, some units can be suppressed: you will not see them represented and the time of the other units will be adjusted to keep representing the same timedelta: ```pycon >>> precisedelta(delta, suppress=['days']) '49 hours and 33.12 seconds' ``` Note that microseconds precision is lost if the seconds and all the units below are suppressed: ```pycon >>> delta = dt.timedelta(seconds=90, microseconds=100) >>> precisedelta(delta, suppress=['seconds', 'milliseconds', 'microseconds']) '1.50 minutes' ``` """ date, delta = date_and_delta(value) if date is None: return value suppress = [Unit[s.upper()] for s in suppress] # Find a suitable minimum unit (it can be greater the one that the # user gave us if it is suppressed). min_unit = Unit[minimum_unit.upper()] min_unit = _suitable_minimum_unit(min_unit, suppress) del minimum_unit # Expand the suppressed units list/set to include all the units # that are below the minimum unit suppress = _suppress_lower_units(min_unit, suppress) # handy aliases days = delta.days secs = delta.seconds usecs = delta.microseconds MICROSECONDS, MILLISECONDS, SECONDS, MINUTES, HOURS, DAYS, MONTHS, YEARS = list( Unit ) # Given DAYS compute YEARS and the remainder of DAYS as follows: # if YEARS is the minimum unit, we cannot use DAYS so # we will use a float for YEARS and 0 for DAYS: # years, days = years/days, 0 # # if YEARS is suppressed, use DAYS: # years, days = 0, days # # otherwise: # years, days = divmod(years, days) # # The same applies for months, hours, minutes and milliseconds below years, days = _quotient_and_remainder(days, 365, YEARS, min_unit, suppress) months, days = _quotient_and_remainder(days, 30.5, MONTHS, min_unit, suppress) # If DAYS is not in suppress, we can represent the days but # if it is a suppressed unit, we need to carry it to a lower unit, # seconds in this case. # # The same applies for secs and usecs below days, secs = _carry(days, secs, 24 * 3600, DAYS, min_unit, suppress) hours, secs = _quotient_and_remainder(secs, 3600, HOURS, min_unit, suppress) minutes, secs = _quotient_and_remainder(secs, 60, MINUTES, min_unit, suppress) secs, usecs = _carry(secs, usecs, 1e6, SECONDS, min_unit, suppress) msecs, usecs = _quotient_and_remainder( usecs, 1000, MILLISECONDS, min_unit, suppress ) # if _unused != 0 we had lost some precision usecs, _unused = _carry(usecs, 0, 1, MICROSECONDS, min_unit, suppress) fmts = [ ("%d year", "%d years", years), ("%d month", "%d months", months), ("%d day", "%d days", days), ("%d hour", "%d hours", hours), ("%d minute", "%d minutes", minutes), ("%d second", "%d seconds", secs), ("%d millisecond", "%d milliseconds", msecs), ("%d microsecond", "%d microseconds", usecs), ] texts = [] for unit, fmt in zip(reversed(Unit), fmts): singular_txt, plural_txt, value = fmt if value > 0: fmt_txt = ngettext(singular_txt, plural_txt, value) if unit == min_unit and math.modf(value)[0] > 0: fmt_txt = fmt_txt.replace("%d", format) texts.append(fmt_txt % value) if unit == min_unit: break if len(texts) == 1: return texts[0] head = ", ".join(texts[:-1]) tail = texts[-1] return " and ".join((head, tail)) napari-0.5.0a1/napari/_vendor/experimental/vendor.txt000066400000000000000000000000421437041365600226560ustar00rootroot00000000000000cachetools==4.1.1 humanize==2.5.0 napari-0.5.0a1/napari/_vendor/qt_json_builder/000077500000000000000000000000001437041365600213125ustar00rootroot00000000000000napari-0.5.0a1/napari/_vendor/qt_json_builder/LICENSE000066400000000000000000000020571437041365600223230ustar00rootroot00000000000000MIT License Copyright (c) 2018 Angus Hollands 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. napari-0.5.0a1/napari/_vendor/qt_json_builder/__init__.py000066400000000000000000000000001437041365600234110ustar00rootroot00000000000000napari-0.5.0a1/napari/_vendor/qt_json_builder/qt_jsonschema_form/000077500000000000000000000000001437041365600251735ustar00rootroot00000000000000napari-0.5.0a1/napari/_vendor/qt_json_builder/qt_jsonschema_form/__init__.py000066400000000000000000000000401437041365600272760ustar00rootroot00000000000000from .form import WidgetBuilder napari-0.5.0a1/napari/_vendor/qt_json_builder/qt_jsonschema_form/defaults.py000066400000000000000000000015211437041365600273530ustar00rootroot00000000000000def enum_defaults(schema): try: return schema["enum"][0] except IndexError: return None def object_defaults(schema): if "properties" in schema: return { k: compute_defaults(s) for k, s in schema["properties"].items() } else: return None def array_defaults(schema): items_schema = schema['items'] if isinstance(items_schema, dict): return [] return [compute_defaults(s) for s in schema["items"]] def compute_defaults(schema): if "default" in schema: return schema["default"] # Enum if "enum" in schema: return enum_defaults(schema) schema_type = schema["type"] if schema_type == "object": return object_defaults(schema) elif schema_type == "array": return array_defaults(schema) return None napari-0.5.0a1/napari/_vendor/qt_json_builder/qt_jsonschema_form/form.py000066400000000000000000000075321437041365600265170ustar00rootroot00000000000000from copy import deepcopy from jsonschema.validators import validator_for from . import widgets from .defaults import compute_defaults def get_widget_state(schema, state=None): if state is None: return compute_defaults(schema) return state def get_schema_type(schema: dict) -> str: return schema['type'] class WidgetBuilder: default_widget_map = { "boolean": { "checkbox": widgets.CheckboxSchemaWidget, "enum": widgets.EnumSchemaWidget, }, "object": { "object": widgets.ObjectSchemaWidget, "enum": widgets.EnumSchemaWidget, "plugins": widgets.PluginWidget, "shortcuts": widgets.ShortcutsWidget, "extension2reader": widgets.Extension2ReaderWidget, }, "number": { "spin": widgets.SpinDoubleSchemaWidget, "text": widgets.TextSchemaWidget, "enum": widgets.EnumSchemaWidget, }, "string": { "textarea": widgets.TextAreaSchemaWidget, "text": widgets.TextSchemaWidget, "password": widgets.PasswordWidget, "filepath": widgets.FilepathSchemaWidget, "colour": widgets.ColorSchemaWidget, "enum": widgets.EnumSchemaWidget, }, "integer": { "spin": widgets.SpinSchemaWidget, "text": widgets.TextSchemaWidget, "range": widgets.IntegerRangeSchemaWidget, "enum": widgets.EnumSchemaWidget, "highlight": widgets.HighlightSizePreviewWidget, }, "array": { "array": widgets.ArraySchemaWidget, "enum": widgets.EnumSchemaWidget, }, } default_widget_variants = { "boolean": "checkbox", "object": "object", "array": "array", "number": "spin", "integer": "spin", "string": "text", } widget_variant_modifiers = { "string": lambda schema: schema.get("format", "text") } def __init__(self, validator_cls=None): self.widget_map = deepcopy(self.default_widget_map) self.validator_cls = validator_cls def create_form( self, schema: dict, ui_schema: dict, state=None ) -> widgets.SchemaWidgetMixin: validator_cls = self.validator_cls if validator_cls is None: validator_cls = validator_for(schema) validator_cls.check_schema(schema) validator = validator_cls(schema) schema_widget = self.create_widget(schema, ui_schema, state) form = widgets.FormWidget(schema_widget) def validate(data): form.clear_errors() errors = [*validator.iter_errors(data)] if errors: form.display_errors(errors) for err in errors: schema_widget.handle_error(err.path, err) schema_widget.on_changed.connect(validate) return form def create_widget( self, schema: dict, ui_schema: dict, state=None, description: str = "", ) -> widgets.SchemaWidgetMixin: schema_type = get_schema_type(schema) try: default_variant = self.widget_variant_modifiers[schema_type]( schema ) except KeyError: default_variant = self.default_widget_variants[schema_type] if "enum" in schema: default_variant = "enum" widget_variant = ui_schema.get('ui:widget', default_variant) widget_cls = self.widget_map[schema_type][widget_variant] widget = widget_cls(schema, ui_schema, self) default_state = get_widget_state(schema, state) if default_state is not None: widget.state = default_state if description: widget.setDescription(description) widget.setToolTip(description) return widget napari-0.5.0a1/napari/_vendor/qt_json_builder/qt_jsonschema_form/signal.py000066400000000000000000000010651437041365600270240ustar00rootroot00000000000000class Signal: def __init__(self): self.cache = {} def __get__(self, instance, owner): if instance is None: return self try: return self.cache[instance] except KeyError: self.cache[instance] = instance = BoundSignal() return instance class BoundSignal: def __init__(self): self._subscribers = [] def emit(self, *args): for sub in self._subscribers: sub(*args) def connect(self, listener): self._subscribers.append(listener) napari-0.5.0a1/napari/_vendor/qt_json_builder/qt_jsonschema_form/utils.py000066400000000000000000000016651437041365600267150ustar00rootroot00000000000000from functools import wraps from typing import Iterator from qtpy import QtWidgets class StateProperty(property): def setter(self, fset): @wraps(fset) def _setter(*args): *head, value = args if value is not None: fset(*head, value) return super().setter(_setter) state_property = StateProperty def reject_none(func): """Only invoke function if state argument is not None""" @wraps(func) def wrapper(self, state): if state is None: return func(self, state) return wrapper def is_concrete_schema(schema: dict) -> bool: return "type" in schema def iter_layout_items(layout) -> Iterator[QtWidgets.QLayoutItem]: return (layout.itemAt(i) for i in range(layout.count())) def iter_layout_widgets( layout: QtWidgets.QLayout, ) -> Iterator[QtWidgets.QWidget]: return (i.widget() for i in iter_layout_items(layout)) napari-0.5.0a1/napari/_vendor/qt_json_builder/qt_jsonschema_form/widgets.py000066400000000000000000000552041437041365600272210ustar00rootroot00000000000000from functools import partial from typing import Dict, List, Optional, TYPE_CHECKING, Tuple from qtpy import QtCore, QtGui, QtWidgets from ...._qt.widgets.qt_extension2reader import Extension2ReaderTable from ...._qt.widgets.qt_highlight_preview import QtHighlightSizePreviewWidget from ...._qt.widgets.qt_keyboard_settings import ShortcutEditor from .signal import Signal from .utils import is_concrete_schema, iter_layout_widgets, state_property from ...._qt.widgets.qt_plugin_sorter import QtPluginSorter from ...._qt.widgets.qt_spinbox import QtSpinBox if TYPE_CHECKING: from .form import WidgetBuilder class SchemaWidgetMixin: on_changed = Signal() VALID_COLOUR = '#ffffff' INVALID_COLOUR = '#f6989d' def __init__( self, schema: dict, ui_schema: dict, widget_builder: 'WidgetBuilder', **kwargs, ): super().__init__(**kwargs) self.schema = schema self.ui_schema = ui_schema self.widget_builder = widget_builder self.on_changed.connect(lambda _: self.clear_error()) self.configure() def configure(self): pass @state_property def state(self): raise NotImplementedError(f"{self.__class__.__name__}.state") @state.setter def state(self, state): raise NotImplementedError(f"{self.__class__.__name__}.state") def handle_error(self, path: Tuple[str], err: Exception): if path: raise ValueError("Cannot handle nested error by default") self._set_valid_state(err) def clear_error(self): self._set_valid_state(None) def _set_valid_state(self, error: Exception = None): palette = self.palette() colour = QtGui.QColor() colour.setNamedColor( self.VALID_COLOUR if error is None else self.INVALID_COLOUR ) palette.setColor(self.backgroundRole(), colour) self.setPalette(palette) self.setToolTip("" if error is None else error.message) # TODO class TextSchemaWidget(SchemaWidgetMixin, QtWidgets.QLineEdit): def configure(self): self.textChanged.connect(self.on_changed.emit) self.opacity = QtWidgets.QGraphicsOpacityEffect(self) self.setGraphicsEffect(self.opacity) self.opacity.setOpacity(1) @state_property def state(self) -> str: return str(self.text()) @state.setter def state(self, state: str): self.setText(state) def setDescription(self, description: str): self.description = description class PasswordWidget(TextSchemaWidget): def configure(self): super().configure() self.setEchoMode(self.Password) def setDescription(self, description: str): self.description = description class TextAreaSchemaWidget(SchemaWidgetMixin, QtWidgets.QTextEdit): @state_property def state(self) -> str: return str(self.toPlainText()) @state.setter def state(self, state: str): self.setPlainText(state) def configure(self): self.textChanged.connect(lambda: self.on_changed.emit(self.state)) self.opacity = QtWidgets.QGraphicsOpacityEffect(self) self.setGraphicsEffect(self.opacity) self.opacity.setOpacity(1) def setDescription(self, description: str): self.description = description class CheckboxSchemaWidget(SchemaWidgetMixin, QtWidgets.QCheckBox): @state_property def state(self) -> bool: return self.isChecked() @state.setter def state(self, checked: bool): self.setChecked(checked) def configure(self): self.stateChanged.connect(lambda _: self.on_changed.emit(self.state)) self.opacity = QtWidgets.QGraphicsOpacityEffect(self) self.setGraphicsEffect(self.opacity) self.opacity.setOpacity(1) def setDescription(self, description: str): self.description = description class SpinDoubleSchemaWidget(SchemaWidgetMixin, QtWidgets.QDoubleSpinBox): @state_property def state(self) -> float: return self.value() @state.setter def state(self, state: float): self.setValue(state) def configure(self): self.valueChanged.connect(self.on_changed.emit) self.opacity = QtWidgets.QGraphicsOpacityEffect(self) self.setGraphicsEffect(self.opacity) self.opacity.setOpacity(1) def setDescription(self, description: str): self.description = description class PluginWidget(SchemaWidgetMixin, QtPluginSorter): @state_property def state(self) -> int: return self.value() @state.setter def state(self, state: int): return None # self.setValue(state) def configure(self): self.hook_list.order_changed.connect(self.on_changed.emit) def setDescription(self, description: str): self.description = description class SpinSchemaWidget(SchemaWidgetMixin, QtSpinBox): @state_property def state(self) -> int: return self.value() @state.setter def state(self, state: int): self.setValue(state) def configure(self): self.valueChanged.connect(self.on_changed.emit) self.opacity = QtWidgets.QGraphicsOpacityEffect(self) self.setGraphicsEffect(self.opacity) self.opacity.setOpacity(1) minimum = -2147483648 if "minimum" in self.schema: minimum = self.schema["minimum"] if self.schema.get("exclusiveMinimum"): minimum += 1 maximum = 2147483647 if "maximum" in self.schema: maximum = self.schema["maximum"] if self.schema.get("exclusiveMaximum"): maximum -= 1 self.setRange(minimum, maximum) if "not" in self.schema and 'const' in self.schema["not"]: self.setProhibitValue(self.schema["not"]['const']) def setDescription(self, description: str): self.description = description class IntegerRangeSchemaWidget(SchemaWidgetMixin, QtWidgets.QSlider): def __init__( self, schema: dict, ui_schema: dict, widget_builder: 'WidgetBuilder', ): super().__init__( schema, ui_schema, widget_builder, orientation=QtCore.Qt.Horizontal ) @state_property def state(self) -> int: return self.value() @state.setter def state(self, state: int): self.setValue(state) def configure(self): self.valueChanged.connect(self.on_changed.emit) self.opacity = QtWidgets.QGraphicsOpacityEffect(self) self.setGraphicsEffect(self.opacity) self.opacity.setOpacity(1) minimum = 0 if "minimum" in self.schema: minimum = self.schema["minimum"] if self.schema.get("exclusiveMinimum"): minimum += 1 maximum = 0 if "maximum" in self.schema: maximum = self.schema["maximum"] if self.schema.get("exclusiveMaximum"): maximum -= 1 if "multipleOf" in self.schema: self.setTickInterval(self.schema["multipleOf"]) self.setSingleStep(self.schema["multipleOf"]) self.setTickPosition(self.TicksBothSides) self.setRange(minimum, maximum) def setDescription(self, description: str): self.description = description class QColorButton(QtWidgets.QPushButton): """Color picker widget QPushButton subclass. Implementation derived from https://martinfitzpatrick.name/article/qcolorbutton-a-color-selector-tool-for-pyqt/ """ colorChanged = QtCore.Signal() def __init__(self, *args, **kwargs): super().__init__(*args, **kwargs) self._color = None self.pressed.connect(self.onColorPicker) self.opacity = QtWidgets.QGraphicsOpacityEffect(self) self.setGraphicsEffect(self.opacity) self.opacity.setOpacity(1) def color(self): return self._color def setColor(self, color): if color != self._color: self._color = color self.colorChanged.emit() if self._color: self.setStyleSheet("background-color: %s;" % self._color) else: self.setStyleSheet("") def onColorPicker(self): dlg = QtWidgets.QColorDialog(self) if self._color: dlg.setCurrentColor(QtGui.QColor(self._color)) if dlg.exec_(): self.setColor(dlg.currentColor().name()) def mousePressEvent(self, event): if event.button() == QtCore.Qt.RightButton: self.setColor(None) return super().mousePressEvent(event) def setDescription(self, description: str): self.description = description class ColorSchemaWidget(SchemaWidgetMixin, QColorButton): """Widget representation of a string with the 'color' format keyword.""" def configure(self): self.colorChanged.connect(lambda: self.on_changed.emit(self.state)) self.opacity = QtWidgets.QGraphicsOpacityEffect(self) self.setGraphicsEffect(self.opacity) self.opacity.setOpacity(1) @state_property def state(self) -> str: return self.color() @state.setter def state(self, data: str): self.setColor(data) def setDescription(self, description: str): self.description = description class FilepathSchemaWidget(SchemaWidgetMixin, QtWidgets.QWidget): def __init__( self, schema: dict, ui_schema: dict, widget_builder: 'WidgetBuilder', ): super().__init__(schema, ui_schema, widget_builder) self.opacity = QtWidgets.QGraphicsOpacityEffect(self) self.setGraphicsEffect(self.opacity) self.opacity.setOpacity(1) layout = QtWidgets.QHBoxLayout() self.setLayout(layout) self.path_widget = QtWidgets.QLineEdit() self.button_widget = QtWidgets.QPushButton("Browse") layout.addWidget(self.path_widget) layout.addWidget(self.button_widget) self.button_widget.clicked.connect(self._on_clicked) self.path_widget.textChanged.connect(self.on_changed.emit) def _on_clicked(self, flag): path, filter = QtWidgets.QFileDialog.getOpenFileName() self.path_widget.setText(path) @state_property def state(self) -> str: return self.path_widget.text() @state.setter def state(self, state: str): self.path_widget.setText(state) def setDescription(self, description: str): self.description = description class ArrayControlsWidget(QtWidgets.QWidget): on_delete = QtCore.Signal() on_move_up = QtCore.Signal() on_move_down = QtCore.Signal() def __init__(self): super().__init__() style = self.style() self.up_button = QtWidgets.QPushButton() self.up_button.setIcon(style.standardIcon(QtWidgets.QStyle.SP_ArrowUp)) self.up_button.clicked.connect(lambda _: self.on_move_up.emit()) self.opacity = QtWidgets.QGraphicsOpacityEffect(self) self.setGraphicsEffect(self.opacity) self.opacity.setOpacity(1) self.delete_button = QtWidgets.QPushButton() self.delete_button.setIcon( style.standardIcon(QtWidgets.QStyle.SP_DialogCancelButton) ) self.delete_button.clicked.connect(lambda _: self.on_delete.emit()) self.down_button = QtWidgets.QPushButton() self.down_button.setIcon( style.standardIcon(QtWidgets.QStyle.SP_ArrowDown) ) self.down_button.clicked.connect(lambda _: self.on_move_down.emit()) group_layout = QtWidgets.QHBoxLayout() self.setLayout(group_layout) group_layout.addWidget(self.up_button) group_layout.addWidget(self.down_button) group_layout.addWidget(self.delete_button) group_layout.setSpacing(0) group_layout.addStretch(0) def setDescription(self, description: str): self.description = description class ArrayRowWidget(QtWidgets.QWidget): def __init__( self, widget: QtWidgets.QWidget, controls: ArrayControlsWidget ): super().__init__() layout = QtWidgets.QHBoxLayout() layout.addWidget(widget) layout.addWidget(controls) self.setLayout(layout) self.opacity = QtWidgets.QGraphicsOpacityEffect(self) self.setGraphicsEffect(self.opacity) self.opacity.setOpacity(1) self.widget = widget self.controls = controls def setDescription(self, description: str): self.description = description class ArraySchemaWidget(SchemaWidgetMixin, QtWidgets.QWidget): @property def rows(self) -> List[ArrayRowWidget]: return [*iter_layout_widgets(self.array_layout)] @state_property def state(self) -> list: return [r.widget.state for r in self.rows] @state.setter def state(self, state: list): for row in self.rows: self._remove_item(row) for item in state: self._add_item(item) self.on_changed.emit(self.state) def handle_error(self, path: Tuple[str], err: Exception): index, *tail = path self.rows[index].widget.handle_error(tail, err) def configure(self): layout = QtWidgets.QVBoxLayout() style = self.style() self.opacity = QtWidgets.QGraphicsOpacityEffect(self) self.setGraphicsEffect(self.opacity) self.opacity.setOpacity(1) self.add_button = QtWidgets.QPushButton() self.add_button.setIcon( style.standardIcon(QtWidgets.QStyle.SP_FileIcon) ) self.add_button.clicked.connect(lambda _: self.add_item()) self.array_layout = QtWidgets.QVBoxLayout() array_widget = QtWidgets.QWidget(self) array_widget.setLayout(self.array_layout) self.on_changed.connect(self._on_updated) layout.addWidget(self.add_button) layout.addWidget(array_widget) self.setLayout(layout) def _on_updated(self, state): # Update add button disabled = self.next_item_schema is None self.add_button.setEnabled(not disabled) previous_row = None for i, row in enumerate(self.rows): if previous_row: can_exchange_previous = ( previous_row.widget.schema == row.widget.schema ) row.controls.up_button.setEnabled(can_exchange_previous) previous_row.controls.down_button.setEnabled( can_exchange_previous ) else: row.controls.up_button.setEnabled(False) row.controls.delete_button.setEnabled(not self.is_fixed_schema(i)) previous_row = row if previous_row: previous_row.controls.down_button.setEnabled(False) def is_fixed_schema(self, index: int) -> bool: schema = self.schema['items'] if isinstance(schema, dict): return False return index < len(schema) @property def next_item_schema(self) -> Optional[dict]: item_schema = self.schema['items'] if isinstance(item_schema, dict): return item_schema index = len(self.rows) try: item_schema = item_schema[index] except IndexError: item_schema = self.schema.get("additionalItems", {}) if isinstance(item_schema, bool): return None if not is_concrete_schema(item_schema): return None return item_schema def add_item(self, item_state=None): self._add_item(item_state) self.on_changed.emit(self.state) def remove_item(self, row: ArrayRowWidget): self._remove_item(row) self.on_changed.emit(self.state) def move_item_up(self, row: ArrayRowWidget): index = self.rows.index(row) self.array_layout.insertWidget(max(0, index - 1), row) self.on_changed.emit(self.state) def move_item_down(self, row: ArrayRowWidget): index = self.rows.index(row) self.array_layout.insertWidget(min(len(self.rows) - 1, index + 1), row) self.on_changed.emit(self.state) def _add_item(self, item_state=None): item_schema = self.next_item_schema # Create widget item_ui_schema = self.ui_schema.get("items", {}) widget = self.widget_builder.create_widget( item_schema, item_ui_schema, item_state ) controls = ArrayControlsWidget() # Create row row = ArrayRowWidget(widget, controls) self.array_layout.addWidget(row) # Setup callbacks widget.on_changed.connect(partial(self.widget_on_changed, row)) controls.on_delete.connect(partial(self.remove_item, row)) controls.on_move_up.connect(partial(self.move_item_up, row)) controls.on_move_down.connect(partial(self.move_item_down, row)) return row def _remove_item(self, row: ArrayRowWidget): self.array_layout.removeWidget(row) row.deleteLater() def widget_on_changed(self, row: ArrayRowWidget, value): self.state[self.rows.index(row)] = value self.on_changed.emit(self.state) class HighlightSizePreviewWidget( SchemaWidgetMixin, QtHighlightSizePreviewWidget ): @state_property def state(self) -> int: return self.value() def setDescription(self, description: str): self._description.setText(description) @state.setter def state(self, state: int): self.setValue(state) def configure(self): self.valueChanged.connect(self.on_changed.emit) self.opacity = QtWidgets.QGraphicsOpacityEffect(self) self.setGraphicsEffect(self.opacity) self.opacity.setOpacity(1) class ShortcutsWidget(SchemaWidgetMixin, ShortcutEditor): @state_property def state(self) -> dict: return self.value() def setDescription(self, description: str): self.description = description @state.setter def state(self, state: dict): # self.setValue(state) return None def configure(self): self.valueChanged.connect(self.on_changed.emit) self.opacity = QtWidgets.QGraphicsOpacityEffect(self) self.setGraphicsEffect(self.opacity) self.opacity.setOpacity(1) class Extension2ReaderWidget(SchemaWidgetMixin, Extension2ReaderTable): @state_property def state(self) -> dict: return self.value() def setDescription(self, description: str): self.description = description @state.setter def state(self, state: dict): # self.setValue(state) return None def configure(self): self.valueChanged.connect(self.on_changed.emit) self.opacity = QtWidgets.QGraphicsOpacityEffect(self) self.setGraphicsEffect(self.opacity) self.opacity.setOpacity(1) class ObjectSchemaWidget(SchemaWidgetMixin, QtWidgets.QGroupBox): def __init__( self, schema: dict, ui_schema: dict, widget_builder: 'WidgetBuilder', ): super().__init__(schema, ui_schema, widget_builder) self.widgets = self.populate_from_schema( schema, ui_schema, widget_builder ) @state_property def state(self) -> dict: return {k: w.state for k, w in self.widgets.items()} @state.setter def state(self, state: dict): for name, value in state.items(): self.widgets[name].state = value def handle_error(self, path: Tuple[str], err: Exception): name, *tail = path self.widgets[name].handle_error(tail, err) def widget_on_changed(self, name: str, value): self.state[name] = value self.on_changed.emit(self.state) def setDescription(self, description: str): self.description = description def populate_from_schema( self, schema: dict, ui_schema: dict, widget_builder: 'WidgetBuilder', ) -> Dict[str, QtWidgets.QWidget]: layout = QtWidgets.QFormLayout() self.setLayout(layout) layout.setAlignment(QtCore.Qt.AlignTop) self.setFlat(False) if 'title' in schema: self.setTitle(schema['title']) # Populate rows widgets = {} layout.setFieldGrowthPolicy(QtWidgets.QFormLayout.FieldGrowthPolicy(1)) for name, sub_schema in schema['properties'].items(): if 'description' in sub_schema: description = sub_schema['description'] else: description = "" sub_ui_schema = ui_schema.get(name, {}) widget = widget_builder.create_widget( sub_schema, sub_ui_schema, description=description ) # TODO onchanged widget._name = name widget.on_changed.connect(partial(self.widget_on_changed, name)) label = sub_schema.get("title", name) layout.addRow(label, widget) widgets[name] = widget return widgets class EnumSchemaWidget(SchemaWidgetMixin, QtWidgets.QComboBox): @state_property def state(self): return self.itemData(self.currentIndex()) @state.setter def state(self, value): index = self.findData(value) if index == -1: raise ValueError(value) self.setCurrentIndex(index) def configure(self): options = self.schema["enum"] for i, opt in enumerate(options): self.addItem(str(opt)) self.setItemData(i, opt) self.currentIndexChanged.connect( lambda _: self.on_changed.emit(self.state) ) self.opacity = QtWidgets.QGraphicsOpacityEffect(self) self.setGraphicsEffect(self.opacity) self.opacity.setOpacity(1) def _index_changed(self, index: int): self.on_changed.emit(self.state) def setDescription(self, description: str): self.description = description class FormWidget(QtWidgets.QWidget): def __init__(self, widget: SchemaWidgetMixin): super().__init__() layout = QtWidgets.QVBoxLayout() self.setLayout(layout) self.error_widget = QtWidgets.QGroupBox() self.error_widget.setTitle("Errors") self.error_layout = QtWidgets.QVBoxLayout() self.error_widget.setLayout(self.error_layout) self.error_widget.hide() layout.addWidget(self.error_widget) layout.addWidget(widget) self.widget = widget def display_errors(self, errors: List[Exception]): self.error_widget.show() layout = self.error_widget.layout() while True: item = layout.takeAt(0) if not item: break item.widget().deleteLater() for err in errors: widget = QtWidgets.QLabel( f".{'.'.join(err.path)} {err.message}" ) layout.addWidget(widget) def clear_errors(self): self.error_widget.hide() napari-0.5.0a1/napari/_vispy/000077500000000000000000000000001437041365600160045ustar00rootroot00000000000000napari-0.5.0a1/napari/_vispy/__init__.py000066400000000000000000000020701437041365600201140ustar00rootroot00000000000000import logging from qtpy import API_NAME from vispy import app # set vispy application to the appropriate qt backend app.use_app(API_NAME) del app # set vispy logger to show warning and errors only vispy_logger = logging.getLogger('vispy') vispy_logger.setLevel(logging.WARNING) from napari._vispy.camera import VispyCamera from napari._vispy.canvas import VispyCanvas from napari._vispy.overlays.axes import VispyAxesOverlay from napari._vispy.overlays.interaction_box import ( VispySelectionBoxOverlay, VispyTransformBoxOverlay, ) from napari._vispy.overlays.scale_bar import VispyScaleBarOverlay from napari._vispy.overlays.text import VispyTextOverlay from napari._vispy.utils.quaternion import quaternion2euler from napari._vispy.utils.visual import create_vispy_layer, create_vispy_overlay __all__ = [ "VispyCamera", "VispyCanvas", "VispyAxesOverlay", "VispySelectionBoxOverlay", "VispyScaleBarOverlay", "VispyTransformBoxOverlay", "VispyTextOverlay", "quaternion2euler", "create_vispy_layer", "create_vispy_overlay", ] napari-0.5.0a1/napari/_vispy/_tests/000077500000000000000000000000001437041365600173055ustar00rootroot00000000000000napari-0.5.0a1/napari/_vispy/_tests/test_image_rendering.py000066400000000000000000000047231437041365600240430ustar00rootroot00000000000000import numpy as np import pytest from napari._tests.utils import skip_on_win_ci from napari._vispy.layers.image import VispyImageLayer from napari.layers.image import Image def test_image_rendering(make_napari_viewer): """Test 3D image with different rendering.""" viewer = make_napari_viewer() viewer.dims.ndisplay = 3 data = np.random.random((20, 20, 20)) layer = viewer.add_image(data) assert layer.rendering == 'mip' # Change the interpolation property with pytest.deprecated_call(): layer.interpolation = 'linear' assert layer.interpolation2d == 'nearest' with pytest.deprecated_call(): assert layer.interpolation == 'linear' assert layer.interpolation3d == 'linear' # Change rendering property layer.rendering = 'translucent' assert layer.rendering == 'translucent' # Change rendering property layer.rendering = 'attenuated_mip' assert layer.rendering == 'attenuated_mip' layer.attenuation = 0.15 assert layer.attenuation == 0.15 # Change rendering property layer.rendering = 'iso' assert layer.rendering == 'iso' layer.iso_threshold = 0.3 assert layer.iso_threshold == 0.3 # Change rendering property layer.rendering = 'additive' assert layer.rendering == 'additive' @skip_on_win_ci def test_visibility_consistency(qapp, make_napari_viewer): """Make sure toggling visibility maintains image contrast. see #1622 for details. """ viewer = make_napari_viewer(show=True) layer = viewer.add_image( np.random.random((200, 200)), contrast_limits=[0, 10] ) qapp.processEvents() layer.contrast_limits = (0, 2) screen1 = viewer.screenshot(flash=False).astype('float') layer.visible = True screen2 = viewer.screenshot(flash=False).astype('float') assert np.max(np.abs(screen2 - screen1)) < 5 def test_clipping_planes_dims(): """ Ensure that dims are correctly set on clipping planes (vispy uses xyz, napary zyx) """ clipping_planes = { 'position': (1, 2, 3), 'normal': (1, 2, 3), } image_layer = Image( np.zeros((2, 2, 2)), experimental_clipping_planes=clipping_planes ) vispy_layer = VispyImageLayer(image_layer) napari_clip = image_layer.experimental_clipping_planes.as_array() # needed to get volume node image_layer._slice_dims(ndisplay=3) vispy_clip = vispy_layer.node.clipping_planes assert np.all(napari_clip == vispy_clip[..., ::-1]) napari-0.5.0a1/napari/_vispy/_tests/test_utils.py000066400000000000000000000043511437041365600220610ustar00rootroot00000000000000import numpy as np import pytest from vispy.util.quaternion import Quaternion from napari._vispy.utils.quaternion import quaternion2euler from napari._vispy.utils.visual import get_view_direction_in_scene_coordinates # Euler angles to be tested, in degrees angles = [[12, 53, 92], [180, -90, 0], [16, 90, 0]] # Prepare for input and add corresponding values in radians angles_param = [(x, True) for x in angles] angles_param.extend([(x, False) for x in np.radians(angles)]) @pytest.mark.parametrize('angles,degrees', angles_param) def test_quaternion2euler(angles, degrees): """Test quaternion to euler angle conversion.""" # Test for degrees q = Quaternion.create_from_euler_angles(*angles, degrees) ea = quaternion2euler(q, degrees=degrees) q_p = Quaternion.create_from_euler_angles(*ea, degrees=degrees) # We now compare the corresponding quaternions ; they should be equals or opposites (as they're already unit ones) q_values = np.array([q.w, q.x, q.y, q.z]) q_p_values = np.array([q_p.w, q_p.x, q_p.y, q_p.z]) nn_zero_ind = np.argmax((q_values != 0) & (q_p_values != 0)) q_values *= np.sign(q_values[nn_zero_ind]) q_p_values *= np.sign(q_p_values[nn_zero_ind]) np.testing.assert_allclose(q_values, q_p_values) def test_get_view_direction_in_scene_coordinates(make_napari_viewer): viewer = make_napari_viewer() # reset view sets the camera angles to (0, 0, 90) viewer.dims.ndim = 3 viewer.dims.ndisplay = 3 # get the viewbox view_box = viewer.window._qt_viewer.view # get the view direction view_dir = get_view_direction_in_scene_coordinates( view_box, viewer.dims.ndim, viewer.dims.displayed ) np.testing.assert_allclose(view_dir, [1, 0, 0], atol=1e-8) def test_get_view_direction_in_scene_coordinates_2d(make_napari_viewer): """view_direction should be None in 2D""" viewer = make_napari_viewer() # reset view sets the camera angles to (0, 0, 90) viewer.dims.ndim = 3 viewer.dims.ndisplay = 2 # get the viewbox view_box = viewer.window._qt_viewer.view # get the view direction view_dir = get_view_direction_in_scene_coordinates( view_box, viewer.dims.ndim, viewer.dims.displayed ) assert view_dir is None napari-0.5.0a1/napari/_vispy/_tests/test_vispy_big_images.py000066400000000000000000000037571437041365600242520ustar00rootroot00000000000000import numpy as np import pytest def test_big_2D_image(make_napari_viewer): """Test big 2D image with axis exceeding max texture size.""" viewer = make_napari_viewer() shape = (20_000, 10) data = np.random.random(shape) layer = viewer.add_image(data, multiscale=False) visual = viewer.window._qt_viewer.layer_to_visual[layer] assert visual.node is not None if visual.MAX_TEXTURE_SIZE_2D is not None: s = np.ceil(np.divide(shape, visual.MAX_TEXTURE_SIZE_2D)).astype(int) assert np.all(layer._transforms['tile2data'].scale == s) def test_big_3D_image(make_napari_viewer): """Test big 3D image with axis exceeding max texture size.""" viewer = make_napari_viewer(ndisplay=3) shape = (5, 10, 3_000) data = np.random.random(shape) layer = viewer.add_image(data, multiscale=False) visual = viewer.window._qt_viewer.layer_to_visual[layer] assert visual.node is not None if visual.MAX_TEXTURE_SIZE_3D is not None: s = np.ceil(np.divide(shape, visual.MAX_TEXTURE_SIZE_3D)).astype(int) assert np.all(layer._transforms['tile2data'].scale == s) @pytest.mark.parametrize( "shape", [(2, 4), (256, 4048), (4, 20_000), (20_000, 4)], ) def test_downsample_value(make_napari_viewer, shape): """Test getting correct value for downsampled data.""" viewer = make_napari_viewer() data = np.zeros(shape) data[shape[0] // 2 :, shape[1] // 2 :] = 1 layer = viewer.add_image(data, multiscale=False) test_points = [ (int(shape[0] * 0.25), int(shape[1] * 0.25)), (int(shape[0] * 0.75), int(shape[1] * 0.25)), (int(shape[0] * 0.25), int(shape[1] * 0.75)), (int(shape[0] * 0.75), int(shape[1] * 0.75)), ] expected_values = [0.0, 0.0, 0.0, 1.0] for test_point, expected_value in zip(test_points, expected_values): viewer.cursor.position = test_point assert ( layer.get_value(viewer.cursor.position, world=True) == expected_value ) napari-0.5.0a1/napari/_vispy/_tests/test_vispy_calls.py000066400000000000000000000102771437041365600232550ustar00rootroot00000000000000from unittest.mock import patch import numpy as np def test_data_change_ndisplay_image(make_napari_viewer): """Test change data calls for image layer with ndisplay change.""" viewer = make_napari_viewer() np.random.seed(0) data = np.random.random((10, 15, 8)) layer = viewer.add_image(data) visual = viewer.window._qt_viewer.layer_to_visual[layer] @patch.object(visual, '_on_data_change', wraps=visual._on_data_change) def test_ndisplay_change(mocked_method, ndisplay=3): viewer.dims.ndisplay = ndisplay mocked_method.assert_called_once() # Switch to 3D rendering mode and back to 2D rendering mode test_ndisplay_change(ndisplay=3) test_ndisplay_change(ndisplay=2) def test_data_change_ndisplay_labels(make_napari_viewer): """Test change data calls for labels layer with ndisplay change.""" viewer = make_napari_viewer() np.random.seed(0) data = np.random.randint(20, size=(10, 15, 8)) layer = viewer.add_labels(data) visual = viewer.window._qt_viewer.layer_to_visual[layer] @patch.object(visual, '_on_data_change', wraps=visual._on_data_change) def test_ndisplay_change(mocked_method, ndisplay=3): viewer.dims.ndisplay = ndisplay mocked_method.assert_called_once() # Switch to 3D rendering mode and back to 2D rendering mode test_ndisplay_change(ndisplay=3) test_ndisplay_change(ndisplay=2) def test_data_change_ndisplay_points(make_napari_viewer): """Test change data calls for points layer with ndisplay change.""" viewer = make_napari_viewer() np.random.seed(0) data = 20 * np.random.random((10, 3)) layer = viewer.add_points(data) visual = viewer.window._qt_viewer.layer_to_visual[layer] @patch.object(visual, '_on_data_change', wraps=visual._on_data_change) def test_ndisplay_change(mocked_method, ndisplay=3): viewer.dims.ndisplay = ndisplay mocked_method.assert_called_once() # Switch to 3D rendering mode and back to 2D rendering mode test_ndisplay_change(ndisplay=3) test_ndisplay_change(ndisplay=2) def test_data_change_ndisplay_vectors(make_napari_viewer): """Test change data calls for vectors layer with ndisplay change.""" viewer = make_napari_viewer() np.random.seed(0) data = 20 * np.random.random((10, 2, 3)) layer = viewer.add_vectors(data) visual = viewer.window._qt_viewer.layer_to_visual[layer] @patch.object(visual, '_on_data_change', wraps=visual._on_data_change) def test_ndisplay_change(mocked_method, ndisplay=3): viewer.dims.ndisplay = ndisplay mocked_method.assert_called_once() # Switch to 3D rendering mode and back to 2D rendering mode test_ndisplay_change(ndisplay=3) test_ndisplay_change(ndisplay=2) def test_data_change_ndisplay_shapes(make_napari_viewer): """Test change data calls for shapes layer with ndisplay change.""" viewer = make_napari_viewer() np.random.seed(0) data = 20 * np.random.random((10, 4, 3)) layer = viewer.add_shapes(data) visual = viewer.window._qt_viewer.layer_to_visual[layer] @patch.object(visual, '_on_data_change', wraps=visual._on_data_change) def test_ndisplay_change(mocked_method, ndisplay=3): viewer.dims.ndisplay = ndisplay mocked_method.assert_called_once() # Switch to 3D rendering mode and back to 2D rendering mode test_ndisplay_change(ndisplay=3) test_ndisplay_change(ndisplay=2) def test_data_change_ndisplay_surface(make_napari_viewer): """Test change data calls for surface layer with ndisplay change.""" viewer = make_napari_viewer() np.random.seed(0) vertices = np.random.random((10, 3)) faces = np.random.randint(10, size=(6, 3)) values = np.random.random(10) data = (vertices, faces, values) layer = viewer.add_surface(data) visual = viewer.window._qt_viewer.layer_to_visual[layer] @patch.object(visual, '_on_data_change', wraps=visual._on_data_change) def test_ndisplay_change(mocked_method, ndisplay=3): viewer.dims.ndisplay = ndisplay mocked_method.assert_called_once() # Switch to 3D rendering mode and back to 2D rendering mode test_ndisplay_change(ndisplay=3) test_ndisplay_change(ndisplay=2) napari-0.5.0a1/napari/_vispy/_tests/test_vispy_camera.py000066400000000000000000000122331437041365600234010ustar00rootroot00000000000000import numpy as np def test_camera(make_napari_viewer): """Test vispy camera creation in 2D.""" viewer = make_napari_viewer() vispy_camera = viewer.window._qt_viewer.camera np.random.seed(0) data = np.random.random((11, 11, 11)) viewer.add_image(data) # Test default values camera values are used and vispy camera has been # updated assert viewer.dims.ndisplay == 2 np.testing.assert_almost_equal(viewer.camera.angles, (0, 0, 90)) np.testing.assert_almost_equal(viewer.camera.center, (0, 5.0, 5.0)) np.testing.assert_almost_equal(viewer.camera.angles, vispy_camera.angles) np.testing.assert_almost_equal(viewer.camera.center, vispy_camera.center) np.testing.assert_almost_equal(viewer.camera.zoom, vispy_camera.zoom) def test_vispy_camera_update_from_model(make_napari_viewer): """Test vispy camera update from model in 2D.""" viewer = make_napari_viewer() vispy_camera = viewer.window._qt_viewer.camera np.random.seed(0) data = np.random.random((11, 11, 11)) viewer.add_image(data) # Test default values camera values are used and vispy camera has been # updated assert viewer.dims.ndisplay == 2 # Update camera center and zoom viewer.camera.center = (11, 12) viewer.camera.zoom = 4 np.testing.assert_almost_equal(viewer.camera.angles, (0, 0, 90)) np.testing.assert_almost_equal(viewer.camera.center, (0, 11, 12)) np.testing.assert_almost_equal(viewer.camera.zoom, 4) np.testing.assert_almost_equal(viewer.camera.angles, vispy_camera.angles) np.testing.assert_almost_equal(viewer.camera.center, vispy_camera.center) np.testing.assert_almost_equal(viewer.camera.zoom, vispy_camera.zoom) def test_camera_model_update_from_vispy(make_napari_viewer): """Test camera model updates from vispy in 2D.""" viewer = make_napari_viewer() vispy_camera = viewer.window._qt_viewer.camera np.random.seed(0) data = np.random.random((11, 11, 11)) viewer.add_image(data) # Test default values camera values are used and vispy camera has been # updated assert viewer.dims.ndisplay == 2 # Update vispy camera center and zoom vispy_camera.center = (11, 12) vispy_camera.zoom = 4 vispy_camera.on_draw(None) np.testing.assert_almost_equal(viewer.camera.angles, (0, 0, 90)) np.testing.assert_almost_equal(viewer.camera.center, (0, 11, 12)) np.testing.assert_almost_equal(viewer.camera.zoom, 4) np.testing.assert_almost_equal(viewer.camera.angles, vispy_camera.angles) np.testing.assert_almost_equal(viewer.camera.center, vispy_camera.center) np.testing.assert_almost_equal(viewer.camera.zoom, vispy_camera.zoom) def test_3D_camera(make_napari_viewer): """Test vispy camera creation in 3D.""" viewer = make_napari_viewer() vispy_camera = viewer.window._qt_viewer.camera np.random.seed(0) data = np.random.random((11, 11, 11)) viewer.add_image(data) viewer.dims.ndisplay = 3 # Test camera values have updated np.testing.assert_almost_equal(viewer.camera.angles, (0, 0, 90)) np.testing.assert_almost_equal(viewer.camera.center, (5.0, 5.0, 5.0)) np.testing.assert_almost_equal(viewer.camera.angles, vispy_camera.angles) np.testing.assert_almost_equal(viewer.camera.center, vispy_camera.center) np.testing.assert_almost_equal(viewer.camera.zoom, vispy_camera.zoom) def test_vispy_camera_update_from_model_3D(make_napari_viewer): """Test vispy camera update from model in 3D.""" viewer = make_napari_viewer() vispy_camera = viewer.window._qt_viewer.camera np.random.seed(0) data = np.random.random((11, 11, 11)) viewer.add_image(data) viewer.dims.ndisplay = 3 # Update camera angles, center, and zoom viewer.camera.angles = (24, 12, -19) viewer.camera.center = (11, 12, 15) viewer.camera.zoom = 4 np.testing.assert_almost_equal(viewer.camera.angles, (24, 12, -19)) np.testing.assert_almost_equal(viewer.camera.center, (11, 12, 15)) np.testing.assert_almost_equal(viewer.camera.zoom, 4) np.testing.assert_almost_equal(viewer.camera.angles, vispy_camera.angles) np.testing.assert_almost_equal(viewer.camera.center, vispy_camera.center) np.testing.assert_almost_equal(viewer.camera.zoom, vispy_camera.zoom) def test_camera_model_update_from_vispy_3D(make_napari_viewer): """Test camera model updates from vispy in 3D.""" viewer = make_napari_viewer() vispy_camera = viewer.window._qt_viewer.camera np.random.seed(0) data = np.random.random((11, 11, 11)) viewer.add_image(data) viewer.dims.ndisplay = 3 # Update vispy camera angles, center, and zoom viewer.camera.angles = (24, 12, -19) vispy_camera.center = (11, 12, 15) vispy_camera.zoom = 4 vispy_camera.on_draw(None) np.testing.assert_almost_equal(viewer.camera.angles, (24, 12, -19)) np.testing.assert_almost_equal(viewer.camera.center, (11, 12, 15)) np.testing.assert_almost_equal(viewer.camera.zoom, 4) np.testing.assert_almost_equal(viewer.camera.angles, vispy_camera.angles) np.testing.assert_almost_equal(viewer.camera.center, vispy_camera.center) np.testing.assert_almost_equal(viewer.camera.zoom, vispy_camera.zoom) napari-0.5.0a1/napari/_vispy/_tests/test_vispy_image_layer.py000066400000000000000000000066221437041365600244340ustar00rootroot00000000000000from itertools import permutations from typing import Union import numpy as np import pytest from vispy.visuals import ImageVisual, VolumeVisual from vispy.visuals.transforms.linear import STTransform from napari._vispy.layers.image import VispyImageLayer from napari.layers import Image def _node_scene_size(node: Union[ImageVisual, VolumeVisual]) -> np.ndarray: """Calculates the size of a vispy image/volume node in 3D space. The size is the shape of the node's data multiplied by the node's transform scale factors. Returns ------- np.ndarray The size of the node as a 3-vector of the form (x, y, z). """ data = node._last_data if isinstance(node, VolumeVisual) else node._data # Only use scale to ignore translate offset used to center top-left pixel. transform = STTransform(scale=np.diag(node.transform.matrix)) # Vispy uses an xy-style ordering, whereas numpy uses a rc-style # ordering, so reverse the shape before applying the transform. size = transform.map(data.shape[::-1]) # The last element should always be one, so ignore it. return size[:3] @pytest.mark.parametrize('order', permutations((0, 1, 2))) def test_3d_slice_of_2d_image_with_order(order): """See https://github.com/napari/napari/issues/4926 We define a non-isotropic shape and scale that combined properly with any order should make a small square when displayed in 3D. """ image = Image(np.zeros((4, 2)), scale=(1, 2)) vispy_image = VispyImageLayer(image) image._slice_dims(point=(0, 0, 0), ndisplay=3, order=order) scene_size = _node_scene_size(vispy_image.node) np.testing.assert_array_equal((4, 4, 1), scene_size) @pytest.mark.parametrize('order', permutations((0, 1, 2))) def test_2d_slice_of_3d_image_with_order(order): """See https://github.com/napari/napari/issues/4926 We define a non-isotropic shape and scale that combined properly with any order should make a small square when displayed in 2D. """ image = Image(np.zeros((8, 4, 2)), scale=(1, 2, 4)) vispy_image = VispyImageLayer(image) image._slice_dims(point=(0, 0, 0), ndisplay=2, order=order) scene_size = _node_scene_size(vispy_image.node) np.testing.assert_array_equal((8, 8, 0), scene_size) @pytest.mark.parametrize('order', permutations((0, 1, 2))) def test_3d_slice_of_3d_image_with_order(order): """See https://github.com/napari/napari/issues/4926 We define a non-isotropic shape and scale that combined properly with any order should make a small cube when displayed in 3D. """ image = Image(np.zeros((8, 4, 2)), scale=(1, 2, 4)) vispy_image = VispyImageLayer(image) image._slice_dims(point=(0, 0, 0), ndisplay=3, order=order) scene_size = _node_scene_size(vispy_image.node) np.testing.assert_array_equal((8, 8, 8), scene_size) @pytest.mark.parametrize('order', permutations((0, 1, 2, 3))) def test_3d_slice_of_4d_image_with_order(order): """See https://github.com/napari/napari/issues/4926 We define a non-isotropic shape and scale that combined properly with any order should make a small cube when displayed in 3D. """ image = Image(np.zeros((16, 8, 4, 2)), scale=(1, 2, 4, 8)) vispy_image = VispyImageLayer(image) image._slice_dims(point=(0, 0, 0, 0), ndisplay=3, order=order) scene_size = _node_scene_size(vispy_image.node) np.testing.assert_array_equal((16, 16, 16), scene_size) napari-0.5.0a1/napari/_vispy/_tests/test_vispy_multiscale.py000066400000000000000000000212201437041365600243070ustar00rootroot00000000000000import numpy as np from napari._tests.utils import skip_local_popups, skip_on_win_ci def test_multiscale(make_napari_viewer): """Test rendering of multiscale data.""" viewer = make_napari_viewer() shapes = [(4000, 3000), (2000, 1500), (1000, 750), (500, 375)] np.random.seed(0) data = [np.random.random(s) for s in shapes] _ = viewer.add_image(data, multiscale=True, contrast_limits=[0, 1]) layer = viewer.layers[0] # Set canvas size to target amount viewer.window._qt_viewer.view.canvas.size = (800, 600) viewer.window._qt_viewer.on_draw(None) # Check that current level is first large enough to fill the canvas with # a greater than one pixel depth assert layer.data_level == 2 # Check that full field of view is currently requested assert np.all(layer.corner_pixels[0] <= [0, 0]) assert np.all(layer.corner_pixels[1] >= np.subtract(shapes[2], 1)) # Test value at top left corner of image viewer.cursor.position = (0, 0) value = layer.get_value(viewer.cursor.position, world=True) np.testing.assert_allclose(value, (2, data[2][(0, 0)])) # Test value at bottom right corner of image viewer.cursor.position = (3995, 2995) value = layer.get_value(viewer.cursor.position, world=True) np.testing.assert_allclose(value, (2, data[2][(999, 749)])) # Test value outside image viewer.cursor.position = (4000, 3000) value = layer.get_value(viewer.cursor.position, world=True) assert value[1] is None def test_3D_multiscale_image(make_napari_viewer): """Test rendering of 3D multiscale image uses lowest resolution.""" viewer = make_napari_viewer() data = [np.random.random((128,) * 3), np.random.random((64,) * 3)] viewer.add_image(data) # Check that this doesn't crash. viewer.dims.ndisplay = 3 # Check lowest resolution is used assert viewer.layers[0].data_level == 1 # Note that draw command must be explicitly triggered in our tests viewer.window._qt_viewer.on_draw(None) @skip_on_win_ci @skip_local_popups def test_multiscale_screenshot(make_napari_viewer): """Test rendering of multiscale data with screenshot.""" viewer = make_napari_viewer(show=True) shapes = [(4000, 3000), (2000, 1500), (1000, 750), (500, 375)] data = [np.ones(s) for s in shapes] _ = viewer.add_image(data, multiscale=True, contrast_limits=[0, 1]) # Set canvas size to target amount viewer.window._qt_viewer.view.canvas.size = (800, 600) screenshot = viewer.screenshot(canvas_only=True, flash=False) center_coord = np.round(np.array(screenshot.shape[:2]) / 2).astype(int) target_center = np.array([255, 255, 255, 255], dtype='uint8') target_edge = np.array([0, 0, 0, 255], dtype='uint8') screen_offset = 3 # Offset is needed as our screenshots have black borders np.testing.assert_allclose(screenshot[tuple(center_coord)], target_center) np.testing.assert_allclose( screenshot[screen_offset, screen_offset], target_edge ) np.testing.assert_allclose( screenshot[-screen_offset, -screen_offset], target_edge ) @skip_on_win_ci @skip_local_popups def test_multiscale_screenshot_zoomed(make_napari_viewer): """Test rendering of multiscale data with screenshot after zoom.""" viewer = make_napari_viewer(show=True) view = viewer.window._qt_viewer shapes = [(4000, 3000), (2000, 1500), (1000, 750), (500, 375)] data = [np.ones(s) for s in shapes] _ = viewer.add_image(data, multiscale=True, contrast_limits=[0, 1]) # Set canvas size to target amount view.view.canvas.size = (800, 600) # Set zoom of camera to show highest resolution tile view.view.camera.rect = [1000, 1000, 200, 150] viewer.window._qt_viewer.on_draw(None) # Check that current level is bottom level of multiscale assert viewer.layers[0].data_level == 0 screenshot = viewer.screenshot(canvas_only=True, flash=False) center_coord = np.round(np.array(screenshot.shape[:2]) / 2).astype(int) target_center = np.array([255, 255, 255, 255], dtype='uint8') screen_offset = 3 # Offset is needed as our screenshots have black borders # for whatever reason this is the only test where the border is 6px on hi DPI. # if the 6 by 6 corner is all black assume we have a 6px border. if not np.allclose(screenshot[:6, :6], np.array([0, 0, 0, 255])): screen_offset = 6 # Hi DPI np.testing.assert_allclose(screenshot[tuple(center_coord)], target_center) np.testing.assert_allclose( screenshot[screen_offset, screen_offset], target_center ) np.testing.assert_allclose( screenshot[-screen_offset, -screen_offset], target_center ) @skip_on_win_ci @skip_local_popups def test_image_screenshot_zoomed(make_napari_viewer): """Test rendering of image data with screenshot after zoom.""" viewer = make_napari_viewer(show=True) view = viewer.window._qt_viewer data = np.ones((4000, 3000)) _ = viewer.add_image(data, multiscale=False, contrast_limits=[0, 1]) # Set canvas size to target amount view.view.canvas.size = (800, 600) # Set zoom of camera to show highest resolution tile view.view.camera.rect = [1000, 1000, 200, 150] viewer.window._qt_viewer.on_draw(None) screenshot = viewer.screenshot(canvas_only=True, flash=False) center_coord = np.round(np.array(screenshot.shape[:2]) / 2).astype(int) target_center = np.array([255, 255, 255, 255], dtype='uint8') screen_offset = 3 # Offset is needed as our screenshots have black borders np.testing.assert_allclose(screenshot[tuple(center_coord)], target_center) np.testing.assert_allclose( screenshot[screen_offset, screen_offset], target_center ) np.testing.assert_allclose( screenshot[-screen_offset, -screen_offset], target_center ) @skip_on_win_ci @skip_local_popups def test_multiscale_zoomed_out(make_napari_viewer): """See https://github.com/napari/napari/issues/4781""" # Need to show viewer to ensure that pixel_scale and physical_size # get set appropriately. viewer = make_napari_viewer(show=True) shapes = [(3200, 3200), (1600, 1600), (800, 800)] data = [np.zeros(s, dtype=np.uint8) for s in shapes] layer = viewer.add_image(data, multiscale=True) qt_viewer = viewer.window._qt_viewer # Canvas size is in screen pixels. qt_viewer.canvas.size = (1600, 1600) # The camera rect is (left, top, width, height) in scene coordinates. # In this case scene coordinates are the same as data/world coordinates # the layer is 2D and data-to-world is identity. # We pick a camera rect size that is much bigger than the data extent # to simulate being zoomed out in the viewer. camera_rect_size = 34000 camera_rect_center = 1599.5 camera_rect_start = camera_rect_center - (camera_rect_size / 2) qt_viewer.view.camera.rect = ( camera_rect_start, camera_rect_start, camera_rect_size, camera_rect_size, ) qt_viewer.on_draw(None) assert layer.data_level == 2 @skip_on_win_ci @skip_local_popups def test_5D_multiscale(make_napari_viewer): """Test 5D multiscale data.""" # Show must be true to trigger multiscale draw and corner estimation viewer = make_napari_viewer(show=True) view = viewer.window._qt_viewer view.set_welcome_visible(False) shapes = [(1, 2, 5, 20, 20), (1, 2, 5, 10, 10), (1, 2, 5, 5, 5)] np.random.seed(0) data = [np.random.random(s) for s in shapes] layer = viewer.add_image(data, multiscale=True) assert layer.data == data assert layer.multiscale is True assert layer.ndim == len(shapes[0]) @skip_on_win_ci @skip_local_popups def test_multiscale_flipped_axes(make_napari_viewer): """Check rendering of multiscale images with negative scale values. See https://github.com/napari/napari/issues/3057 """ viewer = make_napari_viewer(show=True) shapes = [(4000, 3000), (2000, 1500), (1000, 750), (500, 375)] data = [np.ones(s) for s in shapes] # this used to crash, see issue #3057 _ = viewer.add_image( data, multiscale=True, contrast_limits=[0, 1], scale=(-1, 1) ) @skip_on_win_ci @skip_local_popups def test_multiscale_rotated_image(make_napari_viewer): viewer = make_napari_viewer(show=True) sizes = [4000 // i for i in range(1, 5)] arrays = [np.zeros((size, size), dtype=np.uint8) for size in sizes] for arr in arrays: arr[:10, :10] = 255 arr[-10:, -10:] = 255 viewer.add_image(arrays, multiscale=True, rotate=44) screenshot_rgba = viewer.screenshot(canvas_only=True, flash=False) screenshot_rgb = screenshot_rgba[..., :3] assert np.any( screenshot_rgb ) # make sure there is at least one white pixel napari-0.5.0a1/napari/_vispy/_tests/test_vispy_points_layer.py000066400000000000000000000104101437041365600246540ustar00rootroot00000000000000import numpy as np import pytest from napari._vispy.layers.points import VispyPointsLayer from napari.layers import Points @pytest.mark.parametrize("opacity", [0, 0.3, 0.7, 1]) def test_VispyPointsLayer(opacity): points = np.array([[100, 100], [200, 200], [300, 100]]) layer = Points(points, size=30, opacity=opacity) visual = VispyPointsLayer(layer) assert visual.node.opacity == opacity def test_remove_selected_with_derived_text(): """See https://github.com/napari/napari/issues/3504""" points = np.random.rand(3, 2) properties = {'class': np.array(['A', 'B', 'C'])} layer = Points(points, text='class', properties=properties) vispy_layer = VispyPointsLayer(layer) text_node = vispy_layer._get_text_node() np.testing.assert_array_equal(text_node.text, ['A', 'B', 'C']) layer.selected_data = {1} layer.remove_selected() np.testing.assert_array_equal(text_node.text, ['A', 'C']) def test_change_text_updates_node_string(): points = np.random.rand(3, 2) properties = { 'class': np.array(['A', 'B', 'C']), 'name': np.array(['D', 'E', 'F']), } layer = Points(points, text='class', properties=properties) vispy_layer = VispyPointsLayer(layer) text_node = vispy_layer._get_text_node() np.testing.assert_array_equal(text_node.text, properties['class']) layer.text = 'name' np.testing.assert_array_equal(text_node.text, properties['name']) def test_change_text_color_updates_node_color(): points = np.random.rand(3, 2) properties = {'class': np.array(['A', 'B', 'C'])} text = {'string': 'class', 'color': [1, 0, 0]} layer = Points(points, text=text, properties=properties) vispy_layer = VispyPointsLayer(layer) text_node = vispy_layer._get_text_node() np.testing.assert_array_equal(text_node.color.rgb, [[1, 0, 0]]) layer.text.color = [0, 0, 1] np.testing.assert_array_equal(text_node.color.rgb, [[0, 0, 1]]) def test_change_properties_updates_node_strings(): points = np.random.rand(3, 2) properties = {'class': np.array(['A', 'B', 'C'])} layer = Points(points, properties=properties, text='class') vispy_layer = VispyPointsLayer(layer) text_node = vispy_layer._get_text_node() np.testing.assert_array_equal(text_node.text, ['A', 'B', 'C']) layer.properties = {'class': np.array(['D', 'E', 'F'])} np.testing.assert_array_equal(text_node.text, ['D', 'E', 'F']) def test_update_property_value_then_refresh_text_updates_node_strings(): points = np.random.rand(3, 2) properties = {'class': np.array(['A', 'B', 'C'])} layer = Points(points, properties=properties, text='class') vispy_layer = VispyPointsLayer(layer) text_node = vispy_layer._get_text_node() np.testing.assert_array_equal(text_node.text, ['A', 'B', 'C']) layer.properties['class'][1] = 'D' layer.refresh_text() np.testing.assert_array_equal(text_node.text, ['A', 'D', 'C']) def test_change_canvas_size_limits(): points = np.random.rand(3, 2) layer = Points(points, canvas_size_limits=(0, 10000)) vispy_layer = VispyPointsLayer(layer) node = vispy_layer.node assert node.canvas_size_limits == (0, 10000) layer.canvas_size_limits = (20, 80) assert node.canvas_size_limits == (20, 80) def test_text_with_non_empty_constant_string(): points = np.random.rand(3, 2) layer = Points(points, text={'string': {'constant': 'a'}}) vispy_layer = VispyPointsLayer(layer) text_node = vispy_layer._get_text_node() # Vispy cannot broadcast a constant string and assert_array_equal # automatically broadcasts, so explicitly check length. assert len(text_node.text) == 3 np.testing.assert_array_equal(text_node.text, ['a', 'a', 'a']) # Ensure we do position calculation for constants. # See https://github.com/napari/napari/issues/5378 # We want row, column coordinates so drop 3rd dimension and flip. actual_position = text_node.pos[:, 1::-1] np.testing.assert_allclose(actual_position, points) def test_change_antialiasing(): """Changing antialiasing on the layer should change it on the vispy node.""" points = np.random.rand(3, 2) layer = Points(points) vispy_layer = VispyPointsLayer(layer) layer.antialiasing = 5 assert vispy_layer.node.antialias == layer.antialiasing napari-0.5.0a1/napari/_vispy/_tests/test_vispy_scale_bar_visual.py000066400000000000000000000004501437041365600254450ustar00rootroot00000000000000from napari._vispy.overlays.scale_bar import VispyScaleBarOverlay from napari.components.overlays import ScaleBarOverlay def test_scale_bar_instantiation(make_napari_viewer): viewer = make_napari_viewer() model = ScaleBarOverlay() VispyScaleBarOverlay(overlay=model, viewer=viewer) napari-0.5.0a1/napari/_vispy/_tests/test_vispy_shapes_layer.py000066400000000000000000000070531437041365600246340ustar00rootroot00000000000000import numpy as np from napari._vispy.layers.shapes import VispyShapesLayer from napari.layers import Shapes def test_remove_selected_with_derived_text(): """See https://github.com/napari/napari/issues/3504""" np.random.seed(0) shapes = np.random.rand(3, 4, 2) properties = {'class': np.array(['A', 'B', 'C'])} layer = Shapes(shapes, properties=properties, text='class') vispy_layer = VispyShapesLayer(layer) text_node = vispy_layer._get_text_node() np.testing.assert_array_equal(text_node.text, ['A', 'B', 'C']) layer.selected_data = {1} layer.remove_selected() np.testing.assert_array_equal(text_node.text, ['A', 'C']) def test_change_text_updates_node_string(): np.random.seed(0) shapes = np.random.rand(3, 4, 2) properties = { 'class': np.array(['A', 'B', 'C']), 'name': np.array(['D', 'E', 'F']), } layer = Shapes(shapes, properties=properties, text='class') vispy_layer = VispyShapesLayer(layer) text_node = vispy_layer._get_text_node() np.testing.assert_array_equal(text_node.text, properties['class']) layer.text = 'name' np.testing.assert_array_equal(text_node.text, properties['name']) def test_change_text_color_updates_node_color(): np.random.seed(0) shapes = np.random.rand(3, 4, 2) properties = {'class': np.array(['A', 'B', 'C'])} text = {'string': 'class', 'color': [1, 0, 0]} layer = Shapes(shapes, properties=properties, text=text) vispy_layer = VispyShapesLayer(layer) text_node = vispy_layer._get_text_node() np.testing.assert_array_equal(text_node.color.rgb, [[1, 0, 0]]) layer.text.color = [0, 0, 1] np.testing.assert_array_equal(text_node.color.rgb, [[0, 0, 1]]) def test_change_properties_updates_node_strings(): np.random.seed(0) shapes = np.random.rand(3, 4, 2) properties = {'class': np.array(['A', 'B', 'C'])} layer = Shapes(shapes, properties=properties, text='class') vispy_layer = VispyShapesLayer(layer) text_node = vispy_layer._get_text_node() np.testing.assert_array_equal(text_node.text, ['A', 'B', 'C']) layer.properties = {'class': np.array(['D', 'E', 'F'])} np.testing.assert_array_equal(text_node.text, ['D', 'E', 'F']) def test_update_property_value_then_refresh_text_updates_node_strings(): np.random.seed(0) shapes = np.random.rand(3, 4, 2) properties = {'class': np.array(['A', 'B', 'C'])} layer = Shapes(shapes, properties=properties, text='class') vispy_layer = VispyShapesLayer(layer) text_node = vispy_layer._get_text_node() np.testing.assert_array_equal(text_node.text, ['A', 'B', 'C']) layer.properties['class'][1] = 'D' layer.refresh_text() np.testing.assert_array_equal(text_node.text, ['A', 'D', 'C']) def test_text_with_non_empty_constant_string(): np.random.seed(0) shapes = np.random.rand(3, 4, 2) layer = Shapes(shapes, text={'string': {'constant': 'a'}}) vispy_layer = VispyShapesLayer(layer) text_node = vispy_layer._get_text_node() # Vispy cannot broadcast a constant string and assert_array_equal # automatically broadcasts, so explicitly check length. assert len(text_node.text) == 3 np.testing.assert_array_equal(text_node.text, ['a', 'a', 'a']) # Ensure we do position calculation for constants. # See https://github.com/napari/napari/issues/5378 expected_position = np.mean(shapes, axis=1) # We want row, column coordinates so drop 3rd dimension and flip. actual_position = text_node.pos[:, 1::-1] np.testing.assert_allclose(actual_position, expected_position) napari-0.5.0a1/napari/_vispy/_tests/test_vispy_text_visual.py000066400000000000000000000004161437041365600245200ustar00rootroot00000000000000from napari._vispy.overlays.text import VispyTextOverlay from napari.components.overlays import TextOverlay def test_text_instantiation(make_napari_viewer): viewer = make_napari_viewer() model = TextOverlay() VispyTextOverlay(overlay=model, viewer=viewer) napari-0.5.0a1/napari/_vispy/_tests/test_vispy_tracks_layer.py000066400000000000000000000013261437041365600246350ustar00rootroot00000000000000from napari._vispy.layers.tracks import VispyTracksLayer from napari.layers import Tracks def test_tracks_graph_cleanup(): """ Test if graph data can be cleaned up without any issue. There was problems with the shader buffer once, see issue #4155. """ tracks_data = [ [1, 0, 236, 0], [1, 1, 236, 100], [1, 2, 236, 200], [2, 3, 436, 500], [2, 4, 436, 1000], [3, 3, 636, 500], [3, 4, 636, 1000], ] graph = {1: [], 2: [1], 3: [1]} layer = Tracks(tracks_data, graph=graph) visual = VispyTracksLayer(layer) layer.graph = {} assert visual.node._subvisuals[2]._pos is None assert visual.node._subvisuals[2]._connect is None napari-0.5.0a1/napari/_vispy/_tests/test_vispy_vectors_layer.py000066400000000000000000000025461437041365600250400ustar00rootroot00000000000000import numpy as np import pytest from napari._vispy.layers.vectors import ( generate_vector_meshes, generate_vector_meshes_2D, ) @pytest.mark.parametrize( "edge_width, length, dims", [[0, 0, 2], [0.3, 0.3, 2], [1, 1, 3]] ) def test_generate_vector_meshes(edge_width, length, dims): n = 10 data = np.random.random((n, 2, dims)) vertices, faces = generate_vector_meshes( data, width=edge_width, length=length ) vertices_length, vertices_dims = vertices.shape faces_length, faces_dims = faces.shape if dims == 2: assert vertices_length == 4 * n assert faces_length == 2 * n elif dims == 3: assert vertices_length == 8 * n assert faces_length == 4 * n assert vertices_dims == dims assert faces_dims == 3 @pytest.mark.parametrize( "edge_width, length, p", [[0, 0, (1, 0, 0)], [0.3, 0.3, (0, 1, 0)], [1, 1, (0, 0, 1)]], ) def test_generate_vector_meshes_2D(edge_width, length, p): n = 10 dims = 2 data = np.random.random((n, 2, dims)) vertices, faces = generate_vector_meshes_2D( data, width=edge_width, length=length, p=p ) vertices_length, vertices_dims = vertices.shape faces_length, faces_dims = faces.shape assert vertices_length == 4 * n assert vertices_dims == dims assert faces_length == 2 * n assert faces_dims == 3 napari-0.5.0a1/napari/_vispy/camera.py000066400000000000000000000141021437041365600176040ustar00rootroot00000000000000import numpy as np from vispy.scene import ArcballCamera, PanZoomCamera from napari._vispy.utils.quaternion import quaternion2euler class VispyCamera: """Vipsy camera for both 2D and 3D rendering. Parameters ---------- view : vispy.scene.widgets.viewbox.ViewBox Viewbox for current scene. camera : napari.components.Camera napari camera model. dims : napari.components.Dims napari dims model. """ def __init__(self, view, camera, dims) -> None: self._view = view self._camera = camera self._dims = dims # Create 2D camera self._2D_camera = PanZoomCamera(aspect=1) # flip y-axis to have correct alignment self._2D_camera.flip = (0, 1, 0) self._2D_camera.viewbox_key_event = viewbox_key_event # Create 3D camera self._3D_camera = ArcballCamera(fov=0) self._3D_camera.viewbox_key_event = viewbox_key_event self._dims.events.ndisplay.connect( self._on_ndisplay_change, position='first' ) self._camera.events.center.connect(self._on_center_change) self._camera.events.zoom.connect(self._on_zoom_change) self._camera.events.angles.connect(self._on_angles_change) self._camera.events.perspective.connect(self._on_perspective_change) self._on_ndisplay_change() @property def angles(self): """3-tuple: Euler angles of camera in 3D viewing, in degrees. Note that angles might be different than the ones that might have generated the quaternion. """ if self._view.camera == self._3D_camera: # Do conversion from quaternion representation to euler angles angles = quaternion2euler( self._view.camera._quaternion, degrees=True ) else: angles = (0, 0, 90) return angles @angles.setter def angles(self, angles): if self.angles == tuple(angles): return # Only update angles if current camera is 3D camera if self._view.camera == self._3D_camera: # Create and set quaternion quat = self._view.camera._quaternion.create_from_euler_angles( *angles, degrees=True, ) self._view.camera._quaternion = quat self._view.camera.view_changed() @property def center(self): """tuple: Center point of camera view for 2D or 3D viewing.""" if self._view.camera == self._3D_camera: center = tuple(self._view.camera.center) else: # in 2D, we arbitrarily choose 0.0 as the center in z center = tuple(self._view.camera.center[:2]) + (0.0,) # switch from VisPy xyz ordering to NumPy prc ordering center = center[::-1] return center @center.setter def center(self, center): if self.center == tuple(center): return self._view.camera.center = center[::-1] self._view.camera.view_changed() @property def zoom(self): """float: Scale from canvas pixels to world pixels.""" canvas_size = np.array(self._view.canvas.size) if self._view.camera == self._3D_camera: # For fov = 0.0 normalize scale factor by canvas size to get scale factor. # Note that the scaling is stored in the `_projection` property of the # camera which is updated in vispy here # https://github.com/vispy/vispy/blob/v0.6.5/vispy/scene/cameras/perspective.py#L301-L313 scale = self._view.camera.scale_factor else: scale = np.array( [self._view.camera.rect.width, self._view.camera.rect.height] ) scale[np.isclose(scale, 0)] = 1 # fix for #2875 zoom = np.min(canvas_size / scale) return zoom @zoom.setter def zoom(self, zoom): if self.zoom == zoom: return scale = np.array(self._view.canvas.size) / zoom if self._view.camera == self._3D_camera: self._view.camera.scale_factor = np.min(scale) else: # Set view rectangle, as left, right, width, height corner = np.subtract(self._view.camera.center[:2], scale / 2) self._view.camera.rect = tuple(corner) + tuple(scale) @property def perspective(self): """Field of view of camera (only visible in 3D mode).""" return self._3D_camera.fov @perspective.setter def perspective(self, perspective): if self.perspective == perspective: return self._3D_camera.fov = perspective self._view.camera.view_changed() def _on_ndisplay_change(self): if self._dims.ndisplay == 3: self._view.camera = self._3D_camera else: self._view.camera = self._2D_camera self._on_center_change() self._on_zoom_change() self._on_angles_change() def _on_center_change(self): self.center = self._camera.center[-self._dims.ndisplay :] def _on_zoom_change(self): self.zoom = self._camera.zoom def _on_perspective_change(self): self.perspective = self._camera.perspective def _on_angles_change(self): self.angles = self._camera.angles def on_draw(self, _event): """Called whenever the canvas is drawn. Update camera model angles, center, and zoom. """ with self._camera.events.angles.blocker(self._on_angles_change): self._camera.angles = self.angles with self._camera.events.center.blocker(self._on_center_change): self._camera.center = self.center with self._camera.events.zoom.blocker(self._on_zoom_change): self._camera.zoom = self.zoom with self._camera.events.perspective.blocker( self._on_perspective_change ): self._camera.perspective = self.perspective def viewbox_key_event(event): """ViewBox key event handler. Parameters ---------- event : vispy.util.event.Event The vispy event that triggered this method. """ return napari-0.5.0a1/napari/_vispy/canvas.py000066400000000000000000000061571437041365600176420ustar00rootroot00000000000000"""VispyCanvas class. """ from weakref import WeakSet from vispy.scene import SceneCanvas, Widget from napari._vispy.utils.gl import get_max_texture_sizes from napari.utils.colormaps.standardize_color import transform_color class VispyCanvas(SceneCanvas): """SceneCanvas for our QtViewer class. Add two features to SceneCanvas. Ignore mousewheel events with modifiers, and get the max texture size in __init__(). Attributes ---------- max_texture_sizes : Tuple[int, int] The max textures sizes as a (2d, 3d) tuple. """ _instances = WeakSet() def __init__(self, *args, **kwargs) -> None: # Since the base class is frozen we must create this attribute # before calling super().__init__(). self.max_texture_sizes = None self._last_theme_color = None self._background_color_override = None super().__init__(*args, **kwargs) self._instances.add(self) # Call get_max_texture_sizes() here so that we query OpenGL right # now while we know a Canvas exists. Later calls to # get_max_texture_sizes() will return the same results because it's # using an lru_cache. self.max_texture_sizes = get_max_texture_sizes() self.events.ignore_callback_errors = False self.context.set_depth_func('lequal') @property def destroyed(self): return self._backend.destroyed @property def background_color_override(self): return self._background_color_override @background_color_override.setter def background_color_override(self, value): self._background_color_override = value self.bgcolor = value or self._last_theme_color def _on_theme_change(self, event): self._set_theme_change(event.value) def _set_theme_change(self, theme: str): from napari.utils.theme import get_theme # Note 1. store last requested theme color, in case we need to reuse it # when clearing the background_color_override, without needing to # keep track of the viewer. # Note 2. the reason for using the `as_hex` here is to avoid # `UserWarning` which is emitted when RGB values are above 1 self._last_theme_color = transform_color( get_theme(theme, False).canvas.as_hex() )[0] self.bgcolor = self._last_theme_color @property def bgcolor(self): SceneCanvas.bgcolor.fget(self) @bgcolor.setter def bgcolor(self, value): _value = self._background_color_override or value SceneCanvas.bgcolor.fset(self, _value) @property def central_widget(self): """Overrides SceneCanvas.central_widget to make border_width=0""" if self._central_widget is None: self._central_widget = Widget( size=self.size, parent=self.scene, border_width=0 ) return self._central_widget def _process_mouse_event(self, event): """Ignore mouse wheel events which have modifiers.""" if event.type == 'mouse_wheel' and len(event.modifiers) > 0: return super()._process_mouse_event(event) napari-0.5.0a1/napari/_vispy/experimental/000077500000000000000000000000001437041365600205015ustar00rootroot00000000000000napari-0.5.0a1/napari/_vispy/experimental/__init__.py000066400000000000000000000000001437041365600226000ustar00rootroot00000000000000napari-0.5.0a1/napari/_vispy/experimental/_tests/000077500000000000000000000000001437041365600220025ustar00rootroot00000000000000napari-0.5.0a1/napari/_vispy/experimental/_tests/test_vispy_tiled_image.py000066400000000000000000000221231437041365600271100ustar00rootroot00000000000000import numpy as np import pytest from napari._tests.utils import skip_local_popups, skip_on_win_ci from napari._vispy.experimental.vispy_tiled_image_layer import ( VispyTiledImageLayer, ) # Add a loading delay in ms. SHORT_LOADING_DELAY = 1 LONG_LOADING_DELAY = 250 # Test all dtypes dtypes = [ np.dtype(bool), np.dtype(np.int8), np.dtype(np.uint8), np.dtype(np.int16), np.dtype(np.uint16), np.dtype(np.int32), np.dtype(np.uint32), np.dtype(np.int64), np.dtype(np.uint64), np.dtype(np.float16), np.dtype(np.float32), np.dtype(np.float64), ] @pytest.mark.async_only @pytest.mark.skip("NAPARI_OCTREE env var cannot be dynamically set") @skip_on_win_ci @skip_local_popups @pytest.mark.parametrize('dtype', dtypes) def test_tiled_screenshot(qtbot, monkeypatch, make_napari_viewer, dtype): """Test rendering of tiled data with screenshot.""" # Enable tiled rendering monkeypatch.setenv("NAPARI_OCTREE", "1") viewer = make_napari_viewer(show=True) # Set canvas size to target amount viewer.window._qt_viewer.view.canvas.size = (800, 600) shapes = [(4000, 3000), (2000, 1500), (1000, 750), (500, 375)] data = [100 * np.ones(s, dtype) for s in shapes] layer = viewer.add_image( data, multiscale=True, contrast_limits=[0, 200], colormap='blue' ) visual = viewer.window._qt_viewer.layer_to_visual[layer] # Check visual is a tiled image visual assert isinstance(visual, VispyTiledImageLayer) # Wait until the chunks have added, ToDo change this to a qtbot.waitSignal qtbot.wait(SHORT_LOADING_DELAY) # Take the screenshot screenshot = viewer.screenshot(canvas_only=True, flash=False) center_coord = np.round(np.array(screenshot.shape[:2]) / 2).astype(np.int) target_center = np.array([0, 0, 128, 255], dtype='uint8') target_edge = np.array([0, 0, 0, 255], dtype='uint8') screen_offset = 3 # Offset is needed as our screenshots have black borders np.testing.assert_allclose(screenshot[tuple(center_coord)], target_center) np.testing.assert_allclose( screenshot[screen_offset, screen_offset], target_edge ) np.testing.assert_allclose( screenshot[-screen_offset, -screen_offset], target_edge ) @pytest.mark.async_only @pytest.mark.skip("NAPARI_OCTREE env var cannot be dynamically set") @skip_on_win_ci @skip_local_popups def test_tiled_rgb(qtbot, monkeypatch, make_napari_viewer): """Test rgb data works as expected.""" # Enable tiled rendering monkeypatch.setenv("NAPARI_OCTREE", "1") viewer = make_napari_viewer(show=True) # Set canvas size to target amount viewer.window._qt_viewer.view.canvas.size = (800, 600) shapes = [(4000, 3000, 3), (2000, 1500, 3), (1000, 750, 3), (500, 375, 3)] data = [128 * np.ones(s, np.uint8) for s in shapes] layer = viewer.add_image(data, multiscale=True, rgb=True) visual = viewer.window._qt_viewer.layer_to_visual[layer] # Check visual is a tiled image visual assert isinstance(visual, VispyTiledImageLayer) # Wait until the chunks have added, ToDo change this to a qtbot.waitSignal qtbot.wait(SHORT_LOADING_DELAY) # Take the screenshot screenshot = viewer.screenshot(canvas_only=True, flash=False) center_coord = np.round(np.array(screenshot.shape[:2]) / 2).astype(np.int) target_center = np.array([128, 128, 128, 255], dtype='uint8') target_edge = np.array([0, 0, 0, 255], dtype='uint8') screen_offset = 3 # Offset is needed as our screenshots have black borders # Center pixel should be gray np.testing.assert_allclose(screenshot[tuple(center_coord)], target_center) np.testing.assert_allclose( screenshot[screen_offset, screen_offset], target_edge ) np.testing.assert_allclose( screenshot[-screen_offset, -screen_offset], target_edge ) @pytest.mark.async_only @pytest.mark.skip("NAPARI_OCTREE env var cannot be dynamically set") @skip_on_win_ci @skip_local_popups def test_tiled_changing_contrast_limits( qtbot, monkeypatch, make_napari_viewer ): """Test changing contrast limits of tiled data.""" # Enable tiled rendering monkeypatch.setenv("NAPARI_OCTREE", "1") viewer = make_napari_viewer(show=True) # Set canvas size to target amount viewer.window._qt_viewer.view.canvas.size = (800, 600) shapes = [(4000, 3000), (2000, 1500), (1000, 750), (500, 375)] data = [np.ones(s, np.uint8) for s in shapes] layer = viewer.add_image( data, multiscale=True, contrast_limits=[0, 1000], colormap='blue' ) visual = viewer.window._qt_viewer.layer_to_visual[layer] # Check visual is a tiled image visual assert isinstance(visual, VispyTiledImageLayer) # Wait until the chunks have added, ToDo change this to a qtbot.waitSignal qtbot.wait(SHORT_LOADING_DELAY) # Take the screenshot screenshot = viewer.screenshot(canvas_only=True, flash=False) center_coord = np.round(np.array(screenshot.shape[:2]) / 2).astype(np.int) target_center = np.array([0, 0, 255, 255], dtype='uint8') target_edge = np.array([0, 0, 0, 255], dtype='uint8') screen_offset = 3 # Offset is needed as our screenshots have black borders # Center pixel should be black as contrast limits are so large np.testing.assert_allclose(screenshot[tuple(center_coord)], target_edge) np.testing.assert_allclose( screenshot[screen_offset, screen_offset], target_edge ) np.testing.assert_allclose( screenshot[-screen_offset, -screen_offset], target_edge ) # Make clim data range so center pixel now appears fully saturated layer.contrast_limits = [0, 1] # Required wait is longer, ToDo change this to a qtbot.waitSignal qtbot.wait(LONG_LOADING_DELAY) screenshot = viewer.screenshot(canvas_only=True, flash=False) np.testing.assert_allclose(screenshot[tuple(center_coord)], target_center) @pytest.mark.async_only @pytest.mark.skip("NAPARI_OCTREE env var cannot be dynamically set") @skip_on_win_ci @skip_local_popups def test_tiled_single_scale(qtbot, monkeypatch, make_napari_viewer): """Test rgb data works as expected.""" # Enable tiled rendering monkeypatch.setenv("NAPARI_OCTREE", "1") viewer = make_napari_viewer(show=True) # Set canvas size to target amount viewer.window._qt_viewer.view.canvas.size = (800, 600) # Add a single scale image. layer = viewer.add_image(np.ones((4000, 3000)), contrast_limits=[0, 2]) # zoom in so as not to load all the data viewer.camera.zoom = 0.5 visual = viewer.window._qt_viewer.layer_to_visual[layer] # Check visual is a tiled image visual assert isinstance(visual, VispyTiledImageLayer) # Wait until the chunks have added, ToDo change this to a qtbot.waitSignal # Need an extra long delay here for all tiles to load, including those at # edge, as zoomed in. qtbot.wait(10 * LONG_LOADING_DELAY) # Take the screenshot screenshot = viewer.screenshot(canvas_only=True, flash=False) center_coord = np.round(np.array(screenshot.shape[:2]) / 2).astype(np.int) target_center = np.array([128, 128, 128, 255], dtype='uint8') screen_offset = 3 # Offset is needed as our screenshots have black borders # Center pixel should be gray, as should edge as zoomed in np.testing.assert_allclose(screenshot[tuple(center_coord)], target_center) np.testing.assert_allclose( screenshot[screen_offset, screen_offset], target_center ) np.testing.assert_allclose( screenshot[-screen_offset, -screen_offset], target_center ) @pytest.mark.async_only @pytest.mark.skip("NAPARI_OCTREE env var cannot be dynamically set") @skip_on_win_ci @skip_local_popups def test_tiled_labels(qtbot, monkeypatch, make_napari_viewer): """Test labels data works as expected.""" # Enable tiled rendering monkeypatch.setenv("NAPARI_OCTREE", "1") viewer = make_napari_viewer(show=True) # Set canvas size to target amount viewer.window._qt_viewer.view.canvas.size = (800, 600) shapes = [(4000, 3000), (2000, 1500), (1000, 750), (500, 375)] data = [np.ones(s, np.uint8) for s in shapes] layer = viewer.add_labels(data, multiscale=True, opacity=1) visual = viewer.window._qt_viewer.layer_to_visual[layer] # Check visual is a tiled image visual assert isinstance(visual, VispyTiledImageLayer) # Wait until the chunks have added, ToDo change this to a qtbot.waitSignal qtbot.wait(SHORT_LOADING_DELAY) # Take the screenshot screenshot = viewer.screenshot(canvas_only=True, flash=False) center_coord = np.round(np.array(screenshot.shape[:2]) / 2).astype(np.int) col = layer.get_color(1) target_center = np.array([c * 255 for c in col], dtype='uint8') target_edge = np.array([0, 0, 0, 255], dtype='uint8') screen_offset = 3 # Offset is needed as our screenshots have black borders # Center pixel should be gray np.testing.assert_allclose( screenshot[tuple(center_coord)], target_center, atol=1 ) np.testing.assert_allclose( screenshot[screen_offset, screen_offset], target_edge ) np.testing.assert_allclose( screenshot[-screen_offset, -screen_offset], target_edge ) napari-0.5.0a1/napari/_vispy/experimental/texture_atlas.py000066400000000000000000000272401437041365600237440ustar00rootroot00000000000000"""TextureAtlas2D class. A texture atlas is a large texture that stores many smaller tile textures. """ from typing import Callable, NamedTuple, Optional, Tuple import numpy as np from vispy.gloo import Texture2D from napari._vispy.utils.gl import fix_data_dtype from napari.layers.image.experimental import OctreeChunk from napari.types import ArrayLike from napari.utils.translations import trans # Two triangles which cover a [0..1, 0..1] quad. _QUAD = np.array( [[0, 0], [1, 0], [1, 1], [0, 0], [1, 1], [0, 1]], dtype=np.float32, ) def _quad(size: np.ndarray, pos: np.ndarray) -> np.ndarray: """Return one quad with the given size and position. Parameters ---------- size : np.ndarray Size of the quad (X, Y). pos : np.ndarray Position of the quad (X, Y) """ quad = _QUAD.copy() # Copy and modify in place quad[:, :2] *= size quad[:, :2] += pos return quad def _chunk_verts(octree_chunk: OctreeChunk) -> np.ndarray: """Return a quad for the vertex buffer. Parameters ---------- octree_chunk : OctreeChunk Create a quad for this chunk. Returns ------- np.darray The quad vertices. """ geom = octree_chunk.geom return _quad(geom.size, geom.pos) class AtlasTile(NamedTuple): """Information about one specific tile in the atlas. AtlasTile is returned from TextureAtlas2D.add_tile() so the caller has the verts and texture coordinates to render each tile in the atlas. Attributes ---------- index : int The index of this tile in the atlas. verts : np.ndarray The vertices of this tile. tex_coords : np.ndarray The texture coordinates of this tile. """ index: int verts: np.ndarray tex_coords: np.ndarray class TileSpec(NamedTuple): """Specification for the tiles in the atlas. Parameters ---------- shape : np.darray The shape of single tile. ndim : int The number of dimension in the shape of the tile. height : int The height of a tile. width : int The width of a tile. depth : int The depth of a tile, 1 for grayscale or RGB/RGBA. """ shape: np.ndarray ndim: int height: int width: int depth: int @classmethod def from_shape(cls, shape: np.ndarray): """Create a TileSpec from just the shape. Parameters ---------- shape : np.darray Create a TileSpec based on this shape. """ ndim = len(shape) assert ndim in [2, 3] # 2D or 2D with color. height, width = shape[:2] depth = 1 if ndim == 2 else shape[2] return cls(shape, ndim, height, width, depth) def is_compatible(self, data: np.ndarray) -> bool: """Return True if the given data is compatible with our tiles. Parameters ---------- data : np.ndarray Return True if this data is compatible with our tiles. """ if self.ndim != data.ndim: return False # Different number of dimensions. if self.ndim == 3 and self.depth != data.shape[2]: return False # Different depths. if data.shape[0] > self.height or data.shape[1] > self.width: return False # Data is too big for the tile. # It's either an exact match, or it's compatible but smaller than # the full tile, which is fine. return True class TextureAtlas2D(Texture2D): """A two-dimensional texture atlas. A single large texture with "slots" for smaller texture tiles. Parameters ---------- tile_shape : tuple The (height, width) of one tile in texels. shape_in_tiles : Tuple[int, int] The (height, width) of the full texture in terms of tiles. image_converter : Callable[[ArrayLike], ArrayLike] For converting raw to displayed data. """ def __init__( self, tile_shape: tuple, shape_in_tiles: Tuple[int, int], image_converter: Callable[[ArrayLike], ArrayLike], **kwargs, ) -> None: # Each tile's shape in texels, for example (256, 256, 3). self.spec = TileSpec.from_shape(tile_shape) # The full texture's shape in tiles, for example (4, 4). self.shape_in_tiles = shape_in_tiles # The full texture's shape in texels, for example (1024, 1024). height = self.spec.height * self.shape_in_tiles[0] width = self.spec.width * self.shape_in_tiles[1] self.full_shape = np.array( [width, height, self.spec.depth], dtype=np.int32 ) # Total number of texture slots in the atlas. self.num_slots_total = shape_in_tiles[0] * shape_in_tiles[1] # Every index is free initially. self._free_indices = set(range(0, self.num_slots_total)) # Pre-compute the texture coords for every tile. Otherwise we'd be # calculating these over and over as tiles are added. These are for # full tiles only. Edge and corner tiles will need custom texture # coordinates based on their size. tile_shape = self.spec.shape # Use the shape of a full tile. self._tex_coords = [ self._calc_tex_coords(tile_index, tile_shape) for tile_index in range(self.num_slots_total) ] # Store an image converter that will convert from raw to displayed image self.image_converter = image_converter super().__init__(shape=tuple(self.full_shape), **kwargs) @property def num_slots_free(self) -> int: """The number of available texture slots. Returns ------- int The number of available texture slots. """ return len(self._free_indices) @property def num_slots_used(self) -> int: """The number of texture slots currently in use. Returns ------- int The number of texture slots currently in use. """ return self.num_slots_total - self.num_slots_free def _offset(self, tile_index: int) -> Tuple[int, int]: """Return the (row, col) offset into the full atlas texture. Parameters ---------- tile_index : int Return the offset of this tile. Returns ------- Tuple[int, int] The (row, col) offset of this tile in texels. """ width_tiles = self.shape_in_tiles[1] row = int(tile_index / width_tiles) col = tile_index % width_tiles return row * self.spec.height, col * self.spec.width def _calc_tex_coords( self, tile_index: int, tile_shape: np.ndarray, ) -> np.ndarray: """Return the texture coordinates for this tile. This is only called from __init__, so we only calculate the texture coordinates once up front. Parameters ---------- tile_index : int Return coordinates for this tile. tile_shape : np.ndarray The shape of a single tile. Returns ------- np.ndarray A (6, 2) array of texture coordinates. """ offset = self._offset(tile_index) pos = offset / self.full_shape[:2] shape = tile_shape[:2] / self.full_shape[:2] pos = pos[::-1] shape = shape[::-1] return _quad(shape, pos) def add_tile( self, octree_chunk: OctreeChunk, clim=None ) -> Optional[AtlasTile]: """Add one tile to the atlas. Parameters ---------- octree_chunk : np.ndarray The image data for this one tile. clim : tuple, optional Contrast limits to normalize by if provided. Returns ------- Optional[AtlasTile] The AtlasTile if the tile was successfully added. """ data = octree_chunk.data # Transform data from raw to displayed # Ideally this should be removed and all transforming # should happen on GPU data = self.image_converter(data) # normalize by contrast limits if provided. This normalization # will not be required after https://github.com/vispy/vispy/pull/1920/ # and at that point should be changed. if clim is not None and (data.ndim == 2 or data.shape[2] == 1): clim = np.asarray(clim, dtype=np.float32) data = data - clim[0] # not inplace so we don't modify orig data if clim[1] - clim[0] > 0: data /= clim[1] - clim[0] else: data[:] = 1 if data[0, 0] != 0 else 0 assert isinstance(data, np.ndarray) # Make sure data is of a dtype acceptable to vispy data = fix_data_dtype(data) if not self.spec.is_compatible(data): # It will be not compatible of number of dimensions or depth # are wrong. Or if the data is too big to fit in one tile. raise ValueError( trans._( "Data with shape {shape} is not compatible with this TextureAtlas2D which has tile shape {spec_shape}", deferred=True, shape=octree_chunk.data.shape, spec_shape=self.spec.shape, ) ) try: tile_index = self._free_indices.pop() except KeyError: return None # No available texture slots. # Upload the texture data for this one tile. self._set_tile_data(tile_index, data) # Return AtlasTile. The caller will need the texture coordinates to # render quads using our tiles. verts = _chunk_verts(octree_chunk) tex_coords = self._get_tex_coords(tile_index, data) return AtlasTile(tile_index, verts, tex_coords) def _get_tex_coords(self, tile_index: int, data: np.ndarray) -> np.ndarray: """Return the texture coordinates for this tile. Parameters ---------- tile_index : int The index of this tile. data : np.ndarray The image data for this tile. Returns ------- np.ndarray The texture coordinates for the tile. """ # If it's the exact size of our tiles. Return the pre-computed # texture coordinates for this tile. Fast! if self.spec.shape == data.shape: return self._tex_coords[tile_index] # Edge or corner tile, compute exact coords. return self._calc_tex_coords(tile_index, data.shape) def remove_tile(self, tile_index: int) -> None: """Remove a tile from the texture atlas. Parameters ---------- tile_index : int The index of the tile to remove. """ self._free_indices.add(tile_index) # This tile_index is now available. def _set_tile_data(self, tile_index: int, data: np.ndarray) -> None: """Upload the texture data for this one tile. Note the data might be smaller than a full tile slot. If so, we don't really care what texels are in the rest of the tile's slot. They will not be drawn because we'll use the correct texture coordinates for the tiles actual size. Parameters ---------- tile_index : int The index of the tile to upload. data The texture data for the tile. """ # The texel offset of this tile within the larger texture. offset = self._offset(tile_index) # Texture2D.set_data() will use glTexSubImage2D() under the hood to # only write into the tile's portion of the larger texture. This is # a big reason adding tiles to TextureAtlas2D is fast. self.set_data(data, offset=offset, copy=True) napari-0.5.0a1/napari/_vispy/experimental/tile_grid.py000066400000000000000000000056401437041365600230220ustar00rootroot00000000000000"""TileGrid class. A grid drawn around/between the tiles for debugging and demos. """ from __future__ import annotations from typing import TYPE_CHECKING, List import numpy as np from vispy.scene.node import Node from vispy.scene.visuals import Line if TYPE_CHECKING: from napari.layers.image.experimental import OctreeChunk # Grid lines drawn with this width and color. GRID_WIDTH = 3 GRID_COLOR = (1, 0, 0, 1) # Draw grid on top of the tiles. LINE_VISUAL_ORDER = 10 # Outline for 'segments' points, each pair is one line segment. _OUTLINE = np.array( [[0, 0], [1, 0], [1, 0], [1, 1], [1, 1], [0, 1], [0, 1], [0, 0]], dtype=np.float32, ) def _chunk_outline(chunk: OctreeChunk) -> np.ndarray: """Return the verts that outline this single chunk. The Line is should be drawn in 'segments' mode. Parameters ---------- chunk : OctreeChunk Create outline of this chunk. Returns ------- np.ndarray The verts for the outline. """ geom = chunk.geom x, y = geom.pos w, h = geom.size outline = _OUTLINE.copy() # Copy and modify in place. outline[:, :2] *= (w, h) outline[:, :2] += (x, y) return outline class TileGrid: """A grid to show the outline of all the tiles. Created for debugging and demos, but we might show for real in certain situations, like while the tiles are loading? Attributes ---------- parent : Node The parent of the grid. """ def __init__(self, parent: Node) -> None: self.parent = parent self.line = self._create_line() def _create_line(self) -> Line: """Create the Line visual for the grid. Returns ------- Line The new Line visual. """ line = Line(connect='segments', color=GRID_COLOR, width=GRID_WIDTH) line.order = LINE_VISUAL_ORDER line.parent = self.parent return line def update_grid(self, chunks: List[OctreeChunk], base_shape=None) -> None: """Update grid for this set of chunks and the whole boundary. Parameters ---------- chunks : List[ImageChunks] Add a grid that outlines these chunks. base_shape : List[int], optional Height and width of the full resolution level. """ verts = np.zeros((0, 2), dtype=np.float32) for octree_chunk in chunks: chunk_verts = _chunk_outline(octree_chunk) verts = np.vstack([verts, chunk_verts]) # Add in the base shape outline if provided if base_shape is not None: outline = _OUTLINE.copy() # Copy and modify in place. outline[:, :2] *= base_shape[::-1] verts = np.vstack([verts, outline]) self.line.set_data(verts) def clear(self) -> None: """Clear the grid so nothing is drawn.""" data = np.zeros((0, 2), dtype=np.float32) self.line.set_data(data) napari-0.5.0a1/napari/_vispy/experimental/tile_set.py000066400000000000000000000101501437041365600226600ustar00rootroot00000000000000"""TileSet class. TiledImageVisual uses this class to track the tiles it's drawing. """ from typing import Dict, List, NamedTuple, Set from napari._vispy.experimental.texture_atlas import AtlasTile from napari.layers.image.experimental import OctreeChunk class TileData(NamedTuple): """TileSet stores one TileData per tile. Attributes ---------- octree_chunk : OctreeChunk The chunk that created the tile. atlas_tile : AtlasTile The tile that was created from the chunk. """ octree_chunk: OctreeChunk atlas_tile: AtlasTile class TileSet: """The tiles we are drawing. Maintain a dict and a set for fast membership tests in both directions. Attributes ---------- _tiles : Dict[int, TileData] Maps tile_index to the the TileData we have for that tile. _chunks : Set[OctreeChunk] The chunks we have in the set, for fast membership tests. """ def __init__(self) -> None: self._tiles: Dict[int, TileData] = {} self._chunks: Set[OctreeChunk] = set() def __len__(self) -> int: """Return the number of tiles in the set. Returns ------- int The number of tiles in the set. """ return len(self._tiles) def clear(self) -> None: """Clear out all our tiles and chunks. Forget everything.""" self._tiles.clear() self._chunks.clear() def add(self, octree_chunk: OctreeChunk, atlas_tile: AtlasTile) -> None: """Add this TiledData to the set. Parameters ---------- octree_chunk : OctreeChunk The chunk we are adding to the tile set. atlas_tile : AtlasTile The atlas tile that was created for this chunks. """ tile_index = atlas_tile.index self._tiles[tile_index] = TileData(octree_chunk, atlas_tile) self._chunks.add(octree_chunk) def remove(self, tile_index: int) -> None: """Remove the TileData at this index from the set. tile_index : int Remove the TileData at this index. """ octree_chunk = self._tiles[tile_index].octree_chunk self._chunks.remove(octree_chunk) del self._tiles[tile_index] @property def chunk_set(self) -> Set[OctreeChunk]: """The set of chunks we drawing. Returns ------- Set[OctreeChunk] The set of chunks we are drawing. """ return self._chunks @property def chunks(self) -> List[OctreeChunk]: """The chunks we are tracking. Returns ------- List[OctreeChunk] The chunks we are tracking. """ return [tile_data.octree_chunk for tile_data in self._tiles.values()] @property def tile_data(self) -> List[TileData]: """The data for all tiles in the set unsorted. Returns ------- List[TileData] The data for all the tiles in the set unsorted. """ return self._tiles.values() @property def tile_data_sorted(self) -> List[TileData]: """The data for all tiles in the set, sorted back to front. Sorted so tiles from higher octree levels are first. These are the larger and coarser tiles. We draw these "in the background" while smaller higher resolution tiles are drawn in front. This sorting allows us to show the "best available" data in all locations. Returns ------- List[TileData] The data for all the tiles in the set sorted back to front. """ return sorted( self._tiles.values(), key=lambda x: x.octree_chunk.location.level_index, reverse=True, ) def contains_octree_chunk(self, octree_chunk: OctreeChunk) -> bool: """Return True if the set contains this chunk. Parameters ---------- octree_chunk : OctreeChunk Check if this chunk is in the set. Returns ------- bool True if the set contains this chunk data. """ return octree_chunk in self._chunks napari-0.5.0a1/napari/_vispy/experimental/tiled_image_visual.py000066400000000000000000000376771437041365600247250ustar00rootroot00000000000000"""TiledImageVisual class A visual that draws tiles using a texture atlas. Ultimately TiledImageVisual cannot depend on OctreeChunk. And Octree code should not depend on TiledImageVisual! So there really can be no class or named tuple that gets passed between them. Instead, we'll probably just have a function signature that takes things like the pos, size and depth of each tile as separate arguments. But for now the visual and Octree both depend on OctreeChunk. """ from typing import Callable, List, Set import numpy as np from vispy.scene.visuals import Image from vispy.visuals.shaders import Function, FunctionChain from napari._vispy.experimental.texture_atlas import TextureAtlas2D from napari._vispy.experimental.tile_set import TileSet from napari.layers.image.experimental import OctreeChunk from napari.types import ArrayLike from napari.utils.translations import trans # Shape of she whole texture in tiles. Hardcode for now. We hope to make # TiledImageVisuals support multiple texture sizes and multiple tile # sizes in the future. SHAPE_IN_TILES = (16, 16) class TiledImageVisual(Image): """An image that is drawn using one or more tiles. A regular ImageVisual is a single image drawn as a single rectangle with a single texture. A tiled TiledImageVisual also has a single texture, but that texture is a TextureAtlas2D instead of Texture2D. A texture atlas is basically a single texture that contains smaller textures within it, arranged in a grid like a quilt. In our case the smaller textures are all the same size, for example (256, 256). A (4096, 4096) texture can hold 256 different (256, 256) tiles. When the TiledImageVisual is drawn, it draws a single list of quads. Each quad's texture coordinates potentially refers to a different texture in the atlas. The quads can be located anywhere, even in 3D. TiledImageVisual does not know if it's drawing an octree or a grid, or just a scatter of tiles. A key point is while the texture tiles are all the same size, the quads can all be different sizes. For example, one quad might have a (256, 256) texture, but it's physically tiny on the screen. While the next quad is also showing a (256, 256) texture, but that quad is really big on that same screen. This ability to have different size quads comes in handy for octree rendering, where we often draw chunks from multiple levels of the octree at the same time, and those chunks are difference sizes on the screen. Notes ----- Adding or removing tiles from a TiledImageVisual is efficient. Only the bytes in the the tile(s) being updated are sent to the card. The Vispy method BaseTexture.set_data() has an "offset" argument. When setting texture data with an offset under the hood Vispy calls glTexSubImage2D(). It will only update the rectangular region within the texture that's being updated. This is critical to making TiledImageVisual efficient. In addition, uploading new tiles does not cause the shader to be rebuilt. This is another reason TiledImageVisual is faster than creating a stand-alone ImageVisuals, where each new ImageVisual results in a shader build today. If that were fixed TiledImageVisual would still be faster, but the speed gap would be smaller. Parameters ---------- tile_shape : np.ndarray The shape of one tile like (256, 256, 3). image_converter : Callable[[ArrayLike], ArrayLike] For converting raw to displayed data. """ def __init__( self, tile_shape: np.ndarray, image_converter: Callable[[ArrayLike], ArrayLike], *args, **kwargs, ) -> None: self.tile_shape = tile_shape self.image_converter = image_converter self._tiles: TileSet = TileSet() # The tiles we are drawing. self._clim = np.array([0, 1]) # TOOD_OCTREE: need to support clim # Initialize our parent ImageVisual. super().__init__(*args, **kwargs) # We must create the texture atlas *after* calling super().__init__ # because super().__init__ creates self._interpolation which we # our _create_texture_atlas references. # # The unfreeze/freeze stuff is just a vispy thing to guard against # adding attributes after construction, which often leads to bugs, # so we have to toggle it off here. Not a big deal. self.unfreeze() self._texture_atlas = self._create_texture_atlas(tile_shape) self.freeze() def _create_texture_atlas(self, tile_shape: np.ndarray) -> TextureAtlas2D: """Create texture atlas up front or if we change texture shape. Attributes ---------- tile_shape : np.ndarray The shape of our tiles such as (256, 256, 4). Returns ------- TextureAtlas2D The newly created texture atlas. """ interp = 'linear' if self._interpolation == 'linear' else 'nearest' return TextureAtlas2D( tile_shape, SHAPE_IN_TILES, interpolation=interp, image_converter=self.image_converter, ) def set_data(self, image) -> None: """Set data of the ImageVisual. VispyImageLayer._on_display_change calls this with an empty image, but we can just ignore it. When created we are "empty" by virtue of not drawing any tiles yet. """ def set_tile_shape(self, tile_shape: np.ndarray) -> None: """Set the shape of our tiles. All tiles are the same shape in terms of texels. However they might be drawn different physical sizes. For example drawing a single view into a quadtree might end up drawing some tiles 2X or 4X bigger than others. Typically you want to draw the "best available" data which might be on a different level. Parameters ---------- tile_shape : np.ndarray Our tiles shape like (256, 256, 4) """ # Clear all our previous tile information and set the new shape. self._tiles.clear() self.tile_shape = tile_shape # Create the new atlas and tell the shader about it. self._texture_atlas = self._create_texture_atlas(tile_shape) self._data_lookup_fn['texture'] = self._texture_atlas @property def size(self): # TODO_OCTREE: Who checks this? Need to compute the size... # # ImageVisual.size() does # return self._data.shape[:2][::-1] # # We don't have a self._data so what do we put here? Maybe need # a bounds for all the currently drawable tiles? # return self._texture_atlas.texture_shape[:2] # return (1024, 1024) @property def num_tiles(self) -> int: """The number tiles currently being drawn. Returns ------- int The number of tiles currently being drawn. """ return self._texture_atlas.num_slots_used @property def octree_chunks(self) -> List[OctreeChunk]: """The chunks we are currently drawing. List[OctreeChunk] The chunks we are currently drawing. """ return self._tiles.chunks def add_chunks(self, chunks: List[OctreeChunk]) -> int: """Add one or more chunks that we are not already drawing. Parameters ---------- chunks : List[OctreeChunk] Chunks that we may or may not already be drawing. Returns ------- int The number of chunks that still need to be added. """ # Get only the new chunks, the ones we are not currently drawing. new_chunks = [ octree_chunk for octree_chunk in chunks if not self._tiles.contains_octree_chunk(octree_chunk) ] # Add one or more of the new chunks. while new_chunks: self.add_one_chunk(new_chunks.pop(0)) # Add the first one. # In the future we might add several chunks here. We want # to add as many as we can without tanking the framerate # too much. But for now we just add one, because we # were seeing adding taking 40ms for one (256, 256) tile! # # But if that improves, we might want to multiple tiles here, # up to some budget limit. Although not the cost of adding # most happens later when glFlush() is called. break # Return how many chunks we did NOT add. The system should continue # to poll and draw until we return 0. return len(new_chunks) def add_one_chunk(self, octree_chunk: OctreeChunk) -> None: """Add one chunk to the tiled image. Parameters ---------- octree_chunk : OctreeChunk The chunk we are adding. Returns ------- int The newly added chunk's index. """ # Add to the texture atlas. # Note that clim data is currently provided to do a normalization. This # will not be required after https://github.com/vispy/vispy/pull/1920/ # and at that point should be changed. atlas_tile = self._texture_atlas.add_tile( octree_chunk, clim=self._clim ) if atlas_tile is None: # TODO_OCTREE: No slot was available in the atlas. That's bad, # but not sure what we should do in this case. return # Add our mapping between chunks and atlas tiles. self._tiles.add(octree_chunk, atlas_tile) # Call self._build_vertex_data() the next time we are drawn, so # can update things to draw this new chunk. self._need_vertex_update = True @property def chunk_set(self) -> Set[OctreeChunk]: """Return the set of chunks we are drawing. Returns ------- Set[OctreeChunk] The set of chunks we are drawing. """ return self._tiles.chunk_set def prune_tiles(self, drawable_set: Set[OctreeChunk]) -> None: """Remove tiles that are not part of the drawable set. drawable_set : Set[OctreeChunk] The set of currently drawable chunks. """ for tile_data in list(self._tiles.tile_data): if tile_data.octree_chunk not in drawable_set: # print(f"REMOVE: {tile_data.octree_chunk}") tile_index = tile_data.atlas_tile.index self._remove_tile(tile_index) def _remove_tile(self, tile_index: int) -> None: """Remove one tile from the tiled image. Parameters ---------- tile_index : int The tile to remove. """ try: self._tiles.remove(tile_index) self._texture_atlas.remove_tile(tile_index) # Must rebuild to remove this from what we are drawing. self._need_vertex_update = True except IndexError as exc: # Fatal error right now, but maybe in weird situation we should # ignore this error? Let's see when it happens. raise RuntimeError( trans._( "Tile index {tile_index} not found.", deferred=True, tile_index=tile_index, ) ) from exc def _build_vertex_data(self) -> None: """Build vertex and texture coordinate buffers. This overrides ImageVisual._build_vertex_data(), it is called from our _prepare_draw(). This is the heart of tiled rendering. Instead of drawing one quad with one texture, we draw one quad per tile. And for each quad we set its texture coordinates so that it will pull from the right slot in the atlas. As the card draws the tiles, the locations it samples from the texture will hop around in the atlas texture. Today we only have one atlas texture, but in the future we might have multiple atlas textures. If so, we'll want to sort the quads to minimize the number of texture swaps. Sample from different tiles in one atlas texture is fast, but switching texture is slower. """ if len(self._tiles) == 0: return # Nothing to draw. verts = np.zeros((0, 2), dtype=np.float32) tex_coords = np.zeros((0, 2), dtype=np.float32) for tile_data in self._tiles.tile_data_sorted: atlas_tile = tile_data.atlas_tile verts = np.vstack((verts, atlas_tile.verts)) tex_coords = np.vstack((tex_coords, atlas_tile.tex_coords)) # Set the base ImageVisual's _subdiv_ buffers. ImageVisual has two # modes: imposter and subdivision. So far TiledImageVisual # implicitly is always in subdivision mode. Not sure if we'd ever # support imposter, or if that even makes sense with tiles? self._subdiv_position.set_data(verts) self._subdiv_texcoord.set_data(tex_coords) self._need_vertex_update = False def _build_texture(self) -> None: """Override of ImageVisual._build_texture().""" if isinstance(self._clim, str) and self._clim == 'auto': raise ValueError( trans._( 'Auto clims not supported for tiled image visual', deferred=True, ) ) self._texture_limits = np.array(self._clim) self._need_colortransform_update = True self._need_texture_upload = False def _build_color_transform(self): # this first line should be the only difference from the same method in base Image if len(self.tile_shape) == 2 or self.tile_shape[2] == 1: # luminance data fclim = Function(self._func_templates['clim_float']) fgamma = Function(self._func_templates['gamma_float']) # NOTE: red_to_luminance only uses the red component, fancy internalformats # may need to use the other components or a different function chain fun = FunctionChain( None, [ Function(self._func_templates['red_to_luminance']), fclim, fgamma, Function(self.cmap.glsl_map), ], ) else: # RGB/A image data (no colormap) fclim = Function(self._func_templates['clim']) fgamma = Function(self._func_templates['gamma']) fun = FunctionChain( None, [ Function(self._func_templates['null_color_transform']), fclim, fgamma, ], ) fclim['clim'] = self._texture.clim_normalized fgamma['gamma'] = self.gamma return fun def _prepare_draw(self, view) -> None: """Override of ImageVisual._prepare_draw()""" if self._need_interpolation_update: # Call the base ImageVisual._build_interpolation() self._build_interpolation() # But override to use our texture atlas. self._data_lookup_fn['texture'] = self._texture_atlas # We call our own _build_texture if self._need_texture_upload: self._build_texture() # TODO_OCTREE: how does colortransform change for tiled? if self._need_colortransform_update: prg = view.view_program self.shared_program.frag[ 'color_transform' ] = self._build_color_transform() self._need_colortransform_update = False prg['texture2D_LUT'] = ( self.cmap.texture_lut() if (hasattr(self.cmap, 'texture_lut')) else None ) # We call our own _build_vertex_data() if self._need_vertex_update: self._build_vertex_data() # Call the normal ImageVisual._update_method() unchanged. if view._need_method_update: self._update_method(view) napari-0.5.0a1/napari/_vispy/experimental/vispy_tiled_image_layer.py000066400000000000000000000250751437041365600257550ustar00rootroot00000000000000"""VispyTiledImageLayer class. A tiled image layer that uses TiledImageVisual and TextureAtlas2D. """ from __future__ import annotations import logging from dataclasses import dataclass from typing import TYPE_CHECKING, List from napari._vispy.experimental.tile_grid import TileGrid from napari._vispy.experimental.tiled_image_visual import TiledImageVisual from napari._vispy.layers.image import VispyImageLayer from napari.utils.events import EmitterGroup from napari.utils.perf import block_timer if TYPE_CHECKING: from napari.layers.image.experimental import OctreeChunk from napari.layers.image.image import Image LOGGER = logging.getLogger("napari.octree.visual") @dataclass class ChunkStats: """Statistics about chunks during the update process.""" drawable: int = 0 start: int = 0 remaining: int = 0 low: int = 0 final: int = 0 @property def deleted(self) -> int: """How many chunks were deleted.""" return self.start - self.low @property def created(self) -> int: """How many chunks were created.""" return self.final - self.low class VispyTiledImageLayer(VispyImageLayer): """A tiled image drawn using a single TiledImageVisual. Tiles are rendered using TiledImageVisual which uses a TextureAtlas2D, see those classes for more details. Notes ------- An early tiled visual we had created a new ImageVisual for each tile. This led to crashes with PyQt5 due to the constant scene graph changes. Also each new ImageVisual caused a slow down, the shader build was one reason. Finally, rendering was slower because it required a texture swap for each tile. This newer VispyTiledImageLayer solves those problems. Parameters ---------- layer : Image The layer we are drawing. Attributes ---------- grid : TileGrid Optional grid outlining the tiles. """ def __init__(self, layer: Image) -> None: # All tiles are stored in a single TileImageVisual. visual = TiledImageVisual( tile_shape=layer.tile_shape, image_converter=layer._raw_to_displayed, ) # Pass our TiledImageVisual to the base class, it will become our # self.node which VispyBaseImage holds. super().__init__(layer, visual) # Create events after the base class. We have a loaded event that # QtPoll listens to. Because a chunk might be loaded when QtPoll is # totally quiet, no mouse movement, no in-progress loading. We need # to get the polling going so we can load the chunks over time. self.events = EmitterGroup(source=self, loaded=None) # An optional grid that shows tile borders. self.grid = TileGrid(self.node) # So we redraw when the layer loads new data. self.layer.events.loaded.connect(self._on_loaded) @property def num_tiles(self) -> int: """The number of tiles currently being drawn. Returns ------- int The number of tiles currently being drawn. """ return self.node.num_tiles def set_data(self, node, data) -> None: """Set our image data, not implemented. ImageVisual has a set_data() method but we don't. No one can set the data for the whole image, that's why it's a tiled image in the first place. Instead of set_data() we pull our data one chunk at a time by calling self.layer.drawable_chunks in our _update_view() method. Raises ------ NotImplementedError Always raises this. """ raise NotImplementedError() def _update_tile_shape(self) -> None: """If the tile shape was changed, update our node.""" # This might be overly dynamic, but for now if we see there's a new # tile shape we nuke our texture atlas and start over with the new # shape. tile_shape = self.layer.tile_shape if self.node.tile_shape != tile_shape: self.node.set_tile_shape(tile_shape) def _on_poll(self, event=None) -> None: """Called before we are drawn. This is called when the camera moves, or when we have chunks that need to be loaded. We update which tiles we are drawing based on which chunks are currently drawable. """ super()._on_poll() # Mark the event "handled" if we have more chunks to load. # # By saying the poll event was "handled" we're telling QtPoll to # keep polling us, even if the camera stops moving. So that we can # finish up the loads/draws with a potentially still camera. # # We'll be polled until no visuals handle the event, meaning # no visuals need polling. Then all is quiet until the camera # moves again. num_remaining = self._update_view() need_polling = num_remaining > 0 event.handled = need_polling def _update_view(self) -> int: """Update the tiled image based on what's drawable in the layer. We call self._update_draw_chunks() which asks the layer what chunks are drawable, then it potentially loads some of those chunks, if we didn't already have them. This method returns how many drawable chunks still need to be added. If we return non-zero, we expect to be polled and drawn again, even if the camera isn't moving. We expect to be polled and drawn until we can finish adding the rest of the drawable chunks. Returns ------- int The number of chunks that still need to be added. """ if not self.node.visible: return 0 self._update_tile_shape() # In case the tile shape changed! with block_timer("_update_drawn_chunks") as elapsed: stats = self._update_drawn_chunks() if stats.created > 0 or stats.deleted > 0: LOGGER.debug( "tiles: %d -> %d create: %d delete: %d time: %.3fms", stats.start, stats.final, stats.created, stats.deleted, elapsed.duration_ms, ) return stats.remaining def _update_drawn_chunks(self) -> ChunkStats: """Add or remove tiles to match the chunks which are currently drawable. 1) Ask layer for drawable chunks. Their data is in-memory ndarrays. 2) Remove tiles which are no longer drawable. 3) Create tiles for newly drawable chunks, one or more. 4) Optionally update our grid based on the now drawable chunks. Returns ------- ChunkStats Statistics about the update process. """ # Get what we are currently drawing. drawn_chunk_set = self.node.chunk_set # Get the currently drawable chunks from the layer. We pass it the # drawn_chunk_set because that might influence what chunks it # returns. For example if an ideal chunk is being drawn, there is # no reason to send any high level chunks to provide coverage. drawable_chunks: List[OctreeChunk] = self.layer.get_drawable_chunks( drawn_chunk_set ) # Record some stats about this update process. The first one, # stats.drawable, is the number of drawable chunks from the layer. stats = ChunkStats(drawable=len(drawable_chunks)) # Create the drawable set of chunks using their keys, so we can # check membership quickly. drawable_set = set(drawable_chunks) # The number of tiles we are currently drawing before the update. stats.start = self.num_tiles # Remove tiles if their chunk is no longer in the drawable set. self.node.prune_tiles(drawable_set) # The low point, after removing but before adding. stats.low = self.num_tiles # This is how many tiles in drawable_chunks still need to be added. # We don't necessarily add them all in one frame since that might # tank the framerate. stats.remaining = self._add_chunks(drawable_chunks) # This is the final number of tiles we are drawing after adding. stats.final = self.num_tiles # The grid is only for debugging and demos, yet it's quite useful # otherwise you can't really see the borders between the tiles. if self.layer.display.show_grid: # If a only a single scale octree then show the outline of the base shape too if self.layer._slice._meta.num_levels == 1: base_shape = self.layer._slice._meta.base_shape else: base_shape = None self.grid.update_grid( self.node.octree_chunks, base_shape=base_shape ) else: self.grid.clear() return stats def _add_chunks(self, drawable_chunks: List[OctreeChunk]) -> int: """Add some or all of these drawable chunks to the tiled image. Parameters ---------- drawable_chunks : List[OctreeChunk] Chunks we should add, if not already in the tiled image. Returns ------- int The number of chunks that still need to be added. """ if not self.layer.display.track_view: # Tracking the view is the normal mode, where the tiles load in as # the view moves. Not tracking the view is only used for debugging # or demos. To show what were being drawn. return 0 # Nothing more to add # Add tiles for drawable chunks that do not already have a tile. # This might not add all the chunks, because doing so might # tank the framerate. # # Even though the chunks are already in RAM, we have to do some # processing and then we have to move the data to VRAM. That time # cost might not happen here, we probably are just queueing up a # transfer that will happen when we next call glFlush() to let the # card do its business. # # Any chunks not added this frame will have a chance to be added # the next frame, if they are still on the drawable_chunks list # next frame. It's important we keep asking the layer for the # drawable chunks every frame. We don't want to queue up and add # chunks which might no longer be needed. The camera might move # every frame. return self.node.add_chunks(drawable_chunks) def _on_loaded(self) -> None: """The layer loaded new data, so update our view.""" self._update_view() self.events.loaded() napari-0.5.0a1/napari/_vispy/filters/000077500000000000000000000000001437041365600174545ustar00rootroot00000000000000napari-0.5.0a1/napari/_vispy/filters/__init__.py000066400000000000000000000000001437041365600215530ustar00rootroot00000000000000napari-0.5.0a1/napari/_vispy/filters/tracks.py000066400000000000000000000114751437041365600213250ustar00rootroot00000000000000from typing import List, Union import numpy as np from vispy.gloo import VertexBuffer from vispy.visuals.filters.base_filter import Filter class TracksFilter(Filter): """TracksFilter. Custom vertex and fragment shaders for visualizing tracks quickly with vispy. The central assumption is that the tracks are rendered as a continuous vispy Line segment, with connections and colors defined when the visual is created. The shader simply changes the visibility and/or fading of the data according to the current_time and the associate time metadata for each vertex. This is scaled according to the tail and head length. Points ahead of the current time are rendered with alpha set to zero. Parameters ---------- current_time : int, float the current time, which is typically the frame index, although this can be an arbitrary float tail_length : int, float the lower limit on length of the 'tail' head_length : int, float the upper limit on length of the 'tail' use_fade : bool this will enable/disable tail fading with time vertex_time : 1D array, list a vector describing the time associated with each vertex TODO ---- - the track is still displayed, albeit with fading, once the track has finished but is still within the 'tail_length' window. Should it disappear? """ VERT_SHADER = """ varying vec4 v_track_color; void apply_track_shading() { float alpha; if ($a_vertex_time > $current_time + $head_length) { // this is a hack to minimize the frag shader rendering ahead // of the current time point due to interpolation if ($a_vertex_time <= $current_time + 1){ alpha = -100.; } else { alpha = 0.; } } else { // fade the track into the temporal distance, scaled by the // maximum tail and head length from the gui float fade = ($head_length + $current_time - $a_vertex_time) / ($tail_length + $head_length); alpha = clamp(1.0-fade, 0.0, 1.0); } // when use_fade is disabled, the entire track is visible if ($use_fade == 0) { alpha = 1.0; } // set the vertex alpha according to the fade v_track_color.a = alpha; } """ FRAG_SHADER = """ varying vec4 v_track_color; void apply_track_shading() { // if the alpha is below the threshold, discard the fragment if( v_track_color.a <= 0.0 ) { discard; } // interpolate gl_FragColor.a = clamp(v_track_color.a * gl_FragColor.a, 0.0, 1.0); } """ def __init__( self, current_time: Union[int, float] = 0, tail_length: Union[int, float] = 30, head_length: Union[int, float] = 0, use_fade: bool = True, vertex_time: Union[List, np.ndarray] = None, ) -> None: super().__init__( vcode=self.VERT_SHADER, vpos=3, fcode=self.FRAG_SHADER, fpos=9 ) self.current_time = current_time self.tail_length = tail_length self.head_length = head_length self.use_fade = use_fade self.vertex_time = vertex_time @property def current_time(self) -> Union[int, float]: return self._current_time @current_time.setter def current_time(self, n: Union[int, float]): self._current_time = n if isinstance(n, slice): n = np.max(self._vertex_time) self.vshader['current_time'] = float(n) @property def use_fade(self) -> bool: return self._use_fade @use_fade.setter def use_fade(self, value: bool): self._use_fade = value self.vshader['use_fade'] = float(self._use_fade) @property def tail_length(self) -> Union[int, float]: return self._tail_length @tail_length.setter def tail_length(self, tail_length: Union[int, float]): self._tail_length = tail_length self.vshader['tail_length'] = float(self._tail_length) @property def head_length(self) -> Union[int, float]: return self._tail_length @head_length.setter def head_length(self, head_length: Union[int, float]): self._head_length = head_length self.vshader['head_length'] = float(self._head_length) def _attach(self, visual): super()._attach(visual) @property def vertex_time(self): return self._vertex_time @vertex_time.setter def vertex_time(self, v_time): self._vertex_time = np.array(v_time).reshape(-1, 1).astype(np.float32) self.vshader['a_vertex_time'] = VertexBuffer(self.vertex_time) napari-0.5.0a1/napari/_vispy/layers/000077500000000000000000000000001437041365600173035ustar00rootroot00000000000000napari-0.5.0a1/napari/_vispy/layers/__init__.py000066400000000000000000000000001437041365600214020ustar00rootroot00000000000000napari-0.5.0a1/napari/_vispy/layers/base.py000066400000000000000000000167471437041365600206060ustar00rootroot00000000000000from abc import ABC, abstractmethod import numpy as np from vispy.visuals.transforms import MatrixTransform from napari._vispy.utils.gl import BLENDING_MODES, get_max_texture_sizes from napari.components.overlays.base import CanvasOverlay, SceneOverlay from napari.utils.events import disconnect_events class VispyBaseLayer(ABC): """Base object for individual layer views Meant to be subclassed. Parameters ---------- layer : napari.layers.Layer Layer model. node : vispy.scene.VisualNode Central node with which to interact with the visual. Attributes ---------- layer : napari.layers.Layer Layer model. node : vispy.scene.VisualNode Central node with which to interact with the visual. scale : sequence of float Scale factors for the layer visual in the scenecanvas. translate : sequence of float Translation values for the layer visual in the scenecanvas. MAX_TEXTURE_SIZE_2D : int Max texture size allowed by the vispy canvas during 2D rendering. MAX_TEXTURE_SIZE_3D : int Max texture size allowed by the vispy canvas during 2D rendering. Notes ----- _master_transform : vispy.visuals.transforms.MatrixTransform Transform positioning the layer visual inside the scenecanvas. """ def __init__(self, layer, node) -> None: super().__init__() self.events = None # Some derived classes have events. self.layer = layer self._array_like = False self.node = node self.overlays = {} ( self.MAX_TEXTURE_SIZE_2D, self.MAX_TEXTURE_SIZE_3D, ) = get_max_texture_sizes() self.layer.events.refresh.connect(self._on_refresh_change) self.layer.events.set_data.connect(self._on_data_change) self.layer.events.visible.connect(self._on_visible_change) self.layer.events.opacity.connect(self._on_opacity_change) self.layer.events.blending.connect(self._on_blending_change) self.layer.events.scale.connect(self._on_matrix_change) self.layer.events.translate.connect(self._on_matrix_change) self.layer.events.rotate.connect(self._on_matrix_change) self.layer.events.shear.connect(self._on_matrix_change) self.layer.events.affine.connect(self._on_matrix_change) self.layer.experimental_clipping_planes.events.connect( self._on_experimental_clipping_planes_change ) self.layer.events._overlays.connect(self._on_overlays_change) @property def _master_transform(self): """vispy.visuals.transforms.MatrixTransform: Central node's firstmost transform. """ # whenever a new parent is set, the transform is reset # to a NullTransform so we reset it here if not isinstance(self.node.transform, MatrixTransform): self.node.transform = MatrixTransform() return self.node.transform @property def translate(self): """sequence of float: Translation values.""" return self._master_transform.matrix[-1, :] @property def scale(self): """sequence of float: Scale factors.""" matrix = self._master_transform.matrix[:-1, :-1] _, upper_tri = np.linalg.qr(matrix) return np.diag(upper_tri).copy() @property def order(self): """int: Order in which the visual is drawn in the scenegraph. Lower values are closer to the viewer. """ return self.node.order @order.setter def order(self, order): self.node.order = order @abstractmethod def _on_data_change(self): raise NotImplementedError() def _on_refresh_change(self): self.node.update() def _on_visible_change(self): self.node.visible = self.layer.visible def _on_opacity_change(self): self.node.opacity = self.layer.opacity def _on_blending_change(self): blending_kwargs = BLENDING_MODES[self.layer.blending] self.node.set_gl_state(**blending_kwargs) self.node.update() def _on_overlays_change(self): # avoid circular import; TODO: fix? from napari._vispy.utils.visual import create_vispy_overlay overlay_models = self.layer._overlays.values() for overlay in overlay_models: if overlay in self.overlays: continue overlay_visual = create_vispy_overlay(overlay, layer=self.layer) self.overlays[overlay] = overlay_visual if isinstance(overlay, CanvasOverlay): overlay_visual.node.parent = self.node.parent.parent # viewbox elif isinstance(overlay, SceneOverlay): overlay_visual.node.parent = self.node overlay_visual.node.parent = self.node overlay_visual.reset() for overlay in list(self.overlays): if overlay not in overlay_models: overlay_visual = self.overlays.pop(overlay) overlay_visual.close() def _on_matrix_change(self): transform = self.layer._transforms.simplified.set_slice( self.layer._slice_input.displayed ) # convert NumPy axis ordering to VisPy axis ordering # by reversing the axes order and flipping the linear # matrix translate = transform.translate[::-1] matrix = transform.linear_matrix[::-1, ::-1].T # Embed in the top left corner of a 4x4 affine matrix affine_matrix = np.eye(4) affine_matrix[: matrix.shape[0], : matrix.shape[1]] = matrix affine_matrix[-1, : len(translate)] = translate if self._array_like and self.layer._slice_input.ndisplay == 2: # Perform pixel offset to shift origin from top left corner # of pixel to center of pixel. # Note this offset is only required for array like data in # 2D. offset_matrix = self.layer._data_to_world.set_slice( self.layer._slice_input.displayed ).linear_matrix offset = -offset_matrix @ np.ones(offset_matrix.shape[1]) / 2 # Convert NumPy axis ordering to VisPy axis ordering # and embed in full affine matrix affine_offset = np.eye(4) affine_offset[-1, : len(offset)] = offset[::-1] affine_matrix = affine_matrix @ affine_offset self._master_transform.matrix = affine_matrix def _on_experimental_clipping_planes_change(self): if hasattr(self.node, 'clipping_planes') and hasattr( self.layer, 'experimental_clipping_planes' ): # invert axes because vispy uses xyz but napari zyx self.node.clipping_planes = ( self.layer.experimental_clipping_planes.as_array()[..., ::-1] ) def reset(self): self._on_visible_change() self._on_opacity_change() self._on_blending_change() self._on_matrix_change() self._on_experimental_clipping_planes_change() self._on_overlays_change() def _on_poll(self, event=None): # noqa: B027 """Called when camera moves, before we are drawn. Optionally called for some period once the camera stops, so the visual can finish up what it was doing, such as loading data into VRAM or animating itself. """ pass def close(self): """Vispy visual is closing.""" disconnect_events(self.layer.events, self) self.node.transforms = MatrixTransform() self.node.parent = None napari-0.5.0a1/napari/_vispy/layers/image.py000066400000000000000000000251241437041365600207430ustar00rootroot00000000000000import warnings import numpy as np from vispy.color import Colormap as VispyColormap from vispy.scene.node import Node from napari._vispy.layers.base import VispyBaseLayer from napari._vispy.utils.gl import fix_data_dtype, get_gl_extensions from napari._vispy.visuals.image import Image as ImageNode from napari._vispy.visuals.volume import Volume as VolumeNode from napari.layers.base._base_constants import Blending from napari.utils.translations import trans class ImageLayerNode: def __init__(self, custom_node: Node = None, texture_format=None) -> None: if ( texture_format == 'auto' and 'texture_float' not in get_gl_extensions() ): # if the GPU doesn't support float textures, texture_format auto # WILL fail on float dtypes # https://github.com/napari/napari/issues/3988 texture_format = None self._custom_node = custom_node self._image_node = ImageNode( None, method='auto', texture_format=texture_format, ) self._volume_node = VolumeNode( np.zeros((1, 1, 1), dtype=np.float32), clim=[0, 1], texture_format=texture_format, ) def get_node(self, ndisplay: int) -> Node: # Return custom node if we have one. if self._custom_node is not None: return self._custom_node # Return Image or Volume node based on 2D or 3D. if ndisplay == 2: return self._image_node return self._volume_node class VispyImageLayer(VispyBaseLayer): def __init__(self, layer, node=None, texture_format='auto') -> None: # Use custom node from caller, or our standard image/volume nodes. self._layer_node = ImageLayerNode(node, texture_format=texture_format) # Default to 2D (image) node. super().__init__(layer, self._layer_node.get_node(2)) self._array_like = True self.layer.events.rendering.connect(self._on_rendering_change) self.layer.events.depiction.connect(self._on_depiction_change) self.layer.events.interpolation2d.connect( self._on_interpolation_change ) self.layer.events.interpolation3d.connect( self._on_interpolation_change ) self.layer.events.colormap.connect(self._on_colormap_change) self.layer.events.contrast_limits.connect( self._on_contrast_limits_change ) self.layer.events.gamma.connect(self._on_gamma_change) self.layer.events.iso_threshold.connect(self._on_iso_threshold_change) self.layer.events.attenuation.connect(self._on_attenuation_change) self.layer.plane.events.position.connect( self._on_plane_position_change ) self.layer.plane.events.thickness.connect( self._on_plane_thickness_change ) self.layer.plane.events.normal.connect(self._on_plane_normal_change) # display_change is special (like data_change) because it requires a self.reset() # this means that we have to call it manually. Also, it must be called before reset # in order to set the appropriate node first self._on_display_change() self.reset() self._on_data_change() def _on_display_change(self, data=None): parent = self.node.parent self.node.parent = None ndisplay = self.layer._slice_input.ndisplay self.node = self._layer_node.get_node(ndisplay) if data is None: data = np.zeros((1,) * ndisplay, dtype=np.float32) if self.layer._empty: self.node.visible = False else: self.node.visible = self.layer.visible if self.layer.loaded: self.node.set_data(data) self.node.parent = parent self.node.order = self.order for overlay_visual in self.overlays.values(): overlay_visual.node.parent = self.node self.reset() def _on_data_change(self): if not self.layer.loaded: # Do nothing if we are not yet loaded. Calling astype below could # be very expensive. Lets not do it until our data has been loaded. return self._set_node_data(self.node, self.layer._data_view) def _set_node_data(self, node, data): """Our self.layer._data_view has been updated, update our node.""" data = fix_data_dtype(data) ndisplay = self.layer._slice_input.ndisplay if ndisplay == 3 and self.layer.ndim == 2: data = np.expand_dims(data, axis=0) # Check if data exceeds MAX_TEXTURE_SIZE and downsample if self.MAX_TEXTURE_SIZE_2D is not None and ndisplay == 2: data = self.downsample_texture(data, self.MAX_TEXTURE_SIZE_2D) elif self.MAX_TEXTURE_SIZE_3D is not None and ndisplay == 3: data = self.downsample_texture(data, self.MAX_TEXTURE_SIZE_3D) # Check if ndisplay has changed current node type needs updating if (ndisplay == 3 and not isinstance(node, VolumeNode)) or ( ndisplay == 2 and not isinstance(node, ImageNode) ): self._on_display_change(data) else: node.set_data(data) if self.layer._empty: node.visible = False else: node.visible = self.layer.visible # Call to update order of translation values with new dims: self._on_matrix_change() node.update() def _on_interpolation_change(self): self.node.interpolation = ( self.layer.interpolation2d if self.layer._slice_input.ndisplay == 2 else self.layer.interpolation3d ) def _on_rendering_change(self): if isinstance(self.node, VolumeNode): self.node.method = self.layer.rendering self._on_attenuation_change() self._on_iso_threshold_change() def _on_depiction_change(self): if isinstance(self.node, VolumeNode): self.node.raycasting_mode = str(self.layer.depiction) def _on_colormap_change(self): self.node.cmap = VispyColormap(*self.layer.colormap) def _update_mip_minip_cutoff(self): # discard fragments beyond contrast limits, but only with translucent blending if isinstance(self.node, VolumeNode): if self.layer.blending in { Blending.TRANSLUCENT, Blending.TRANSLUCENT_NO_DEPTH, }: self.node.mip_cutoff = self.node._texture.clim_normalized[0] self.node.minip_cutoff = self.node._texture.clim_normalized[1] else: self.node.mip_cutoff = None self.node.minip_cutoff = None def _on_contrast_limits_change(self): self.node.clim = self.layer.contrast_limits # cutoffs must be updated after clims, so we can set them to the new values self._update_mip_minip_cutoff() # iso also may depend on contrast limit values self._on_iso_threshold_change() def _on_blending_change(self): super()._on_blending_change() # cutoffs must be updated after blending, so we can know if # the new blending is a translucent one self._update_mip_minip_cutoff() def _on_gamma_change(self): if len(self.node.shared_program.frag._set_items) > 0: self.node.gamma = self.layer.gamma def _on_iso_threshold_change(self): if isinstance(self.node, VolumeNode): if self.node._texture.is_normalized: cmin, cmax = self.layer.contrast_limits_range self.node.threshold = (self.layer.iso_threshold - cmin) / ( cmax - cmin ) else: self.node.threshold = self.layer.iso_threshold def _on_attenuation_change(self): if isinstance(self.node, VolumeNode): self.node.attenuation = self.layer.attenuation def _on_plane_thickness_change(self): if isinstance(self.node, VolumeNode): self.node.plane_thickness = self.layer.plane.thickness def _on_plane_position_change(self): if isinstance(self.node, VolumeNode): self.node.plane_position = self.layer.plane.position def _on_plane_normal_change(self): if isinstance(self.node, VolumeNode): self.node.plane_normal = self.layer.plane.normal def reset(self, event=None): super().reset() self._on_interpolation_change() self._on_colormap_change() self._on_contrast_limits_change() self._on_gamma_change() self._on_rendering_change() self._on_depiction_change() self._on_plane_position_change() self._on_plane_normal_change() self._on_plane_thickness_change() def downsample_texture(self, data, MAX_TEXTURE_SIZE): """Downsample data based on maximum allowed texture size. Parameters ---------- data : array Data to be downsampled if needed. MAX_TEXTURE_SIZE : int Maximum allowed texture size. Returns ------- data : array Data that now fits inside texture. """ if np.any(np.greater(data.shape, MAX_TEXTURE_SIZE)): if self.layer.multiscale: raise ValueError( trans._( "Shape of in dividual tiles in multiscale {shape} cannot exceed GL_MAX_TEXTURE_SIZE {texture_size}. Rendering is currently in {ndisplay}D mode.", deferred=True, shape=data.shape, texture_size=MAX_TEXTURE_SIZE, ndisplay=self.layer._slice_input.ndisplay, ) ) warnings.warn( trans._( "data shape {shape} exceeds GL_MAX_TEXTURE_SIZE {texture_size} in at least one axis and will be downsampled. Rendering is currently in {ndisplay}D mode.", deferred=True, shape=data.shape, texture_size=MAX_TEXTURE_SIZE, ndisplay=self.layer._slice_input.ndisplay, ) ) downsample = np.ceil( np.divide(data.shape, MAX_TEXTURE_SIZE) ).astype(int) scale = np.ones(self.layer.ndim) for i, d in enumerate(self.layer._slice_input.displayed): scale[d] = downsample[i] self.layer._transforms['tile2data'].scale = scale self._on_matrix_change() slices = tuple(slice(None, None, ds) for ds in downsample) data = data[slices] return data napari-0.5.0a1/napari/_vispy/layers/labels.py000066400000000000000000000003221437041365600211140ustar00rootroot00000000000000from napari._vispy.layers.image import VispyImageLayer class VispyLabelsLayer(VispyImageLayer): def __init__(self, *args, **kwargs) -> None: super().__init__(*args, texture_format=None, **kwargs) napari-0.5.0a1/napari/_vispy/layers/points.py000066400000000000000000000153441437041365600212000ustar00rootroot00000000000000import numpy as np from napari._vispy.layers.base import VispyBaseLayer from napari._vispy.utils.gl import BLENDING_MODES from napari._vispy.utils.text import update_text from napari._vispy.visuals.points import PointsVisual from napari.settings import get_settings from napari.utils.colormaps.standardize_color import transform_color from napari.utils.events import disconnect_events class VispyPointsLayer(VispyBaseLayer): _highlight_color = (0, 0.6, 1) _highlight_width = None def __init__(self, layer) -> None: self._highlight_width = get_settings().appearance.highlight_thickness node = PointsVisual() super().__init__(layer, node) self.layer.events.symbol.connect(self._on_data_change) self.layer.events.edge_width.connect(self._on_data_change) self.layer.events.edge_width_is_relative.connect(self._on_data_change) self.layer.events.edge_color.connect(self._on_data_change) self.layer._edge.events.colors.connect(self._on_data_change) self.layer._edge.events.color_properties.connect(self._on_data_change) self.layer.events.face_color.connect(self._on_data_change) self.layer._face.events.colors.connect(self._on_data_change) self.layer._face.events.color_properties.connect(self._on_data_change) self.layer.events.highlight.connect(self._on_highlight_change) self.layer.text.events.connect(self._on_text_change) self.layer.events.shading.connect(self._on_shading_change) self.layer.events.antialiasing.connect(self._on_antialiasing_change) self.layer.events.canvas_size_limits.connect( self._on_canvas_size_limits_change ) self._on_data_change() def _on_data_change(self): # Set vispy data, noting that the order of the points needs to be # reversed to make the most recently added point appear on top # and the rows / columns need to be switched for vispy's x / y ordering if len(self.layer._indices_view) == 0: # always pass one invisible point to avoid issues data = np.zeros((1, self.layer._slice_input.ndisplay)) size = [0] edge_color = np.array([[0.0, 0.0, 0.0, 1.0]], dtype=np.float32) face_color = np.array([[1.0, 1.0, 1.0, 1.0]], dtype=np.float32) edge_width = [0] symbol = ['o'] else: data = self.layer._view_data size = self.layer._view_size edge_color = self.layer._view_edge_color face_color = self.layer._view_face_color edge_width = self.layer._view_edge_width symbol = self.layer._view_symbol set_data = self.node._subvisuals[0].set_data if self.layer.edge_width_is_relative: edge_kw = { 'edge_width': None, 'edge_width_rel': edge_width, } else: edge_kw = { 'edge_width': edge_width, 'edge_width_rel': None, } set_data( data[:, ::-1], size=size, symbol=symbol, edge_color=edge_color, face_color=face_color, **edge_kw, ) self.reset() def _on_highlight_change(self): settings = get_settings() if len(self.layer._highlight_index) > 0: # Color the hovered or selected points data = self.layer._view_data[self.layer._highlight_index] if data.ndim == 1: data = np.expand_dims(data, axis=0) size = self.layer._view_size[self.layer._highlight_index] symbol = self.layer._view_symbol[self.layer._highlight_index] else: data = np.zeros((1, self.layer._slice_input.ndisplay)) size = 0 symbol = ['o'] self.node._subvisuals[1].set_data( data[:, ::-1], size=size, symbol=symbol, edge_width=settings.appearance.highlight_thickness, edge_color=self._highlight_color, face_color=transform_color('transparent'), ) if ( self.layer._highlight_box is None or 0 in self.layer._highlight_box.shape ): pos = np.zeros((1, self.layer._slice_input.ndisplay)) width = 0 else: pos = self.layer._highlight_box width = settings.appearance.highlight_thickness self.node._subvisuals[2].set_data( pos=pos[:, ::-1], color=self._highlight_color, width=width, ) self.node.update() def _update_text(self, *, update_node=True): """Function to update the text node properties Parameters ---------- update_node : bool If true, update the node after setting the properties """ update_text(node=self._get_text_node(), layer=self.layer) if update_node: self.node.update() def _get_text_node(self): """Function to get the text node from the Compound visual""" text_node = self.node._subvisuals[-1] return text_node def _on_text_change(self, event=None): if event is not None: if event.type == 'blending': self._on_blending_change(event) return if event.type == 'values': return self._update_text() def _on_blending_change(self): """Function to set the blending mode""" points_blending_kwargs = BLENDING_MODES[self.layer.blending] self.node.set_gl_state(**points_blending_kwargs) text_node = self._get_text_node() text_blending_kwargs = BLENDING_MODES[self.layer.text.blending] text_node.set_gl_state(**text_blending_kwargs) # selection box is always without depth box_blending_kwargs = BLENDING_MODES['translucent_no_depth'] self.node._subvisuals[2].set_gl_state(**box_blending_kwargs) self.node.update() def _on_antialiasing_change(self): self.node.antialias = self.layer.antialiasing def _on_shading_change(self): shading = self.layer.shading if shading == 'spherical': self.node.spherical = True else: self.node.spherical = False def _on_canvas_size_limits_change(self): self.node.canvas_size_limits = self.layer.canvas_size_limits def reset(self): super().reset() self._update_text(update_node=False) self._on_highlight_change() self._on_antialiasing_change() self._on_shading_change() self._on_canvas_size_limits_change() def close(self): """Vispy visual is closing.""" disconnect_events(self.layer.text.events, self) super().close() napari-0.5.0a1/napari/_vispy/layers/shapes.py000066400000000000000000000120701437041365600211400ustar00rootroot00000000000000import numpy as np from napari._vispy.layers.base import VispyBaseLayer from napari._vispy.utils.gl import BLENDING_MODES from napari._vispy.utils.text import update_text from napari._vispy.visuals.shapes import ShapesVisual from napari.settings import get_settings from napari.utils.events import disconnect_events class VispyShapesLayer(VispyBaseLayer): def __init__(self, layer) -> None: node = ShapesVisual() super().__init__(layer, node) self.layer.events.edge_width.connect(self._on_data_change) self.layer.events.edge_color.connect(self._on_data_change) self.layer.events.face_color.connect(self._on_data_change) self.layer.events.highlight.connect(self._on_highlight_change) self.layer.text.events.connect(self._on_text_change) # TODO: move to overlays self.node._subvisuals[3].symbol = 'square' self.node._subvisuals[3].scaling = False self.reset() self._on_data_change() def _on_data_change(self): faces = self.layer._data_view._mesh.displayed_triangles colors = self.layer._data_view._mesh.displayed_triangles_colors vertices = self.layer._data_view._mesh.vertices # Note that the indices of the vertices need to be reversed to # go from numpy style to xyz if vertices is not None: vertices = vertices[:, ::-1] if len(vertices) == 0 or len(faces) == 0: vertices = np.zeros((3, self.layer._slice_input.ndisplay)) faces = np.array([[0, 1, 2]]) colors = np.array([[0, 0, 0, 0]]) if ( len(self.layer.data) and self.layer._slice_input.ndisplay == 3 and self.layer.ndim == 2 ): vertices = np.pad(vertices, ((0, 0), (0, 1)), mode='constant') self.node._subvisuals[0].set_data( vertices=vertices, faces=faces, face_colors=colors ) # Call to update order of translation values with new dims: self._on_matrix_change() self._update_text(update_node=False) self.node.update() def _on_highlight_change(self): settings = get_settings() self.layer._highlight_width = settings.appearance.highlight_thickness # Compute the vertices and faces of any shape outlines vertices, faces = self.layer._outline_shapes() if vertices is None or len(vertices) == 0 or len(faces) == 0: vertices = np.zeros((3, self.layer._slice_input.ndisplay)) faces = np.array([[0, 1, 2]]) self.node._subvisuals[1].set_data( vertices=vertices, faces=faces, color=self.layer._highlight_color, ) # Compute the location and properties of the vertices and box that # need to get rendered ( vertices, face_color, edge_color, pos, width, ) = self.layer._compute_vertices_and_box() width = settings.appearance.highlight_thickness if vertices is None or len(vertices) == 0: vertices = np.zeros((1, self.layer._slice_input.ndisplay)) size = 0 else: size = self.layer._vertex_size self.node._subvisuals[3].set_data( vertices, size=size, face_color=face_color, edge_color=edge_color, edge_width=width, ) if pos is None or len(pos) == 0: pos = np.zeros((1, self.layer._slice_input.ndisplay)) width = 0 self.node._subvisuals[2].set_data( pos=pos, color=edge_color, width=width ) def _update_text(self, *, update_node=True): """Function to update the text node properties Parameters ---------- update_node : bool If true, update the node after setting the properties """ update_text(node=self._get_text_node(), layer=self.layer) if update_node: self.node.update() def _get_text_node(self): """Function to get the text node from the Compound visual""" text_node = self.node._subvisuals[-1] return text_node def _on_text_change(self, event=None): if event is not None: if event.type == 'blending': self._on_blending_change(event) return if event.type == 'values': return self._update_text() def _on_blending_change(self): """Function to set the blending mode""" shapes_blending_kwargs = BLENDING_MODES[self.layer.blending] self.node.set_gl_state(**shapes_blending_kwargs) text_node = self._get_text_node() text_blending_kwargs = BLENDING_MODES[self.layer.text.blending] text_node.set_gl_state(**text_blending_kwargs) self.node.update() def reset(self): super().reset() self._on_highlight_change() self._on_blending_change() def close(self): """Vispy visual is closing.""" disconnect_events(self.layer.text.events, self) super().close() napari-0.5.0a1/napari/_vispy/layers/surface.py000066400000000000000000000141121437041365600213040ustar00rootroot00000000000000import numpy as np from vispy.color import Colormap as VispyColormap from vispy.geometry import MeshData from napari._vispy.layers.base import VispyBaseLayer from napari._vispy.visuals.surface import SurfaceVisual class VispySurfaceLayer(VispyBaseLayer): """Vispy view for the surface layer. View is based on the vispy mesh node and uses default values for lighting direction and lighting color. More information can be found here https://github.com/vispy/vispy/blob/main/vispy/visuals/mesh.py """ def __init__(self, layer) -> None: node = SurfaceVisual() self._meshdata = None super().__init__(layer, node) self.layer.events.colormap.connect(self._on_colormap_change) self.layer.events.contrast_limits.connect( self._on_contrast_limits_change ) self.layer.events.gamma.connect(self._on_gamma_change) self.layer.events.shading.connect(self._on_shading_change) self.layer.wireframe.events.visible.connect( self._on_wireframe_visible_change ) self.layer.wireframe.events.width.connect( self._on_wireframe_width_change ) self.layer.wireframe.events.color.connect( self._on_wireframe_color_change ) self.layer.normals.face.events.connect(self._on_face_normals_change) self.layer.normals.vertex.events.connect( self._on_vertex_normals_change ) self.reset() self._on_data_change() def _on_data_change(self): ndisplay = self.layer._slice_input.ndisplay if len(self.layer._data_view) == 0 or len(self.layer._view_faces) == 0: vertices = None faces = None vertex_values = np.array([0]) else: # Offsetting so pixels now centered # coerce to float to solve vispy/vispy#2007 # reverse order to get zyx instead of xyz vertices = np.asarray( self.layer._data_view[:, ::-1], dtype=np.float32 ) # due to above xyz>zyx, also reverse order of faces to fix handedness of normals faces = self.layer._view_faces[:, ::-1] vertex_values = self.layer._view_vertex_values if vertices is not None and ndisplay == 3 and self.layer.ndim == 2: vertices = np.pad(vertices, ((0, 0), (0, 1))) # manually detach filters when we go to 2D to avoid dimensionality issues # see comments in napari#3475. The filter is set again after set_data! if ndisplay == 2: filt = self.node.shading_filter try: self.node.detach(filt) self.node.shading = None self.node.shading_filter = None except ValueError: # sometimes we try to detach non-attached filters, which causes a ValueError pass self.node.set_data( vertices=vertices, faces=faces, vertex_values=vertex_values ) # disable normals in 2D to avoid shape errors if ndisplay == 2: meshdata = MeshData() else: meshdata = self.node.mesh_data self._meshdata = meshdata self._on_face_normals_change() self._on_vertex_normals_change() self._on_shading_change() self.node.update() # Call to update order of translation values with new dims: self._on_matrix_change() def _on_colormap_change(self): if self.layer.gamma != 1: # when gamma!=1, we instantiate a new colormap with 256 control # points from 0-1 colors = self.layer.colormap.map( np.linspace(0, 1, 256) ** self.layer.gamma ) cmap = VispyColormap(colors) else: cmap = VispyColormap(*self.layer.colormap) if self.layer._slice_input.ndisplay == 3: self.node.view_program['texture2D_LUT'] = ( cmap.texture_lut() if (hasattr(cmap, 'texture_lut')) else None ) self.node.cmap = cmap def _on_contrast_limits_change(self): self.node.clim = self.layer.contrast_limits def _on_gamma_change(self): self._on_colormap_change() def _on_shading_change(self): shading = None if self.layer.shading == 'none' else self.layer.shading if self.layer._slice_input.ndisplay == 3: self.node.shading = shading self.node.update() def _on_wireframe_visible_change(self): self.node.wireframe_filter.enabled = self.layer.wireframe.visible self.node.update() def _on_wireframe_width_change(self): self.node.wireframe_filter.width = self.layer.wireframe.width self.node.update() def _on_wireframe_color_change(self): self.node.wireframe_filter.color = self.layer.wireframe.color self.node.update() def _on_face_normals_change(self): self.node.face_normals.visible = self.layer.normals.face.visible if self.node.face_normals.visible: self.node.face_normals.set_data( self._meshdata, length=self.layer.normals.face.length, color=self.layer.normals.face.color, width=self.layer.normals.face.width, primitive='face', ) def _on_vertex_normals_change(self): self.node.vertex_normals.visible = self.layer.normals.vertex.visible if self.node.vertex_normals.visible: self.node.vertex_normals.set_data( self._meshdata, length=self.layer.normals.vertex.length, color=self.layer.normals.vertex.color, width=self.layer.normals.vertex.width, primitive='vertex', ) def reset(self, event=None): super().reset() self._on_colormap_change() self._on_contrast_limits_change() self._on_shading_change() self._on_wireframe_visible_change() self._on_wireframe_width_change() self._on_wireframe_color_change() self._on_face_normals_change() self._on_vertex_normals_change() napari-0.5.0a1/napari/_vispy/layers/tracks.py000066400000000000000000000115021437041365600211430ustar00rootroot00000000000000from napari._vispy.layers.base import VispyBaseLayer from napari._vispy.visuals.tracks import TracksVisual class VispyTracksLayer(VispyBaseLayer): """VispyTracksLayer Track layer for visualizing tracks. """ def __init__(self, layer) -> None: node = TracksVisual() super().__init__(layer, node) self.layer.events.tail_width.connect(self._on_appearance_change) self.layer.events.tail_length.connect(self._on_appearance_change) self.layer.events.head_length.connect(self._on_appearance_change) self.layer.events.display_id.connect(self._on_appearance_change) self.layer.events.display_tail.connect(self._on_appearance_change) self.layer.events.display_graph.connect(self._on_appearance_change) self.layer.events.color_by.connect(self._on_appearance_change) self.layer.events.colormap.connect(self._on_appearance_change) # these events are fired when changes occur to the tracks or the # graph - as the vertex buffer of the shader needs to be updated # alongside the actual vertex data self.layer.events.rebuild_tracks.connect(self._on_tracks_change) self.layer.events.rebuild_graph.connect(self._on_graph_change) self.reset() self._on_data_change() def _on_data_change(self): """Update the display.""" # update the shaders self.node.tracks_filter.current_time = self.layer.current_time self.node.graph_filter.current_time = self.layer.current_time # add text labels if they're visible if self.node._subvisuals[1].visible: labels_text, labels_pos = self.layer.track_labels self.node._subvisuals[1].text = labels_text self.node._subvisuals[1].pos = labels_pos self.node.update() # Call to update order of translation values with new dims: self._on_matrix_change() def _on_appearance_change(self): """Change the appearance of the data.""" # update shader properties related to appearance self.node.tracks_filter.use_fade = self.layer.use_fade self.node.tracks_filter.tail_length = self.layer.tail_length self.node.tracks_filter.head_length = self.layer.head_length self.node.graph_filter.use_fade = self.layer.use_fade self.node.graph_filter.tail_length = self.layer.tail_length self.node.graph_filter.head_length = self.layer.head_length # set visibility of subvisuals self.node._subvisuals[0].visible = self.layer.display_tail self.node._subvisuals[1].visible = self.layer.display_id self.node._subvisuals[2].visible = self.layer.display_graph # set the width of the track tails self.node._subvisuals[0].set_data( width=self.layer.tail_width, color=self.layer.track_colors, ) self.node._subvisuals[2].set_data( width=self.layer.tail_width, ) def _on_tracks_change(self): """Update the shader when the track data changes.""" self.node.tracks_filter.use_fade = self.layer.use_fade self.node.tracks_filter.tail_length = self.layer.tail_length self.node.tracks_filter.vertex_time = self.layer.track_times # change the data to the vispy line visual self.node._subvisuals[0].set_data( pos=self.layer._view_data, connect=self.layer.track_connex, width=self.layer.tail_width, color=self.layer.track_colors, ) # Call to update order of translation values with new dims: self._on_matrix_change() def _on_graph_change(self): """Update the shader when the graph data changes.""" # if the user clears a graph after it has been created, vispy offers # no method to clear the data, therefore, we need to set private # attributes to None to prevent errors if self.layer._view_graph is None: self.node._subvisuals[2]._pos = None self.node._subvisuals[2]._connect = None self.node.update() return # vertex time buffer must change only if data is updated, otherwise vispy buffers might be of different lengths self.node.graph_filter.use_fade = self.layer.use_fade self.node.graph_filter.tail_length = self.layer.tail_length self.node.graph_filter.vertex_time = self.layer.graph_times self.node._subvisuals[2].set_data( pos=self.layer._view_graph, connect=self.layer.graph_connex, width=self.layer.tail_width, color='white', ) # Call to update order of translation values with new dims: self._on_matrix_change() def reset(self): super().reset() self._on_appearance_change() self._on_tracks_change() self._on_graph_change() napari-0.5.0a1/napari/_vispy/layers/vectors.py000066400000000000000000000077301437041365600213510ustar00rootroot00000000000000from copy import copy import numpy as np from napari._vispy.layers.base import VispyBaseLayer from napari._vispy.visuals.vectors import VectorsVisual from napari.layers.utils.layer_utils import segment_normal class VispyVectorsLayer(VispyBaseLayer): def __init__(self, layer) -> None: node = VectorsVisual() super().__init__(layer, node) self.layer.events.edge_color.connect(self._on_data_change) self.reset() self._on_data_change() def _on_data_change(self): # Make meshes vertices, faces = generate_vector_meshes( self.layer._view_data, self.layer.edge_width, self.layer.length, ) face_color = self.layer._view_face_color ndisplay = self.layer._slice_input.ndisplay ndim = self.layer.ndim if len(vertices) == 0 or len(faces) == 0: vertices = np.zeros((3, ndisplay)) faces = np.array([[0, 1, 2]]) face_color = np.array([[0, 0, 0, 0]]) else: vertices = vertices[:, ::-1] if ndisplay == 3 and ndim == 2: vertices = np.pad(vertices, ((0, 0), (0, 1)), mode='constant') self.node.set_data( vertices=vertices, faces=faces, face_colors=face_color, ) self.node.update() # Call to update order of translation values with new dims: self._on_matrix_change() def generate_vector_meshes(vectors, width, length): """Generates list of mesh vertices and triangles from a list of vectors Parameters ---------- vectors : (N, 2, D) array A list of N vectors with start point and projections of the vector in D dimensions, where D is 2 or 3. width : float width of the line to be drawn length : float length multiplier of the line to be drawn Returns ------- vertices : (4N, 2) array for 2D and (8N, 2) array for 3D Vertices of all triangles for the lines triangles : (2N, 3) array for 2D or (4N, 3) array for 3D Vertex indices that form the mesh triangles """ ndim = vectors.shape[2] if ndim == 2: vertices, triangles = generate_vector_meshes_2D(vectors, width, length) else: v_a, t_a = generate_vector_meshes_2D( vectors, width, length, p=(0, 0, 1) ) v_b, t_b = generate_vector_meshes_2D( vectors, width, length, p=(1, 0, 0) ) vertices = np.concatenate([v_a, v_b], axis=0) triangles = np.concatenate([t_a, len(v_a) + t_b], axis=0) return vertices, triangles def generate_vector_meshes_2D(vectors, width, length, p=(0, 0, 1)): """Generates list of mesh vertices and triangles from a list of vectors Parameters ---------- vectors : (N, 2, D) array A list of N vectors with start point and projections of the vector in D dimensions, where D is 2 or 3. width : float width of the line to be drawn length : float length multiplier of the line to be drawn p : 3-tuple, optional orthogonal vector for segment calculation in 3D. Returns ------- vertices : (4N, D) array Vertices of all triangles for the lines triangles : (2N, 3) array Vertex indices that form the mesh triangles """ ndim = vectors.shape[2] vectors = np.reshape(copy(vectors), (-1, ndim)) vectors[1::2] = vectors[::2] + length * vectors[1::2] centers = np.repeat(vectors, 2, axis=0) offsets = segment_normal(vectors[::2, :], vectors[1::2, :], p=p) offsets = np.repeat(offsets, 4, axis=0) signs = np.ones((len(offsets), ndim)) signs[::2] = -1 offsets = offsets * signs vertices = centers + width * offsets / 2 triangles = np.array( [ [2 * i, 2 * i + 1, 2 * i + 2] if i % 2 == 0 else [2 * i - 1, 2 * i, 2 * i + 1] for i in range(len(vectors)) ] ).astype(np.uint32) return vertices, triangles napari-0.5.0a1/napari/_vispy/overlays/000077500000000000000000000000001437041365600176505ustar00rootroot00000000000000napari-0.5.0a1/napari/_vispy/overlays/__init__.py000066400000000000000000000000001437041365600217470ustar00rootroot00000000000000napari-0.5.0a1/napari/_vispy/overlays/axes.py000066400000000000000000000056271437041365600211740ustar00rootroot00000000000000import numpy as np from napari._vispy.overlays.base import ViewerOverlayMixin, VispySceneOverlay from napari._vispy.visuals.axes import Axes from napari.utils.theme import get_theme class VispyAxesOverlay(ViewerOverlayMixin, VispySceneOverlay): """Axes indicating world coordinate origin and orientation.""" def __init__(self, *, viewer, overlay, parent=None) -> None: self._scale = 1 # Target axes length in canvas pixels self._target_length = 80 super().__init__( node=Axes(), viewer=viewer, overlay=overlay, parent=parent ) self.overlay.events.visible.connect(self._on_visible_change) self.overlay.events.colored.connect(self._on_data_change) self.overlay.events.dashed.connect(self._on_data_change) self.overlay.events.labels.connect(self._on_labels_visible_change) self.overlay.events.arrows.connect(self._on_data_change) self.viewer.events.theme.connect(self._on_data_change) self.viewer.camera.events.zoom.connect(self._on_zoom_change) self.viewer.dims.events.order.connect(self._on_data_change) self.viewer.dims.events.range.connect(self._on_data_change) self.viewer.dims.events.ndisplay.connect(self._on_data_change) self.viewer.dims.events.axis_labels.connect( self._on_labels_text_change ) self.reset() def _on_data_change(self): # Determine which axes are displayed axes = self.viewer.dims.displayed[::-1] # Counting backwards from total number of dimensions # determine axes positions. This is done as by default # the last NumPy axis corresponds to the first Vispy axis reversed_axes = [self.viewer.dims.ndim - 1 - a for a in axes] self.node.set_data( axes=axes, reversed_axes=reversed_axes, colored=self.overlay.colored, bg_color=get_theme(self.viewer.theme, False).canvas, dashed=self.overlay.dashed, arrows=self.overlay.arrows, ) def _on_labels_visible_change(self): self.node.text.visible = self.overlay.labels def _on_labels_text_change(self): axes = self.viewer.dims.displayed[::-1] axes_labels = [self.viewer.dims.axis_labels[a] for a in axes] self.node.text.text = axes_labels def _on_zoom_change(self): scale = 1 / self.viewer.camera.zoom # If scale has not changed, do not redraw if abs(np.log10(self._scale) - np.log10(scale)) < 1e-4: return self._scale = scale scale = self._target_length * self._scale # Update axes scale self.node.transform.reset() self.node.transform.scale([scale, scale, scale, 1]) def reset(self): super().reset() self._on_data_change() self._on_labels_visible_change() self._on_labels_text_change() self._on_zoom_change() napari-0.5.0a1/napari/_vispy/overlays/base.py000066400000000000000000000122651437041365600211420ustar00rootroot00000000000000from vispy.visuals.transforms import MatrixTransform, STTransform from napari.components._viewer_constants import CanvasPosition from napari.utils.events import disconnect_events from napari.utils.translations import trans class VispyBaseOverlay: """ Base overlay backend for vispy. Creates event connections between napari Overlay models and the vispy backend, translating them into rendering. """ def __init__(self, *, overlay, node, parent=None) -> None: super().__init__() self.overlay = overlay self.node = node self.node.order = self.overlay.order self.overlay.events.visible.connect(self._on_visible_change) self.overlay.events.opacity.connect(self._on_opacity_change) if parent is not None: self.node.parent = parent def _on_visible_change(self): self.node.visible = self.overlay.visible def _on_opacity_change(self): self.node.opacity = self.overlay.opacity def reset(self): self._on_visible_change() self._on_opacity_change() def close(self): disconnect_events(self.overlay.events, self) self.node.transforms = MatrixTransform() self.node.parent = None class VispyCanvasOverlay(VispyBaseOverlay): """ Vispy overlay backend for overlays that live in canvas space. """ def __init__(self, *, overlay, node, parent=None) -> None: super().__init__(overlay=overlay, node=node, parent=None) # offsets and size are used to control fine positioning, and will depend # on the subclass and visual that needs to be rendered self.x_offset = 10 self.y_offset = 10 self.x_size = 0 self.y_size = 0 self.node.transform = STTransform() self.overlay.events.position.connect(self._on_position_change) self.node.events.parent_change.connect(self._on_parent_change) def _on_parent_change(self, event): if event.old is not None: disconnect_events(self, event.old.canvas) if event.new is not None and self.node.canvas is not None: # connect the canvas resize to recalculating the position event.new.canvas.events.resize.connect(self._on_position_change) def _on_position_change(self, event=None): # subclasses should set sizes correctly and adjust offsets to get # the optimal positioning if self.node.canvas is None: return x_max, y_max = list(self.node.canvas.size) position = self.overlay.position if position == CanvasPosition.TOP_LEFT: transform = [self.x_offset, self.y_offset, 0, 0] elif position == CanvasPosition.TOP_CENTER: transform = [x_max / 2 - self.x_size / 2, self.y_offset, 0, 0] elif position == CanvasPosition.TOP_RIGHT: transform = [ x_max - self.x_size - self.x_offset, self.y_offset, 0, 0, ] elif position == CanvasPosition.BOTTOM_LEFT: transform = [ self.x_offset, y_max - self.y_size - self.y_offset, 0, 0, ] elif position == CanvasPosition.BOTTOM_CENTER: transform = [ x_max / 2 - self.x_size / 2, y_max - self.y_size - self.y_offset, 0, 0, ] elif position == CanvasPosition.BOTTOM_RIGHT: transform = [ x_max - self.x_size - self.x_offset, y_max - self.y_size - self.y_offset, 0, 0, ] else: raise ValueError( trans._( 'Position {position} not recognized.', deferred=True, position=position, ) ) self.node.transform.translate = transform scale = abs(self.node.transform.scale[0]) self.node.transform.scale = [scale, 1, 1, 1] def reset(self): super().reset() self._on_position_change() class VispySceneOverlay(VispyBaseOverlay): """ Vispy overlay backend for overlays that live in scene (2D or 3D) space. """ def __init__(self, *, overlay, node, parent=None) -> None: super().__init__(overlay=overlay, node=node, parent=None) self.node.transform = MatrixTransform() class LayerOverlayMixin: def __init__(self, *, layer, overlay, node, parent=None) -> None: super().__init__( node=node, overlay=overlay, parent=parent, ) self.layer = layer self.layer._overlays.events.removed.connect(self.close) def close(self): disconnect_events(self.layer.events, self) super().close() class ViewerOverlayMixin: def __init__(self, *, viewer, overlay, node, parent=None) -> None: super().__init__( node=node, overlay=overlay, parent=parent, ) self.viewer = viewer self.viewer._overlays.events.removed.connect(self.close) def close(self): disconnect_events(self.viewer.events, self) super().close() napari-0.5.0a1/napari/_vispy/overlays/bounding_box.py000066400000000000000000000046231437041365600227040ustar00rootroot00000000000000from napari._vispy.overlays.base import LayerOverlayMixin, VispySceneOverlay from napari._vispy.visuals.bounding_box import BoundingBox class VispyBoundingBoxOverlay(LayerOverlayMixin, VispySceneOverlay): def __init__(self, *, layer, overlay, parent=None): super().__init__( node=BoundingBox(), layer=layer, overlay=overlay, parent=parent, ) self.layer.events.set_data.connect(self._on_bounds_change) self.overlay.events.lines.connect(self._on_lines_change) self.overlay.events.line_thickness.connect( self._on_line_thickness_change ) self.overlay.events.line_color.connect(self._on_line_color_change) self.overlay.events.points.connect(self._on_points_change) self.overlay.events.point_size.connect(self._on_point_size_change) self.overlay.events.point_color.connect(self._on_point_color_change) def _on_bounds_change(self): bounds = self.layer._display_bounding_box( self.layer._slice_input.displayed ) # invert for vispy self.node.set_bounds(bounds[::-1]) self._on_lines_change() def _on_lines_change(self): if self.layer._slice_input.ndisplay == 2: self.node.line2d.visible = self.overlay.lines self.node.line3d.visible = False else: self.node.line3d.visible = self.overlay.lines self.node.line2d.visible = False def _on_points_change(self): self.node.markers.visible = self.overlay.points def _on_line_thickness_change(self): self.node.line2d.set_data(width=self.overlay.line_thickness) self.node.line3d.set_data(width=self.overlay.line_thickness) def _on_line_color_change(self): self.node.line2d.set_data(color=self.overlay.line_color) self.node.line3d.set_data(color=self.overlay.line_color) def _on_point_size_change(self): self.node._marker_size = self.overlay.point_size self._on_bounds_change() def _on_point_color_change(self): self.node._marker_color = self.overlay.point_color self._on_bounds_change() def reset(self): super().reset() self._on_line_thickness_change() self._on_line_color_change() self._on_point_color_change() self._on_point_size_change() self._on_points_change() self._on_bounds_change() napari-0.5.0a1/napari/_vispy/overlays/interaction_box.py000066400000000000000000000057721437041365600234240ustar00rootroot00000000000000from napari._vispy.overlays.base import LayerOverlayMixin, VispySceneOverlay from napari._vispy.visuals.interaction_box import InteractionBox from napari.layers.base._base_constants import InteractionBoxHandle class _VispyBoundingBoxOverlay(LayerOverlayMixin, VispySceneOverlay): def __init__(self, *, layer, overlay, parent=None) -> None: super().__init__( node=InteractionBox(), layer=layer, overlay=overlay, parent=parent, ) self.layer.events.set_data.connect(self._on_visible_change) def _on_bounds_change(self): pass def _on_visible_change(self): if self.layer._slice_input.ndisplay == 2: super()._on_visible_change() self._on_bounds_change() else: self.node.visible = False def reset(self): super().reset() self._on_bounds_change() class VispySelectionBoxOverlay(_VispyBoundingBoxOverlay): def __init__(self, *, layer, overlay, parent=None) -> None: super().__init__( layer=layer, overlay=overlay, parent=parent, ) self.overlay.events.bounds.connect(self._on_bounds_change) self.overlay.events.handles.connect(self._on_bounds_change) self.overlay.events.selected_handle.connect(self._on_bounds_change) def _on_bounds_change(self): if self.layer._slice_input.ndisplay == 2: top_left, bot_right = self.overlay.bounds self.node.set_data( # invert axes for vispy top_left[::-1], bot_right[::-1], handles=self.overlay.handles, selected=self.overlay.selected_handle, ) class VispyTransformBoxOverlay(_VispyBoundingBoxOverlay): def __init__(self, *, layer, overlay, parent=None) -> None: super().__init__( layer=layer, overlay=overlay, parent=parent, ) self.layer.events.scale.connect(self._on_bounds_change) self.layer.events.translate.connect(self._on_bounds_change) self.layer.events.rotate.connect(self._on_bounds_change) self.layer.events.shear.connect(self._on_bounds_change) self.layer.events.affine.connect(self._on_bounds_change) self.overlay.events.selected_handle.connect(self._on_bounds_change) def _on_bounds_change(self): if self.layer._slice_input.ndisplay == 2: bounds = self.layer._display_bounding_box( self.layer._slice_input.displayed ) # invert axes for vispy top_left, bot_right = (tuple(point) for point in bounds.T[:, ::-1]) if self.overlay.selected_handle == InteractionBoxHandle.INSIDE: selected = slice(None) else: selected = self.overlay.selected_handle self.node.set_data( top_left, bot_right, handles=True, selected=selected, ) napari-0.5.0a1/napari/_vispy/overlays/scale_bar.py000066400000000000000000000135751437041365600221500ustar00rootroot00000000000000import bisect import numpy as np from napari._vispy.overlays.base import ViewerOverlayMixin, VispyCanvasOverlay from napari._vispy.visuals.scale_bar import ScaleBar from napari.utils._units import PREFERRED_VALUES, get_unit_registry from napari.utils.colormaps.standardize_color import transform_color from napari.utils.theme import get_theme class VispyScaleBarOverlay(ViewerOverlayMixin, VispyCanvasOverlay): """Scale bar in world coordinates.""" def __init__(self, *, viewer, overlay, parent=None) -> None: self._target_length = 150 self._scale = 1 self._unit = None super().__init__( node=ScaleBar(), viewer=viewer, overlay=overlay, parent=parent ) self.x_size = 150 # will be updated on zoom anyways # need to change from defaults because the anchor is in the center self.y_offset = 20 self.y_size = 5 self.overlay.events.box.connect(self._on_box_change) self.overlay.events.box_color.connect(self._on_data_change) self.overlay.events.color.connect(self._on_data_change) self.overlay.events.colored.connect(self._on_data_change) self.overlay.events.font_size.connect(self._on_text_change) self.overlay.events.ticks.connect(self._on_data_change) self.overlay.events.unit.connect(self._on_unit_change) self.viewer.events.theme.connect(self._on_data_change) self.viewer.camera.events.zoom.connect(self._on_zoom_change) self.reset() def _on_unit_change(self): self._unit = get_unit_registry()(self.overlay.unit) self._on_zoom_change(force=True) def _calculate_best_length(self, desired_length: float): """Calculate new quantity based on the pixel length of the bar. Parameters ---------- desired_length : float Desired length of the scale bar in world size. Returns ------- new_length : float New length of the scale bar in world size based on the preferred scale bar value. new_quantity : pint.Quantity New quantity with abbreviated base unit. """ current_quantity = self._unit * desired_length # convert the value to compact representation new_quantity = current_quantity.to_compact() # calculate the scaling factor taking into account any conversion # that might have occurred (e.g. um -> cm) factor = current_quantity / new_quantity # select value closest to one of our preferred values index = bisect.bisect_left(PREFERRED_VALUES, new_quantity.magnitude) if index > 0: # When we get the lowest index of the list, removing -1 will # return the last index. index -= 1 new_value = PREFERRED_VALUES[index] # get the new pixel length utilizing the user-specified units new_length = ((new_value * factor) / self._unit.magnitude).magnitude new_quantity = new_value * new_quantity.units return new_length, new_quantity def _on_zoom_change(self, *, force: bool = False): """Update axes length based on zoom scale.""" # If scale has not changed, do not redraw scale = 1 / self.viewer.camera.zoom if abs(np.log10(self._scale) - np.log10(scale)) < 1e-4 and not force: return self._scale = scale scale_canvas2world = self._scale target_canvas_pixels = self._target_length # convert desired length to world size target_world_pixels = scale_canvas2world * target_canvas_pixels # calculate the desired length as well as update the value and units target_world_pixels_rounded, new_dim = self._calculate_best_length( target_world_pixels ) target_canvas_pixels_rounded = ( target_world_pixels_rounded / scale_canvas2world ) scale = target_canvas_pixels_rounded # Update scalebar and text self.node.transform.scale = [scale, 1, 1, 1] self.node.text.text = f'{new_dim:~}' self.x_size = scale # needed to offset properly self._on_position_change() def _on_data_change(self): """Change color and data of scale bar and box.""" color = self.overlay.color box_color = self.overlay.box_color if not self.overlay.colored: if self.overlay.box: # The box is visible - set the scale bar color to the negative of the # box color. color = 1 - box_color color[-1] = 1 else: # set scale color negative of theme background. # the reason for using the `as_hex` here is to avoid # `UserWarning` which is emitted when RGB values are above 1 if ( self.node.parent is not None and self.node.parent.parent.canvas.background_color_override ): background_color = transform_color( self.node.parent.parent.canvas.background_color_override )[0] else: background_color = get_theme( self.viewer.theme, False ).canvas.as_hex() background_color = transform_color(background_color)[0] color = np.subtract(1, background_color) color[-1] = background_color[-1] self.node.set_data(color, self.overlay.ticks) self.node.box.color = box_color def _on_box_change(self): self.node.box.visible = self.overlay.box def _on_text_change(self): """Update text information""" self.node.text.font_size = self.overlay.font_size def reset(self): super().reset() self._on_unit_change() self._on_data_change() self._on_box_change() self._on_text_change() napari-0.5.0a1/napari/_vispy/overlays/text.py000066400000000000000000000036011437041365600212060ustar00rootroot00000000000000from vispy.scene.visuals import Text from napari._vispy.overlays.base import ViewerOverlayMixin, VispyCanvasOverlay from napari.components._viewer_constants import CanvasPosition class VispyTextOverlay(ViewerOverlayMixin, VispyCanvasOverlay): """Text overlay.""" def __init__(self, *, viewer, overlay, parent=None) -> None: super().__init__( node=Text(pos=(0, 0)), viewer=viewer, overlay=overlay, parent=parent, ) self.node.font_size = self.overlay.font_size self.node.anchors = ("left", "top") self.overlay.events.text.connect(self._on_text_change) self.overlay.events.color.connect(self._on_color_change) self.overlay.events.font_size.connect(self._on_font_size_change) self.reset() def _on_text_change(self): self.node.text = self.overlay.text def _on_color_change(self): self.node.color = self.overlay.color def _on_font_size_change(self): self.node.font_size = self.overlay.font_size def _on_position_change(self, event=None): super()._on_position_change() position = self.overlay.position if position == CanvasPosition.TOP_LEFT: anchors = ("left", "bottom") elif position == CanvasPosition.TOP_RIGHT: anchors = ("right", "bottom") elif position == CanvasPosition.TOP_CENTER: anchors = ("center", "bottom") elif position == CanvasPosition.BOTTOM_RIGHT: anchors = ("right", "top") elif position == CanvasPosition.BOTTOM_LEFT: anchors = ("left", "top") elif position == CanvasPosition.BOTTOM_CENTER: anchors = ("center", "top") self.node.anchors = anchors def reset(self): super().reset() self._on_text_change() self._on_color_change() self._on_font_size_change() napari-0.5.0a1/napari/_vispy/utils/000077500000000000000000000000001437041365600171445ustar00rootroot00000000000000napari-0.5.0a1/napari/_vispy/utils/__init__.py000066400000000000000000000000001437041365600212430ustar00rootroot00000000000000napari-0.5.0a1/napari/_vispy/utils/gl.py000066400000000000000000000100621437041365600201170ustar00rootroot00000000000000"""OpenGL Utilities. """ from contextlib import contextmanager from functools import lru_cache from typing import Tuple import numpy as np from vispy.app import Canvas from vispy.gloo import gl from vispy.gloo.context import get_current_canvas from napari.utils.translations import trans texture_dtypes = [ np.dtype(np.uint8), np.dtype(np.uint16), np.dtype(np.float32), ] @contextmanager def _opengl_context(): """Assure we are running with a valid OpenGL context. Only create a Canvas is one doesn't exist. Creating and closing a Canvas causes vispy to process Qt events which can cause problems. Ideally call opengl_context() on start after creating your first Canvas. However it will work either way. """ canvas = Canvas(show=False) if get_current_canvas() is None else None try: yield finally: if canvas is not None: canvas.close() @lru_cache(maxsize=1) def get_gl_extensions() -> str: """Get basic info about the Gl capabilities of this machine""" with _opengl_context(): return gl.glGetParameter(gl.GL_EXTENSIONS) @lru_cache def get_max_texture_sizes() -> Tuple[int, int]: """Return the maximum texture sizes for 2D and 3D rendering. If this function is called without an OpenGL context it will create a temporary non-visible Canvas. Either way the lru_cache means subsequent calls to thing function will return the original values without actually running again. Returns ------- Tuple[int, int] The max textures sizes for (2d, 3d) rendering. """ with _opengl_context(): max_size_2d = gl.glGetParameter(gl.GL_MAX_TEXTURE_SIZE) if max_size_2d == (): max_size_2d = None # vispy/gloo doesn't provide the GL_MAX_3D_TEXTURE_SIZE location, # but it can be found in this list of constants # http://pyopengl.sourceforge.net/documentation/pydoc/OpenGL.GL.html with _opengl_context(): GL_MAX_3D_TEXTURE_SIZE = 32883 max_size_3d = gl.glGetParameter(GL_MAX_3D_TEXTURE_SIZE) if max_size_3d == (): max_size_3d = None return max_size_2d, max_size_3d def fix_data_dtype(data): """Makes sure the dtype of the data is accetpable to vispy. Acceptable types are int8, uint8, int16, uint16, float32. Parameters ---------- data : np.ndarray Data that will need to be of right type. Returns ------- np.ndarray Data that is of right type and will be passed to vispy. """ dtype = np.dtype(data.dtype) if dtype in texture_dtypes: return data else: try: dtype = dict(i=np.float32, f=np.float32, u=np.uint16, b=np.uint8)[ dtype.kind ] except KeyError as e: # not an int or float raise TypeError( trans._( 'type {dtype} not allowed for texture; must be one of {textures}', # noqa: E501 deferred=True, dtype=dtype, textures=set(texture_dtypes), ) ) from e return data.astype(dtype) BLENDING_MODES = { 'opaque': dict( depth_test=True, cull_face=False, blend=False, blend_func=('one', 'zero'), blend_equation='func_add', ), 'translucent': dict( depth_test=True, cull_face=False, blend=True, blend_func=('src_alpha', 'one_minus_src_alpha', 'zero', 'one'), blend_equation='func_add', ), 'translucent_no_depth': dict( depth_test=False, cull_face=False, blend=True, blend_func=('src_alpha', 'one_minus_src_alpha', 'zero', 'one'), blend_equation='func_add', # see vispy/vispy#2324 ), 'additive': dict( depth_test=False, cull_face=False, blend=True, blend_func=('src_alpha', 'one'), blend_equation='func_add', ), 'minimum': dict( depth_test=False, cull_face=False, blend=True, blend_func=('one', 'one'), blend_equation='min', ), } napari-0.5.0a1/napari/_vispy/utils/quaternion.py000066400000000000000000000027371437041365600217140ustar00rootroot00000000000000import numpy as np def quaternion2euler(quaternion, degrees=False): """Converts VisPy quaternion into euler angle representation. Euler angles have degeneracies, so the output might different from the Euler angles that might have been used to generate the input quaternion. Euler angles representation also has a singularity near pitch = Pi/2 ; to avoid this, we set to Pi/2 pitch angles that are closer than the chosen epsilon from it. Parameters ---------- quaternion : vispy.util.Quaternion Quaternion for conversion. degrees : bool If output is returned in degrees or radians. Returns ------- angles : 3-tuple Euler angles in (rx, ry, rz) order. """ epsilon = 1e-10 q = quaternion sin_theta_2 = 2 * (q.w * q.y - q.z * q.x) sin_theta_2 = np.sign(sin_theta_2) * min(abs(sin_theta_2), 1) if abs(sin_theta_2) > 1 - epsilon: theta_1 = -np.sign(sin_theta_2) * 2 * np.arctan2(q.x, q.w) theta_2 = np.arcsin(sin_theta_2) theta_3 = 0 else: theta_1 = np.arctan2( 2 * (q.w * q.z + q.y * q.x), 1 - 2 * (q.y * q.y + q.z * q.z), ) theta_2 = np.arcsin(sin_theta_2) theta_3 = np.arctan2( 2 * (q.w * q.x + q.y * q.z), 1 - 2 * (q.x * q.x + q.y * q.y), ) angles = (theta_1, theta_2, theta_3) if degrees: return tuple(np.degrees(angles)) else: return angles napari-0.5.0a1/napari/_vispy/utils/text.py000066400000000000000000000042031437041365600205010ustar00rootroot00000000000000from typing import Union import numpy as np from vispy.scene.visuals import Text from napari.layers import Points, Shapes from napari.layers.utils.string_encoding import ConstantStringEncoding def update_text( *, node: Text, layer: Union[Points, Shapes], ): """Update the vispy text node with a layer's text parameters. Parameters ---------- node : vispy.scene.visuals.Text The text node to be updated. layer : Union[Points, Shapes] A layer with text. """ ndisplay = layer._slice_input.ndisplay # Vispy always needs non-empty values and coordinates, so if a layer # effectively has no visible text then return single dummy data. # This also acts as a minor optimization. if _has_visible_text(layer): text_values = layer._view_text colors = layer._view_text_color coords, anchor_x, anchor_y = layer._view_text_coords else: text_values = np.array(['']) colors = np.zeros((4,), np.float32) coords = np.zeros((1, ndisplay)) anchor_x = 'center' anchor_y = 'center' # Vispy wants (x, y) positions instead of (row, column) coordinates. if ndisplay == 2: positions = np.flip(coords, axis=1) elif ndisplay == 3: raw_positions = np.flip(coords, axis=1) n_positions, position_dims = raw_positions.shape if position_dims < 3: padded_positions = np.zeros((n_positions, 3)) padded_positions[:, 0:2] = raw_positions positions = padded_positions else: positions = raw_positions node.text = text_values node.pos = positions node.anchors = (anchor_x, anchor_y) text_manager = layer.text node.rotation = text_manager.rotation node.color = colors node.font_size = text_manager.size def _has_visible_text(layer: Union[Points, Shapes]) -> bool: text = layer.text if not text.visible: return False if ( isinstance(text.string, ConstantStringEncoding) and text.string.constant == '' ): return False if len(layer._indices_view) == 0: return False return True napari-0.5.0a1/napari/_vispy/utils/visual.py000066400000000000000000000131431437041365600210230ustar00rootroot00000000000000from __future__ import annotations from typing import List, Tuple import numpy as np from vispy.scene.widgets.viewbox import ViewBox from napari._vispy.layers.base import VispyBaseLayer from napari._vispy.layers.image import VispyImageLayer from napari._vispy.layers.labels import VispyLabelsLayer from napari._vispy.layers.points import VispyPointsLayer from napari._vispy.layers.shapes import VispyShapesLayer from napari._vispy.layers.surface import VispySurfaceLayer from napari._vispy.layers.tracks import VispyTracksLayer from napari._vispy.layers.vectors import VispyVectorsLayer from napari._vispy.overlays.axes import VispyAxesOverlay from napari._vispy.overlays.base import VispyBaseOverlay from napari._vispy.overlays.bounding_box import VispyBoundingBoxOverlay from napari._vispy.overlays.interaction_box import ( VispySelectionBoxOverlay, VispyTransformBoxOverlay, ) from napari._vispy.overlays.scale_bar import VispyScaleBarOverlay from napari._vispy.overlays.text import VispyTextOverlay from napari.components.overlays import ( AxesOverlay, BoundingBoxOverlay, Overlay, ScaleBarOverlay, SelectionBoxOverlay, TextOverlay, TransformBoxOverlay, ) from napari.layers import ( Image, Labels, Layer, Points, Shapes, Surface, Tracks, Vectors, ) from napari.utils.config import async_octree from napari.utils.translations import trans layer_to_visual = { Image: VispyImageLayer, Labels: VispyLabelsLayer, Points: VispyPointsLayer, Shapes: VispyShapesLayer, Surface: VispySurfaceLayer, Vectors: VispyVectorsLayer, Tracks: VispyTracksLayer, } overlay_to_visual = { ScaleBarOverlay: VispyScaleBarOverlay, TextOverlay: VispyTextOverlay, AxesOverlay: VispyAxesOverlay, BoundingBoxOverlay: VispyBoundingBoxOverlay, TransformBoxOverlay: VispyTransformBoxOverlay, SelectionBoxOverlay: VispySelectionBoxOverlay, } if async_octree: from napari._vispy.experimental.vispy_tiled_image_layer import ( VispyTiledImageLayer, ) from napari.layers.image.experimental.octree_image import _OctreeImageBase # Insert _OctreeImageBase in front so it gets picked over plain Image. new_mapping = {_OctreeImageBase: VispyTiledImageLayer} new_mapping.update(layer_to_visual) layer_to_visual = new_mapping def create_vispy_layer(layer: Layer) -> VispyBaseLayer: """Create vispy visual for a layer based on its layer type. Parameters ---------- layer : napari.layers._base_layer.Layer Layer that needs its property widget created. Returns ------- visual : VispyBaseLayer Vispy layer """ for layer_type, visual_class in layer_to_visual.items(): if isinstance(layer, layer_type): return visual_class(layer) raise TypeError( trans._( 'Could not find VispyLayer for layer of type {dtype}', deferred=True, dtype=type(layer), ) ) def create_vispy_overlay(overlay: Overlay, **kwargs) -> List[VispyBaseOverlay]: """ Create vispy visuals for each overlay contained in an Overlays model based on their type, Parameters ---------- overlay : napari.components.overlays.VispyBaseOverlay The overlay to create a visual for. Returns ------- visual : VispyBaseOverlay Vispy overlay """ for overlay_type, visual_class in overlay_to_visual.items(): if isinstance(overlay, overlay_type): return visual_class(overlay=overlay, **kwargs) raise TypeError( trans._( 'Could not find VispyOverlay for overlay of type {dtype}', deferred=True, dtype=type(overlay), ) ) def get_view_direction_in_scene_coordinates( view: ViewBox, ndim: int, dims_displayed: Tuple[int], ) -> np.ndarray: """Calculate the unit vector pointing in the direction of the view. This is only for 3D viewing, so it returns None when len(dims_displayed) == 2. Adapted From: https://stackoverflow.com/questions/37877592/ get-view-direction-relative-to-scene-in-vispy/37882984 Parameters ---------- view : vispy.scene.widgets.viewbox.ViewBox The vispy view box object to get the view direction from. ndim : int The number of dimensions in the full nD dims model. This is typically from viewer.dims.ndim dims_displayed : Tuple[int] The indices of the dims displayed in the viewer. This is typically from viewer.dims.displayed. Returns ------- view_vector : np.ndarray Unit vector in the direction of the view in scene coordinates. Axes are ordered zyx. If the viewer is in 2D (i.e., len(dims_displayed) == 2), view_vector is None. """ # only return a vector when viewing in 3D if len(dims_displayed) == 2: return None tform = view.scene.transform w, h = view.canvas.size # get a point at the center of the canvas # (homogeneous screen coords) screen_center = np.array([w / 2, h / 2, 0, 1]) # find a point just in front of the center point # transform both to world coords and find the vector d1 = np.array([0, 0, 1, 0]) point_in_front_of_screen_center = screen_center + d1 p1 = tform.imap(point_in_front_of_screen_center) p0 = tform.imap(screen_center) d2 = p1 - p0 # in 3D world coordinates d3 = d2[0:3] d4 = d3 / np.linalg.norm(d3) # data are ordered xyz on vispy Volume d4 = d4[[2, 1, 0]] view_dir_world = np.zeros((ndim,)) for i, d in enumerate(dims_displayed): view_dir_world[d] = d4[i] return view_dir_world napari-0.5.0a1/napari/_vispy/visuals/000077500000000000000000000000001437041365600174725ustar00rootroot00000000000000napari-0.5.0a1/napari/_vispy/visuals/__init__.py000066400000000000000000000000001437041365600215710ustar00rootroot00000000000000napari-0.5.0a1/napari/_vispy/visuals/axes.py000066400000000000000000000206141437041365600210070ustar00rootroot00000000000000import numpy as np from vispy.scene.visuals import Compound, Line, Mesh, Text from napari.layers.shapes._shapes_utils import triangulate_ellipse from napari.utils.colormaps.standardize_color import transform_color from napari.utils.translations import trans def make_dashed_line(num_dashes, axis): """Make a dashed line. Parameters ---------- num_dashes : int Number of dashes in the line. axis : int Axis which is dashed. Returns ------- np.ndarray Dashed line, of shape (num_dashes, 3) with zeros in the non dashed axes and line segments in the dashed axis. """ dashes = np.linspace(0, 1, num_dashes * 2) dashed_line_ends = np.concatenate( [[dashes[2 * i], dashes[2 * i + 1]] for i in range(num_dashes)], axis=0 ) dashed_line = np.zeros((2 * num_dashes, 3)) dashed_line[:, axis] = np.array(dashed_line_ends) return dashed_line def make_arrow_head(num_segments, axis): """Make an arrowhead line. Parameters ---------- num_segments : int Number of segments in the arrowhead. axis Arrowhead direction. Returns ------- np.ndarray, np.ndarray Vertices and faces of the arrowhead. """ corners = np.array([[-1, -1], [-1, 1], [1, 1], [1, -1]]) * 0.1 vertices, faces = triangulate_ellipse(corners, num_segments) full_vertices = np.zeros((num_segments + 1, 3)) inds = list(range(3)) inds.pop(axis) full_vertices[:, inds] = vertices full_vertices[:, axis] = 0.9 full_vertices[0, axis] = 1.02 return full_vertices, faces def color_lines(colors): if len(colors) == 2: return np.concatenate( [[colors[0]] * 2, [colors[1]] * 2], axis=0, ) elif len(colors) == 3: return np.concatenate( [[colors[0]] * 2, [colors[1]] * 2, [colors[2]] * 2], axis=0, ) else: return ValueError( trans._( 'Either 2 or 3 colors must be provided, got {number}.', deferred=True, number=len(colors), ) ) def color_dashed_lines(colors): if len(colors) == 2: return np.concatenate( [[colors[0]] * 2, [colors[1]] * 4 * 2], axis=0, ) elif len(colors) == 3: return np.concatenate( [[colors[0]] * 2, [colors[1]] * 4 * 2, [colors[2]] * 8 * 2], axis=0, ) else: return ValueError( trans._( 'Either 2 or 3 colors must be provided, got {number}.', deferred=True, number=len(colors), ) ) def color_arrowheads(colors, num_segments): if len(colors) == 2: return np.concatenate( [[colors[0]] * num_segments, [colors[1]] * num_segments], axis=0, ) elif len(colors) == 3: return np.concatenate( [ [colors[0]] * num_segments, [colors[1]] * num_segments, [colors[2]] * num_segments, ], axis=0, ) else: return ValueError( trans._( 'Either 2 or 3 colors must be provided, got {number}.', deferred=True, number=len(colors), ) ) class Axes(Compound): def __init__(self) -> None: self._num_segments_arrowhead = 100 # CMYRGB for 6 axes data in x, y, z, ... ordering self._default_color = [ [0, 1, 1, 1], [1, 0, 1, 1], [1, 1, 0, 1], [1, 0, 0, 1], [0, 1, 0, 1], [0, 0, 1, 1], ] self._text_offsets = 0.1 * np.array([1, 1, 1]) # note order is x, y, z for VisPy self._line_data2D = np.array( [[0, 0, 0], [1, 0, 0], [0, 0, 0], [0, 1, 0]] ) self._line_data3D = np.array( [[0, 0, 0], [1, 0, 0], [0, 0, 0], [0, 1, 0], [0, 0, 0], [0, 0, 1]] ) # note order is x, y, z for VisPy self._dashed_line_data2D = np.concatenate( [[[1, 0, 0], [0, 0, 0]], make_dashed_line(4, axis=1)], axis=0, ) self._dashed_line_data3D = np.concatenate( [ [[1, 0, 0], [0, 0, 0]], make_dashed_line(4, axis=1), make_dashed_line(8, axis=2), ], axis=0, ) # note order is x, y, z for VisPy vertices = np.empty((0, 3)) faces = np.empty((0, 3)) for axis in range(2): v, f = make_arrow_head(self._num_segments_arrowhead, axis) faces = np.concatenate([faces, f + len(vertices)], axis=0) vertices = np.concatenate([vertices, v], axis=0) self._default_arrow_vertices2D = vertices self._default_arrow_faces2D = faces.astype(int) vertices = np.empty((0, 3)) faces = np.empty((0, 3)) for axis in range(3): v, f = make_arrow_head(self._num_segments_arrowhead, axis) faces = np.concatenate([faces, f + len(vertices)], axis=0) vertices = np.concatenate([vertices, v], axis=0) self._default_arrow_vertices3D = vertices self._default_arrow_faces3D = faces.astype(int) super().__init__( [ Line(connect='segments', method='gl', width=3), Mesh(), Text( text='1', font_size=10, anchor_x='center', anchor_y='center', ), ] ) @property def line(self): return self._subvisuals[0] @property def mesh(self): return self._subvisuals[1] @property def text(self): return self._subvisuals[2] def set_data(self, axes, reversed_axes, colored, bg_color, dashed, arrows): ndisplay = len(axes) # Determine colors of axes based on reverse position if colored: axes_colors = [ self._default_color[ra % len(self._default_color)] for ra in reversed_axes ] else: # the reason for using the `as_hex` here is to avoid # `UserWarning` which is emitted when RGB values are above 1 bg_color = transform_color(bg_color.as_hex())[0] color = np.subtract(1, bg_color) color[-1] = bg_color[-1] axes_colors = [color] * ndisplay # Determine data based on number of displayed dimensions and # axes visualization parameters if dashed and ndisplay == 2: data = self._dashed_line_data2D color = color_dashed_lines(axes_colors) text_data = self._line_data2D[1::2] elif dashed and ndisplay == 3: data = self._dashed_line_data3D color = color_dashed_lines(axes_colors) text_data = self._line_data3D[1::2] elif not dashed and ndisplay == 2: data = self._line_data2D color = color_lines(axes_colors) text_data = self._line_data2D[1::2] elif not dashed and ndisplay == 3: data = self._line_data3D color = color_lines(axes_colors) text_data = self._line_data3D[1::2] else: raise ValueError( trans._( 'Axes dash status and ndisplay combination not supported', deferred=True, ) ) if arrows and ndisplay == 2: arrow_vertices = self._default_arrow_vertices2D arrow_faces = self._default_arrow_faces2D arrow_color = color_arrowheads( axes_colors, self._num_segments_arrowhead ) elif arrows and ndisplay == 3: arrow_vertices = self._default_arrow_vertices3D arrow_faces = self._default_arrow_faces3D arrow_color = color_arrowheads( axes_colors, self._num_segments_arrowhead ) else: arrow_vertices = np.zeros((3, 3)) arrow_faces = np.array([[0, 1, 2]]) arrow_color = [[0, 0, 0, 0]] self.line.set_data(data, color) self.mesh.set_data( vertices=arrow_vertices, faces=arrow_faces, face_colors=arrow_color, ) self.text.color = axes_colors self.text.pos = text_data + self._text_offsets napari-0.5.0a1/napari/_vispy/visuals/bounding_box.py000066400000000000000000000046321437041365600225260ustar00rootroot00000000000000from itertools import product import numpy as np from vispy.scene.visuals import Compound, Line from napari._vispy.visuals.markers import Markers class BoundingBox(Compound): # vertices are generated according to the following scheme: # 5-------7 # /| /| # 1-------3 | # | | | | # | 4-----|-6 # |/ |/ # 0-------2 _edges = np.array( [ [0, 1], [1, 3], [3, 2], [2, 0], [0, 4], [1, 5], [2, 6], [3, 7], [4, 5], [5, 7], [7, 6], [6, 4], ] ) def __init__(self, *args, **kwargs): self._marker_color = (1, 1, 1, 1) self._marker_size = 1 super().__init__( [Line(), Line(), Markers(antialias=0)], *args, **kwargs ) @property def line2d(self): return self._subvisuals[0] @property def line3d(self): return self._subvisuals[1] @property def markers(self): return self._subvisuals[2] def _set_bounds_2d(self, vertices): # only the front face is needed for 2D edges = self._edges[:4] self.line2d.set_data(pos=vertices, connect=edges) self.line2d.visible = True self.line3d.visible = False self.markers.set_data( pos=vertices, size=self._marker_size, face_color=self._marker_color, edge_width=0, ) def _set_bounds_3d(self, vertices): # pixels in 3D are shifted by half in napari compared to vispy # TODO: find exactly where this difference is and write it here vertices = vertices - 0.5 self.line3d.set_data( pos=vertices, connect=self._edges.copy(), color='red', width=2 ) self.line3d.visible = True self.line2d.visible = False self.markers.set_data( pos=vertices, size=self._marker_size, face_color=self._marker_color, edge_width=0, ) def set_bounds(self, bounds): """ Takes another node to generate its bounding box. """ vertices = np.array(list(product(*bounds))) if any(b is None for b in bounds): return if len(bounds) == 2: self._set_bounds_2d(vertices) else: self._set_bounds_3d(vertices) napari-0.5.0a1/napari/_vispy/visuals/clipping_planes_mixin.py000066400000000000000000000016301437041365600244170ustar00rootroot00000000000000from typing import List, Optional, Protocol from vispy.visuals.filters import Filter from vispy.visuals.filters.clipping_planes import PlanesClipper class _PVisual(Protocol): """ Type for vispy visuals that implement the attach method """ _subvisuals: Optional[List['_PVisual']] def attach(self, filt: Filter, view=None): ... class ClippingPlanesMixin: """ Mixin class that attaches clipping planes filters to the (sub)visuals and provides property getter and setter """ def __init__(self: _PVisual, *args, **kwargs) -> None: self._clip_filter = PlanesClipper() super().__init__(*args, **kwargs) self.attach(self._clip_filter) @property def clipping_planes(self): return self._clip_filter.clipping_planes @clipping_planes.setter def clipping_planes(self, value): self._clip_filter.clipping_planes = value napari-0.5.0a1/napari/_vispy/visuals/image.py000066400000000000000000000005341437041365600211300ustar00rootroot00000000000000from vispy.scene.visuals import Image as BaseImage # If data is not present, we need bounds to be None (see napari#3517) class Image(BaseImage): def _compute_bounds(self, axis, view): if self._data is None: return None elif axis > 1: return (0, 0) else: return (0, self.size[axis]) napari-0.5.0a1/napari/_vispy/visuals/interaction_box.py000066400000000000000000000037641437041365600232450ustar00rootroot00000000000000import numpy as np from vispy.scene.visuals import Compound, Line from napari._vispy.visuals.markers import Markers from napari.layers.utils.interaction_box import ( generate_interaction_box_vertices, ) class InteractionBox(Compound): # vertices are generated according to the following scheme: # (y is actually upside down in the canvas) # 8 # | # 0---4---2 1 = position # | | # 5 9 6 # | | # 1---7---3 _edges = np.array( [ [0, 1], [1, 3], [3, 2], [2, 0], [4, 8], ] ) def __init__(self, *args, **kwargs): self._marker_color = (1, 1, 1, 1) self._marker_size = 10 self._highlight_width = 2 # squares for corners, diamonds for midpoints, disc for rotation handle self._marker_symbol = ['square'] * 4 + ['diamond'] * 4 + ['disc'] self._edge_color = (0, 0, 1, 1) super().__init__([Line(), Markers(antialias=0)], *args, **kwargs) @property def line(self): return self._subvisuals[0] @property def markers(self): return self._subvisuals[1] def set_data(self, top_left, bot_right, handles=True, selected=None): vertices = generate_interaction_box_vertices( top_left, bot_right, handles=handles ) edges = self._edges if handles else self._edges[:4] self.line.set_data(pos=vertices, connect=edges) if handles: marker_edges = np.zeros(len(vertices)) if selected is not None: marker_edges[selected] = self._highlight_width self.markers.set_data( pos=vertices, size=self._marker_size, face_color=self._marker_color, symbol=self._marker_symbol, edge_width=marker_edges, edge_color=self._edge_color, ) else: self.markers.set_data(pos=np.empty((0, 2))) napari-0.5.0a1/napari/_vispy/visuals/markers.py000066400000000000000000000030011437041365600215020ustar00rootroot00000000000000from vispy.scene.visuals import Markers as BaseMarkers clamp_shader = """ float clamped_size = clamp($v_size, $canvas_size_min, $canvas_size_max); float clamped_ratio = clamped_size / $v_size; $v_size = clamped_size; v_edgewidth = v_edgewidth * clamped_ratio; gl_PointSize = $v_size + 4. * (v_edgewidth + 1.5 * u_antialias); """ old_vshader = BaseMarkers._shaders['vertex'] new_vshader = old_vshader[:-2] + clamp_shader + '\n}' # very ugly... class Markers(BaseMarkers): _shaders = { 'vertex': new_vshader, 'fragment': BaseMarkers._shaders['fragment'], } def __init__(self, *args, **kwargs) -> None: self._canvas_size_limits = 0, 10000 super().__init__(*args, **kwargs) self.canvas_size_limits = 0, 10000 def _compute_bounds(self, axis, view): # needed for entering 3D rendering mode when a points # layer is invisible and the self._data property is None if self._data is None: return None pos = self._data['a_position'] if pos is None: return None if pos.shape[1] > axis: return (pos[:, axis].min(), pos[:, axis].max()) else: return (0, 0) @property def canvas_size_limits(self): return self._canvas_size_limits @canvas_size_limits.setter def canvas_size_limits(self, value): self._canvas_size_limits = value self.shared_program.vert['canvas_size_min'] = value[0] self.shared_program.vert['canvas_size_max'] = value[1] napari-0.5.0a1/napari/_vispy/visuals/points.py000066400000000000000000000032521437041365600213620ustar00rootroot00000000000000from vispy.scene.visuals import Compound, Line, Text from napari._vispy.visuals.clipping_planes_mixin import ClippingPlanesMixin from napari._vispy.visuals.markers import Markers class PointsVisual(ClippingPlanesMixin, Compound): """ Compound vispy visual for point visualization with clipping planes functionality Components: - Markers for points (vispy.MarkersVisual) - Markers for selection highlights (vispy.MarkersVisual) - Lines for highlights (vispy.LineVisual) - Text labels (vispy.TextVisual) """ def __init__(self) -> None: super().__init__([Markers(), Markers(), Line(), Text()]) self.scaling = True @property def scaling(self): """ Scaling property for both the markers visuals. If set to true, the points rescale based on zoom (i.e: constant world-space size) """ return self._subvisuals[0].scaling @scaling.setter def scaling(self, value): for marker in self._subvisuals[:2]: marker.scaling = value @property def antialias(self): return self._subvisuals[0].antialias @antialias.setter def antialias(self, value): for marker in self._subvisuals[:2]: marker.antialias = value @property def spherical(self): return self._subvisuals[0].spherical @spherical.setter def spherical(self, value): self._subvisuals[0].spherical = value @property def canvas_size_limits(self): return self._subvisuals[0].canvas_size_limits @canvas_size_limits.setter def canvas_size_limits(self, value): self._subvisuals[0].canvas_size_limits = value napari-0.5.0a1/napari/_vispy/visuals/scale_bar.py000066400000000000000000000022531437041365600217610ustar00rootroot00000000000000import numpy as np from vispy.scene.visuals import Compound, Line, Rectangle, Text class ScaleBar(Compound): def __init__(self) -> None: self._data = np.array( [ [0, 0], [1, 0], [0, -5], [0, 5], [1, -5], [1, 5], ] ) # order matters (last is drawn on top) super().__init__( [ Rectangle(center=[0.5, 0.5], width=1.1, height=36), Text( text='1px', pos=[0.5, 0.5], anchor_x="center", anchor_y="top", font_size=10, ), Line(connect='segments', method='gl', width=3), ] ) @property def line(self): return self._subvisuals[2] @property def text(self): return self._subvisuals[1] @property def box(self): return self._subvisuals[0] def set_data(self, color, ticks): data = self._data if ticks else self._data[:2] self.line.set_data(data, color) self.text.color = color napari-0.5.0a1/napari/_vispy/visuals/shapes.py000066400000000000000000000012361437041365600213310ustar00rootroot00000000000000from vispy.scene.visuals import Compound, Line, Markers, Mesh, Text from napari._vispy.visuals.clipping_planes_mixin import ClippingPlanesMixin class ShapesVisual(ClippingPlanesMixin, Compound): """ Compound vispy visual for shapes visualization with clipping planes functionality Components: - Mesh for shape faces (vispy.MeshVisual) - Mesh for highlights (vispy.MeshVisual) - Lines for highlights (vispy.LineVisual) - Vertices for highlights (vispy.MarkersVisual) - Text labels (vispy.TextVisual) """ def __init__(self) -> None: super().__init__([Mesh(), Mesh(), Line(), Markers(), Text()]) napari-0.5.0a1/napari/_vispy/visuals/surface.py000066400000000000000000000014201437041365600214710ustar00rootroot00000000000000from vispy.scene.visuals import Mesh, MeshNormals from vispy.visuals.filters import WireframeFilter from napari._vispy.visuals.clipping_planes_mixin import ClippingPlanesMixin class SurfaceVisual(ClippingPlanesMixin, Mesh): """ Surface vispy visual with added: - clipping plane functionality - wireframe visualisation - normals visualisation """ def __init__(self, *args, **kwargs) -> None: self.wireframe_filter = WireframeFilter() self.face_normals = None self.vertex_normals = None super().__init__(*args, **kwargs) self.face_normals = MeshNormals(primitive='face', parent=self) self.vertex_normals = MeshNormals(primitive='vertex', parent=self) self.attach(self.wireframe_filter) napari-0.5.0a1/napari/_vispy/visuals/tracks.py000066400000000000000000000015611437041365600213360ustar00rootroot00000000000000from vispy.scene.visuals import Compound, Line, Text from napari._vispy.filters.tracks import TracksFilter from napari._vispy.visuals.clipping_planes_mixin import ClippingPlanesMixin class TracksVisual(ClippingPlanesMixin, Compound): """ Compound vispy visual for Track visualization with clipping planes functionality Components: - Track lines (vispy.LineVisual) - Track IDs (vispy.TextVisual) - Graph edges (vispy.LineVisual) """ def __init__(self) -> None: self.tracks_filter = TracksFilter() self.graph_filter = TracksFilter() super().__init__([Line(), Text(), Line()]) self._subvisuals[0].attach(self.tracks_filter) self._subvisuals[2].attach(self.graph_filter) # text label properties self._subvisuals[1].color = 'white' self._subvisuals[1].font_size = 8 napari-0.5.0a1/napari/_vispy/visuals/vectors.py000066400000000000000000000003571437041365600215360ustar00rootroot00000000000000from vispy.scene.visuals import Mesh from napari._vispy.visuals.clipping_planes_mixin import ClippingPlanesMixin class VectorsVisual(ClippingPlanesMixin, Mesh): """ Vectors vispy visual with clipping plane functionality """ napari-0.5.0a1/napari/_vispy/visuals/volume.py000066400000000000000000000123721437041365600213600ustar00rootroot00000000000000from vispy.scene.visuals import Volume as BaseVolume FUNCTION_DEFINITIONS = """ // the tolerance for testing equality of floats with floatEqual and floatNotEqual const float equality_tolerance = 1e-8; bool floatNotEqual(float val1, float val2) { // check if val1 and val2 are not equal bool not_equal = abs(val1 - val2) > equality_tolerance; return not_equal; } bool floatEqual(float val1, float val2) { // check if val1 and val2 are equal bool equal = abs(val1 - val2) < equality_tolerance; return equal; } // the background value for the iso_categorical shader const float categorical_bg_value = 0; int detectAdjacentBackground(float val_neg, float val_pos) { // determine if the adjacent voxels along an axis are both background int adjacent_bg = int( floatEqual(val_neg, categorical_bg_value) ); adjacent_bg = adjacent_bg * int( floatEqual(val_pos, categorical_bg_value) ); return adjacent_bg; } vec4 calculateCategoricalColor(vec4 betterColor, vec3 loc, vec3 step) { // Calculate color by incorporating ambient and diffuse lighting vec4 color0 = $get_data(loc); vec4 color1; vec4 color2; float val0 = colorToVal(color0); float val1 = 0; float val2 = 0; int n_bg_borders = 0; // View direction vec3 V = normalize(view_ray); // calculate normal vector from gradient vec3 N; // normal color1 = $get_data(loc+vec3(-step[0],0.0,0.0)); color2 = $get_data(loc+vec3(step[0],0.0,0.0)); val1 = colorToVal(color1); val2 = colorToVal(color2); N[0] = val1 - val2; n_bg_borders += detectAdjacentBackground(val1, val2); color1 = $get_data(loc+vec3(0.0,-step[1],0.0)); color2 = $get_data(loc+vec3(0.0,step[1],0.0)); val1 = colorToVal(color1); val2 = colorToVal(color2); N[1] = val1 - val2; n_bg_borders += detectAdjacentBackground(val1, val2); color1 = $get_data(loc+vec3(0.0,0.0,-step[2])); color2 = $get_data(loc+vec3(0.0,0.0,step[2])); val1 = colorToVal(color1); val2 = colorToVal(color2); N[2] = val1 - val2; n_bg_borders += detectAdjacentBackground(val1, val2); // Normalize and flip normal so it points towards viewer N = normalize(N); float Nselect = float(dot(N,V) > 0.0); N = (2.0*Nselect - 1.0) * N; // == Nselect * N - (1.0-Nselect)*N; // Init colors vec4 ambient_color = vec4(0.0, 0.0, 0.0, 0.0); vec4 diffuse_color = vec4(0.0, 0.0, 0.0, 0.0); vec4 final_color; // todo: allow multiple light, define lights on viewvox or subscene int nlights = 1; for (int i=0; i 0.0 ); L = normalize(L+(1.0-lightEnabled)); // Calculate lighting properties float lambertTerm = clamp( dot(N,L), 0.0, 1.0 ); if (n_bg_borders > 0) { // to fix dim pixels due to poor normal estimation, // we give a default lambda to pixels surrounded by background lambertTerm = 0.5; } // Calculate mask float mask1 = lightEnabled; // Calculate colors ambient_color += mask1 * u_ambient; // * gl_LightSource[i].ambient; diffuse_color += mask1 * lambertTerm; } // Calculate final color by componing different components final_color = betterColor * ( ambient_color + diffuse_color); final_color.a = betterColor.a; // Done return final_color; } """ ISO_CATEGORICAL_SNIPPETS = dict( before_loop=""" vec4 color3 = vec4(0.0); // final color vec3 dstep = 1.5 / u_shape; // step to sample derivative, set to match iso shader gl_FragColor = vec4(0.0); bool discard_fragment = true; """, in_loop=""" // check if value is different from the background value if ( floatNotEqual(val, categorical_bg_value) ) { // Take the last interval in smaller steps vec3 iloc = loc - step; for (int i=0; i<10; i++) { color = $get_data(iloc); color = applyColormap(color.g); if (floatNotEqual(color.a, 0) ) { // when the value mapped to non-transparent color is reached // calculate the color (apply lighting effects) color = calculateCategoricalColor(color, iloc, dstep); gl_FragColor = color; // set the variables for the depth buffer frag_depth_point = iloc * u_shape; discard_fragment = false; iter = nsteps; break; } iloc += step * 0.1; } } """, after_loop=""" if (discard_fragment) discard; """, ) shaders = BaseVolume._shaders.copy() before, after = shaders['fragment'].split('void main()') shaders['fragment'] = before + FUNCTION_DEFINITIONS + 'void main()' + after rendering_methods = BaseVolume._rendering_methods.copy() rendering_methods['iso_categorical'] = ISO_CATEGORICAL_SNIPPETS class Volume(BaseVolume): # add the new rendering method to the snippets dict _shaders = shaders _rendering_methods = rendering_methods napari-0.5.0a1/napari/benchmarks/000077500000000000000000000000001437041365600166105ustar00rootroot00000000000000napari-0.5.0a1/napari/benchmarks/README.md000066400000000000000000000013511437041365600200670ustar00rootroot00000000000000# Napari benchmarking with airspeed velocity (asv) These are benchmarks to be run with airspeed velocity ([asv](https://asv.readthedocs.io/en/stable/)). They are not distributed with installs. ## Example commands Run all the benchmarks: `asv run` Do a "quick" run in the current environment, where each benchmark function is run only once: `asv run --python=same -q` To run a single benchmark (Vectors3DSuite.time_refresh) with the environment you are currently in: `asv dev --bench Vectors3DSuite.time_refresh` To compare benchmarks across branches, run using conda environments (instead of virtualenv), and limit to the `Labels2DSuite` benchmarks: `asv continuous main fix_benchmark_ci -q --environment conda --bench Labels2DSuite` napari-0.5.0a1/napari/benchmarks/__init__.py000066400000000000000000000000001437041365600207070ustar00rootroot00000000000000napari-0.5.0a1/napari/benchmarks/benchmark_image_layer.py000066400000000000000000000052551437041365600234610ustar00rootroot00000000000000# See "Writing benchmarks" in the asv docs for more information. # https://asv.readthedocs.io/en/latest/writing_benchmarks.html # or the napari documentation on benchmarking # https://github.com/napari/napari/blob/main/docs/BENCHMARKS.md import os import numpy as np from napari.layers import Image class Image2DSuite: """Benchmarks for the Image layer with 2D data.""" params = [2**i for i in range(4, 13)] def setup(self, n): np.random.seed(0) self.data = np.random.random((n, n)) self.new_data = np.random.random((n, n)) self.layer = Image(self.data) def time_create_layer(self, n): """Time to create an image layer.""" Image(self.data) def time_set_view_slice(self, n): """Time to set view slice.""" self.layer._set_view_slice() def time_update_thumbnail(self, n): """Time to update thumbnail.""" self.layer._update_thumbnail() def time_get_value(self, n): """Time to get current value.""" self.layer.get_value((0,) * 2) def time_set_data(self, n): """Time to get current value.""" self.layer.data = self.new_data def time_refresh(self, n): """Time to refresh view.""" self.layer.refresh() def mem_layer(self, n): """Memory used by layer.""" return self.layer def mem_data(self, n): """Memory used by raw data.""" return self.data class Image3DSuite: """Benchmarks for the Image layer with 3D data.""" params = [2**i for i in range(4, 11)] def setup(self, n): if "CI" in os.environ and n > 512: raise NotImplementedError("Skip on CI (not enough memory)") np.random.seed(0) self.data = np.random.random((n, n, n)) self.new_data = np.random.random((n, n, n)) self.layer = Image(self.data) def time_create_layer(self, n): """Time to create an image layer.""" Image(self.data) def time_set_view_slice(self, n): """Time to set view slice.""" self.layer._set_view_slice() def time_update_thumbnail(self, n): """Time to update thumbnail.""" self.layer._update_thumbnail() def time_get_value(self, n): """Time to get current value.""" self.layer.get_value((0,) * 3) def time_set_data(self, n): """Time to get current value.""" self.layer.data = self.new_data def time_refresh(self, n): """Time to refresh view.""" self.layer.refresh() def mem_layer(self, n): """Memory used by layer.""" return Image(self.data) def mem_data(self, n): """Memory used by raw data.""" return self.data napari-0.5.0a1/napari/benchmarks/benchmark_labels_layer.py000066400000000000000000000063741437041365600236440ustar00rootroot00000000000000# See "Writing benchmarks" in the asv docs for more information. # https://asv.readthedocs.io/en/latest/writing_benchmarks.html # or the napari documentation on benchmarking # https://github.com/napari/napari/blob/main/docs/BENCHMARKS.md import os import numpy as np from napari.layers import Labels class Labels2DSuite: """Benchmarks for the Labels layer with 2D data""" params = [2**i for i in range(4, 13)] def setup(self, n): np.random.seed(0) self.data = np.random.randint(20, size=(n, n)) self.layer = Labels(self.data) def time_create_layer(self, n): """Time to create layer.""" Labels(self.data) def time_set_view_slice(self, n): """Time to set view slice.""" self.layer._set_view_slice() def time_refresh(self, n): """Time to refresh view.""" self.layer.refresh() def time_update_thumbnail(self, n): """Time to update thumbnail.""" self.layer._update_thumbnail() def time_get_value(self, n): """Time to get current value.""" self.layer.get_value((0,) * 2) def time_raw_to_displayed(self, n): """Time to convert raw to displayed.""" self.layer._raw_to_displayed(self.layer._slice.image.raw) def time_paint_circle(self, n): """Time to paint circle.""" self.layer.paint((0,) * 2, self.layer.selected_label) def time_fill(self, n): """Time to fill.""" self.layer.fill( (0,) * 2, 1, self.layer.selected_label, ) def mem_layer(self, n): """Memory used by layer.""" return self.layer def mem_data(self, n): """Memory used by raw data.""" return self.data class Labels3DSuite: """Benchmarks for the Labels layer with 3D data.""" params = [2**i for i in range(4, 11)] def setup(self, n): if "CI" in os.environ and n > 512: raise NotImplementedError("Skip on CI (not enough memory)") np.random.seed(0) self.data = np.random.randint(20, size=(n, n, n)) self.layer = Labels(self.data) def time_create_layer(self, n): """Time to create layer.""" Labels(self.data) def time_set_view_slice(self, n): """Time to set view slice.""" self.layer._set_view_slice() def time_refresh(self, n): """Time to refresh view.""" self.layer.refresh() def time_update_thumbnail(self, n): """Time to update thumbnail.""" self.layer._update_thumbnail() def time_get_value(self, n): """Time to get current value.""" self.layer.get_value((0,) * 3) def time_raw_to_displayed(self, n): """Time to convert raw to displayed.""" self.layer._raw_to_displayed(self.layer._slice.image.raw) def time_paint_circle(self, n): """Time to paint circle.""" self.layer.paint((0,) * 3, self.layer.selected_label) def time_fill(self, n): """Time to fill.""" self.layer.fill( (0,) * 3, 1, self.layer.selected_label, ) def mem_layer(self, n): """Memory used by layer.""" return self.layer def mem_data(self, n): """Memory used by raw data.""" return self.data napari-0.5.0a1/napari/benchmarks/benchmark_points_layer.py000066400000000000000000000071361437041365600237130ustar00rootroot00000000000000# See "Writing benchmarks" in the asv docs for more information. # https://asv.readthedocs.io/en/latest/writing_benchmarks.html # or the napari documentation on benchmarking # https://github.com/napari/napari/blob/main/docs/BENCHMARKS.md import numpy as np from napari.layers import Points class Points2DSuite: """Benchmarks for the Points layer with 2D data""" params = [2**i for i in range(4, 18, 2)] def setup(self, n): np.random.seed(0) self.data = np.random.random((n, 2)) self.layer = Points(self.data) def time_create_layer(self, n): """Time to create layer.""" Points(self.data) def time_refresh(self, n): """Time to refresh view.""" self.layer.refresh() def time_set_view_slice(self, n): """Time to set view slice.""" self.layer._set_view_slice() def time_update_thumbnail(self, n): """Time to update thumbnail.""" self.layer._update_thumbnail() def time_get_value(self, n): """Time to get current value.""" self.layer.get_value((0,) * 2) def time_add(self, n): self.layer.add(self.data) def mem_layer(self, n): """Memory used by layer.""" return self.layer def mem_data(self, n): """Memory used by raw data.""" return self.data class Points3DSuite: """Benchmarks for the Points layer with 3D data.""" params = [2**i for i in range(4, 18, 2)] def setup(self, n): np.random.seed(0) self.data = np.random.random((n, 3)) self.layer = Points(self.data) def time_create_layer(self, n): """Time to create layer.""" Points(self.data) def time_refresh(self, n): """Time to refresh view.""" self.layer.refresh() def time_set_view_slice(self, n): """Time to set view slice.""" self.layer._set_view_slice() def time_update_thumbnail(self, n): """Time to update thumbnail.""" self.layer._update_thumbnail() def time_get_value(self, n): """Time to get current value.""" self.layer.get_value((0,) * 3) def mem_layer(self, n): """Memory used by layer.""" return self.layer def mem_data(self, n): """Memory used by raw data.""" return self.data class PointsSlicingSuite: """Benchmarks for slicing the Points layer with 3D data.""" params = [True, False] timeout = 300 def setup(self, flatten_slice_axis): np.random.seed(0) self.data = np.random.uniform(size=(20_000_000, 3), low=0, high=500) if flatten_slice_axis: self.data[:, 0] = np.round(self.data[:, 0]) self.layer = Points(self.data) self.slice = np.s_[249, :, :] def time_slice_points(self, flatten_slice_axis): """Time to take one slice of points""" self.layer._slice_data(self.slice) class PointsToMaskSuite: """Benchmarks for creating a binary image mask from points.""" param_names = ['num_points', 'mask_shape', 'point_size'] params = [ [64, 256, 1024, 4096, 16384], [ (256, 256), (512, 512), (1024, 1024), (2048, 2048), (128, 128, 128), (256, 256, 256), (512, 512, 512), ], [5, 10], ] def setup(self, num_points, mask_shape, point_size): np.random.seed(0) data = np.random.random((num_points, len(mask_shape))) * mask_shape self.layer = Points(data, size=point_size) def time_to_mask(self, num_points, mask_shape, point_size): self.layer.to_mask(shape=mask_shape) napari-0.5.0a1/napari/benchmarks/benchmark_qt_slicing.py000066400000000000000000000127051437041365600233350ustar00rootroot00000000000000# See "Writing benchmarks" in the asv docs for more information. # https://asv.readthedocs.io/en/latest/writing_benchmarks.html # or the napari documentation on benchmarking # https://github.com/napari/napari/blob/main/docs/BENCHMARKS.md import time import numpy as np import zarr from qtpy.QtWidgets import QApplication import napari from napari.layers import Image SAMPLE_PARAMS = { 'skin_data': { # napari-bio-sample-data 'shape': (1280, 960, 3), 'chunk_shape': (512, 512, 3), 'dtype': 'uint8', }, 'jrc_hela-2 (scale 3)': { # s3://janelia-cosem-datasets/jrc_hela-2/jrc_hela-2.n5 'shape': (796, 200, 1500), 'dtype': 'uint16', 'chunk_shape': (64, 64, 64), }, } def get_image_params(): # chunksizes = [(64,64,64), (256,256,256), (512,512,512)] latencies = [0.05 * i for i in range(0, 3)] datanames = SAMPLE_PARAMS.keys() params = (latencies, datanames) return params class SlowMemoryStore(zarr.storage.MemoryStore): def __init__(self, load_delay, *args, **kwargs) -> None: self.load_delay = load_delay super().__init__(*args, **kwargs) def __getitem__(self, item: str): time.sleep(self.load_delay) return super().__getitem__(item) class AsyncImage2DSuite: """TODO: these benchmarks are skipped. Remove the NotImplementedError in setup to enable. """ params = get_image_params() timeout = 300 def setup(self, latency, dataname): shape = SAMPLE_PARAMS[dataname]['shape'] chunk_shape = SAMPLE_PARAMS[dataname]['chunk_shape'] dtype = SAMPLE_PARAMS[dataname]['dtype'] store = SlowMemoryStore(load_delay=latency) self.data = zarr.zeros( shape, chunks=chunk_shape, dtype=dtype, store=store, ) self.layer = Image(self.data) raise NotImplementedError def time_create_layer(self, *args): """Time to create an image layer.""" Image(self.data) def time_set_view_slice(self, *args): """Time to set view slice.""" self.layer._set_view_slice() def time_refresh(self, *args): """Time to refresh view.""" self.layer.refresh() class QtViewerAsyncImage2DSuite: """TODO: these benchmarks are skipped. Remove the NotImplementedError in setup to enable. """ params = get_image_params() timeout = 300 def setup(self, latency, dataname): shape = SAMPLE_PARAMS[dataname]['shape'] chunk_shape = SAMPLE_PARAMS[dataname]['chunk_shape'] dtype = SAMPLE_PARAMS[dataname]['dtype'] if len(shape) == 3 and shape[2] == 3: # Skip 2D RGB tests -- scrolling does not apply self.viewer = None raise NotImplementedError store = SlowMemoryStore(load_delay=latency) _ = QApplication.instance() or QApplication([]) self.data = zarr.zeros( shape, chunks=chunk_shape, dtype=dtype, store=store, ) self.viewer = napari.Viewer() self.viewer.add_image(self.data) raise NotImplementedError def time_z_scroll(self, *args): layers_to_scroll = 4 for z in range(layers_to_scroll): z = z * (self.data.shape[2] // layers_to_scroll) self.viewer.dims.set_current_step(0, z) def teardown(self, *args): if self.viewer is not None: self.viewer.window.close() class QtViewerAsyncPointsSuite: """TODO: these benchmarks are skipped. Remove the NotImplementedError in setup to enable. """ n_points = [2**i for i in range(12, 18)] params = n_points def setup(self, n_points): _ = QApplication.instance() or QApplication([]) np.random.seed(0) self.viewer = napari.Viewer() # Fake image layer to set bounds. Is this really needed? self.empty_image = np.zeros((512, 512, 512), dtype="uint8") self.viewer.add_image(self.empty_image) self.point_data = np.random.randint(512, size=(n_points, 3)) self.viewer.add_points(self.point_data) raise NotImplementedError def time_z_scroll(self, *args): for z in range(self.empty_image.shape[0]): self.viewer.dims.set_current_step(0, z) def teardown(self, *args): self.viewer.window.close() class QtViewerAsyncPointsAndImage2DSuite: """TODO: these benchmarks are skipped. Remove the NotImplementedError in setup to enable. """ n_points = [2**i for i in range(12, 18, 2)] chunksize = [256, 512, 1024] latency = [0.05 * i for i in range(0, 3)] params = (n_points, latency, chunksize) timeout = 600 def setup(self, n_points, latency, chunksize): store = SlowMemoryStore(load_delay=latency) _ = QApplication.instance() or QApplication([]) np.random.seed(0) self.image_data = zarr.zeros( (64, 2048, 2048), chunks=(1, chunksize, chunksize), dtype='uint8', store=store, ) self.viewer = napari.Viewer() self.viewer.add_image(self.image_data) self.point_data = np.random.randint(512, size=(n_points, 3)) self.viewer.add_points(self.point_data) raise NotImplementedError def time_z_scroll(self, *args): for z in range(self.image_data.shape[0]): self.viewer.dims.set_current_step(0, z) def teardown(self, *args): self.viewer.window.close() napari-0.5.0a1/napari/benchmarks/benchmark_qt_viewer.py000066400000000000000000000010511437041365600231760ustar00rootroot00000000000000# See "Writing benchmarks" in the asv docs for more information. # https://asv.readthedocs.io/en/latest/writing_benchmarks.html # or the napari documentation on benchmarking # https://github.com/napari/napari/blob/main/docs/BENCHMARKS.md import napari class QtViewerSuite: """Benchmarks for viewing images in the viewer.""" def setup(self): self.viewer = None def teardown(self): self.viewer.window.close() def time_create_viewer(self): """Time to create the viewer.""" self.viewer = napari.Viewer() napari-0.5.0a1/napari/benchmarks/benchmark_qt_viewer_image.py000066400000000000000000000153771437041365600243600ustar00rootroot00000000000000# See "Writing benchmarks" in the asv docs for more information. # https://asv.readthedocs.io/en/latest/writing_benchmarks.html # or the napari documentation on benchmarking # https://github.com/napari/napari/blob/main/docs/BENCHMARKS.md import numpy as np from qtpy.QtWidgets import QApplication import napari class QtViewerViewImageSuite: """Benchmarks for viewing images in the viewer.""" params = [2**i for i in range(4, 13)] def setup(self, n): _ = QApplication.instance() or QApplication([]) np.random.seed(0) self.data = np.random.random((n, n)) self.viewer = None def teardown(self, n): self.viewer.window.close() def time_view_image(self, n): """Time to view an image.""" self.viewer = napari.view_image(self.data) class QtViewerAddImageSuite: """Benchmarks for adding images to the viewer.""" params = [2**i for i in range(4, 13)] def setup(self, n): _ = QApplication.instance() or QApplication([]) np.random.seed(0) self.data = np.random.random((n, n)) self.viewer = napari.Viewer() def teardown(self, n): self.viewer.window.close() def time_add_image(self, n): """Time to view an image.""" self.viewer.add_image(self.data) class QtViewerImageSuite: """Benchmarks for images in the viewer.""" params = [2**i for i in range(4, 13)] def setup(self, n): _ = QApplication.instance() or QApplication([]) np.random.seed(0) self.data = np.random.random((n, n)) self.viewer = napari.view_image(self.data) def teardown(self, n): self.viewer.window.close() def time_zoom(self, n): """Time to zoom in and zoom out.""" self.viewer.window._qt_viewer.view.camera.zoom(0.5, center=(0.5, 0.5)) self.viewer.window._qt_viewer.view.camera.zoom(2.0, center=(0.5, 0.5)) def time_refresh(self, n): """Time to refresh view.""" self.viewer.layers[0].refresh() def time_set_view_slice(self, n): """Time to set view slice.""" self.viewer.layers[0]._set_view_slice() def time_update_thumbnail(self, n): """Time to update thumbnail.""" self.viewer.layers[0]._update_thumbnail() def time_get_value(self, n): """Time to get current value.""" self.viewer.layers[0].get_value((0,) * 2) class QtViewerSingleImageSuite: """Benchmarks for a single image layer in the viewer.""" def setup(self): _ = QApplication.instance() or QApplication([]) np.random.seed(0) self.data = np.random.random((128, 128, 128)) self.new_data = np.random.random((128, 128, 128)) self.viewer = napari.view_image(self.data) def teardown(self): self.viewer.window.close() def time_zoom(self): """Time to zoom in and zoom out.""" self.viewer.window._qt_viewer.view.camera.zoom(0.5, center=(0.5, 0.5)) self.viewer.window._qt_viewer.view.camera.zoom(2.0, center=(0.5, 0.5)) def time_set_data(self): """Time to set view slice.""" self.viewer.layers[0].data = self.new_data def time_refresh(self): """Time to refresh view.""" self.viewer.layers[0].refresh() def time_set_view_slice(self): """Time to set view slice.""" self.viewer.layers[0]._set_view_slice() def time_update_thumbnail(self): """Time to update thumbnail.""" self.viewer.layers[0]._update_thumbnail() def time_get_value(self): """Time to get current value.""" self.viewer.layers[0].get_value((0,) * 3) def time_ndisplay(self): """Time to enter 3D rendering.""" self.viewer.dims.ndisplay = 3 class QtViewerSingleInvisbleImageSuite: """Benchmarks for a invisible single image layer in the viewer.""" def setup(self): _ = QApplication.instance() or QApplication([]) np.random.seed(0) self.data = np.random.random((128, 128, 128)) self.new_data = np.random.random((128, 128, 128)) self.viewer = napari.view_image(self.data, visible=False) def teardown(self): self.viewer.window.close() def time_zoom(self): """Time to zoom in and zoom out.""" self.viewer.window._qt_viewer.view.camera.zoom(0.5, center=(0.5, 0.5)) self.viewer.window._qt_viewer.view.camera.zoom(2.0, center=(0.5, 0.5)) def time_set_data(self): """Time to set view slice.""" self.viewer.layers[0].data = self.new_data def time_refresh(self): """Time to refresh view.""" self.viewer.layers[0].refresh() def time_set_view_slice(self): """Time to set view slice.""" self.viewer.layers[0]._set_view_slice() def time_update_thumbnail(self): """Time to update thumbnail.""" self.viewer.layers[0]._update_thumbnail() def time_get_value(self): """Time to get current value.""" self.viewer.layers[0].get_value((0,) * 3) def time_ndisplay(self): """Time to enter 3D rendering.""" self.viewer.dims.ndisplay = 3 class QtImageRenderingSuite: """Benchmarks for a single image layer in the viewer.""" params = [2**i for i in range(4, 13)] def setup(self, n): _ = QApplication.instance() or QApplication([]) np.random.seed(0) self.data = np.random.random((n, n)) * 2**12 self.viewer = napari.view_image(self.data, ndisplay=2) def teardown(self, n): self.viewer.close() def time_change_contrast(self, n): """Time to change contrast limits.""" self.viewer.layers[0].contrast_limits = (250, 3000) self.viewer.layers[0].contrast_limits = (300, 2900) self.viewer.layers[0].contrast_limits = (350, 2800) def time_change_gamma(self, n): """Time to change gamma.""" self.viewer.layers[0].gamma = 0.5 self.viewer.layers[0].gamma = 0.8 self.viewer.layers[0].gamma = 1.3 class QtVolumeRenderingSuite: """Benchmarks for a single image layer in the viewer.""" params = [2**i for i in range(4, 10)] def setup(self, n): _ = QApplication.instance() or QApplication([]) np.random.seed(0) self.data = np.random.random((n, n, n)) * 2**12 self.viewer = napari.view_image(self.data, ndisplay=3) def teardown(self, n): self.viewer.close() def time_change_contrast(self, n): """Time to change contrast limits.""" self.viewer.layers[0].contrast_limits = (250, 3000) self.viewer.layers[0].contrast_limits = (300, 2900) self.viewer.layers[0].contrast_limits = (350, 2800) def time_change_gamma(self, n): """Time to change gamma.""" self.viewer.layers[0].gamma = 0.5 self.viewer.layers[0].gamma = 0.8 self.viewer.layers[0].gamma = 1.3 napari-0.5.0a1/napari/benchmarks/benchmark_qt_viewer_labels.py000066400000000000000000000047111437041365600245260ustar00rootroot00000000000000# See "Writing benchmarks" in the asv docs for more information. # https://asv.readthedocs.io/en/latest/writing_benchmarks.html # or the napari documentation on benchmarking # https://github.com/napari/napari/blob/main/docs/BENCHMARKS.md from dataclasses import dataclass from typing import List import numpy as np from qtpy.QtWidgets import QApplication import napari @dataclass class MouseEvent: # mock mouse event class type: str is_dragging: bool pos: List[int] view_direction: List[int] class QtViewerSingleLabelsSuite: """Benchmarks for editing a single labels layer in the viewer.""" def setup(self): _ = QApplication.instance() or QApplication([]) np.random.seed(0) self.data = np.random.randint(10, size=(512, 512)) self.viewer = napari.view_labels(self.data) self.layer = self.viewer.layers[0] self.layer.brush_size = 10 self.layer.mode = 'paint' self.layer.selected_label = 3 self.layer._last_cursor_coord = (511, 511) self.event = MouseEvent( type='mouse_move', is_dragging=True, pos=(500, 500), view_direction=None, ) def teardown(self): self.viewer.window.close() def time_zoom(self): """Time to zoom in and zoom out.""" self.viewer.window._qt_viewer.view.camera.zoom(0.5, center=(0.5, 0.5)) self.viewer.window._qt_viewer.view.camera.zoom(2.0, center=(0.5, 0.5)) def time_set_view_slice(self): """Time to set view slice.""" self.layer._set_view_slice() def time_refresh(self): """Time to refresh view.""" self.layer.refresh() def time_update_thumbnail(self): """Time to update thumbnail.""" self.layer._update_thumbnail() def time_get_value(self): """Time to get current value.""" self.layer.get_value((0,) * 2) def time_raw_to_displayed(self): """Time to convert raw to displayed.""" self.layer._raw_to_displayed(self.layer._slice.image.raw) def time_paint(self): """Time to paint.""" self.layer.paint((0,) * 2, self.layer.selected_label) def time_fill(self): """Time to fill.""" self.layer.fill( (0,) * 2, 1, self.layer.selected_label, ) def time_on_mouse_move(self): """Time to drag paint on mouse move.""" self.viewer.window._qt_viewer.on_mouse_move(self.event) napari-0.5.0a1/napari/benchmarks/benchmark_qt_viewer_vectors.py000066400000000000000000000022341437041365600247470ustar00rootroot00000000000000# See "Writing benchmarks" in the asv docs for more information. # https://asv.readthedocs.io/en/latest/writing_benchmarks.html # or the napari documentation on benchmarking # https://github.com/napari/napari/blob/main/docs/BENCHMARKS.md import numpy as np from qtpy.QtWidgets import QApplication import napari class QtViewerViewVectorSuite: """Benchmarks for viewing vectors in the viewer.""" params = [2**i for i in range(4, 18, 2)] def setup(self, n): _ = QApplication.instance() or QApplication([]) np.random.seed(0) self.data = np.random.random((n, 2, 3)) self.viewer = napari.Viewer() self.layer = self.viewer.add_vectors(self.data) self.visual = self.viewer.window._qt_viewer.layer_to_visual[self.layer] def teardown(self, n): self.viewer.window.close() def time_vectors_refresh(self, n): """Time to refresh a vector.""" self.viewer.layers[0].refresh() def time_vectors_multi_refresh(self, n): """Time to refresh a vector multiple times.""" self.viewer.layers[0].refresh() self.viewer.layers[0].refresh() self.viewer.layers[0].refresh() napari-0.5.0a1/napari/benchmarks/benchmark_shapes_layer.py000066400000000000000000000142721437041365600236610ustar00rootroot00000000000000# See "Writing benchmarks" in the asv docs for more information. # https://asv.readthedocs.io/en/latest/writing_benchmarks.html # or the napari documentation on benchmarking # https://github.com/napari/napari/blob/main/docs/BENCHMARKS.md import collections import numpy as np from napari.layers import Shapes from napari.utils._proxies import ReadOnlyWrapper from napari.utils.interactions import ( mouse_move_callbacks, mouse_press_callbacks, mouse_release_callbacks, ) Event = collections.namedtuple( 'Event', field_names=['type', 'is_dragging', 'modifiers', 'position'] ) class Shapes2DSuite: """Benchmarks for the Shapes layer with 2D data""" params = [2**i for i in range(4, 9)] def setup(self, n): np.random.seed(0) self.data = [50 * np.random.random((6, 2)) for i in range(n)] self.layer = Shapes(self.data, shape_type='polygon') def time_create_layer(self, n): """Time to create an image layer.""" Shapes(self.data, shape_type='polygon') def time_refresh(self, n): """Time to refresh view.""" self.layer.refresh() def time_set_view_slice(self, n): """Time to set view slice.""" self.layer._set_view_slice() def time_update_thumbnail(self, n): """Time to update thumbnail.""" self.layer._update_thumbnail() def time_get_value(self, n): """Time to get current value.""" self.layer.get_value((0,) * 2) def mem_layer(self, n): """Memory used by layer.""" return self.layer def mem_data(self, n): """Memory used by raw data.""" return self.data class Shapes3DSuite: """Benchmarks for the Shapes layer with 3D data.""" params = [2**i for i in range(4, 9)] def setup(self, n): np.random.seed(0) self.data = [50 * np.random.random((6, 3)) for i in range(n)] self.layer = Shapes(self.data, shape_type='polygon') def time_create_layer(self, n): """Time to create a layer.""" Shapes(self.data, shape_type='polygon') def time_refresh(self, n): """Time to refresh view.""" self.layer.refresh() def time_set_view_slice(self, n): """Time to set view slice.""" self.layer._set_view_slice() def time_update_thumbnail(self, n): """Time to update thumbnail.""" self.layer._update_thumbnail() def time_get_value(self, n): """Time to get current value.""" self.layer.get_value((0,) * 3) def mem_layer(self, n): """Memory used by layer.""" return self.layer def mem_data(self, n): """Memory used by raw data.""" return self.data class ShapesInteractionSuite: """Benchmarks for interacting with the Shapes layer with 2D data""" params = [2**i for i in range(4, 9)] def setup(self, n): np.random.seed(0) self.data = [50 * np.random.random((6, 2)) for i in range(n)] self.layer = Shapes(self.data, shape_type='polygon') self.layer.mode = 'select' # initialize the position and select a shape position = tuple(np.mean(self.layer.data[0], axis=0)) # create events click_event = ReadOnlyWrapper( Event( type='mouse_press', is_dragging=False, modifiers=[], position=position, ) ) # Simulate click mouse_press_callbacks(self.layer, click_event) release_event = ReadOnlyWrapper( Event( type='mouse_release', is_dragging=False, modifiers=[], position=position, ) ) # Simulate release mouse_release_callbacks(self.layer, release_event) def time_drag_shape(self, n): """Time to process 5 shape drag events""" # initialize the position and select a shape position = tuple(np.mean(self.layer.data[0], axis=0)) # create events click_event = ReadOnlyWrapper( Event( type='mouse_press', is_dragging=False, modifiers=[], position=position, ) ) # Simulate click mouse_press_callbacks(self.layer, click_event) # create events drag_event = ReadOnlyWrapper( Event( type='mouse_press', is_dragging=True, modifiers=[], position=position, ) ) # start drag event mouse_move_callbacks(self.layer, drag_event) # simulate 5 drag events for _ in range(5): position = tuple(np.add(position, [10, 5])) drag_event = ReadOnlyWrapper( Event( type='mouse_press', is_dragging=True, modifiers=[], position=position, ) ) # Simulate move, click, and release mouse_move_callbacks(self.layer, drag_event) release_event = ReadOnlyWrapper( Event( type='mouse_release', is_dragging=False, modifiers=[], position=position, ) ) # Simulate release mouse_release_callbacks(self.layer, release_event) time_drag_shape.param_names = ['n_shapes'] def time_select_shape(self, n): """Time to process shape selection events""" position = tuple(np.mean(self.layer.data[1], axis=0)) # create events click_event = ReadOnlyWrapper( Event( type='mouse_press', is_dragging=False, modifiers=[], position=position, ) ) # Simulate click mouse_press_callbacks(self.layer, click_event) release_event = ReadOnlyWrapper( Event( type='mouse_release', is_dragging=False, modifiers=[], position=position, ) ) # Simulate release mouse_release_callbacks(self.layer, release_event) time_select_shape.param_names = ['n_shapes'] napari-0.5.0a1/napari/benchmarks/benchmark_surface_layer.py000066400000000000000000000046761437041365600240350ustar00rootroot00000000000000# See "Writing benchmarks" in the asv docs for more information. # https://asv.readthedocs.io/en/latest/writing_benchmarks.html # or the napari documentation on benchmarking # https://github.com/napari/napari/blob/main/docs/BENCHMARKS.md import numpy as np from napari.layers import Surface class Surface2DSuite: """Benchmarks for the Surface layer with 2D data""" params = [2**i for i in range(4, 18, 2)] def setup(self, n): np.random.seed(0) self.data = ( np.random.random((n, 2)), np.random.randint(n, size=(n, 3)), np.random.random(n), ) self.layer = Surface(self.data) def time_create_layer(self, n): """Time to create an image layer.""" Surface(self.data) def time_refresh(self, n): """Time to refresh view.""" self.layer.refresh() def time_set_view_slice(self, n): """Time to set view slice.""" self.layer._set_view_slice() def time_update_thumbnail(self, n): """Time to update thumbnail.""" self.layer._update_thumbnail() def time_get_value(self, n): """Time to get current value.""" self.layer.get_value((0,) * 2) def mem_layer(self, n): """Memory used by layer.""" return self.layer def mem_data(self, n): """Memory used by raw data.""" return self.data class Surface3DSuite: """Benchmarks for the Surface layer with 3D data.""" params = [2**i for i in range(4, 18, 2)] def setup(self, n): np.random.seed(0) self.data = ( np.random.random((n, 3)), np.random.randint(n, size=(n, 3)), np.random.random(n), ) self.layer = Surface(self.data) def time_create_layer(self, n): """Time to create a layer.""" Surface(self.data) def time_refresh(self, n): """Time to refresh view.""" self.layer.refresh() def time_set_view_slice(self, n): """Time to set view slice.""" self.layer._set_view_slice() def time_update_thumbnail(self, n): """Time to update thumbnail.""" self.layer._update_thumbnail() def time_get_value(self, n): """Time to get current value.""" self.layer.get_value((0,) * 3) def mem_layer(self, n): """Memory used by layer.""" return self.layer def mem_data(self, n): """Memory used by raw data.""" return self.data napari-0.5.0a1/napari/benchmarks/benchmark_text_manager.py000066400000000000000000000044151437041365600236560ustar00rootroot00000000000000# See "Writing benchmarks" in the asv docs for more information. # https://asv.readthedocs.io/en/latest/writing_benchmarks.html # or the napari documentation on benchmarking # https://napari.org/developers/benchmarks.html import numpy as np import pandas as pd from napari.layers.utils.text_manager import TextManager class TextManagerSuite: """Benchmarks for creating and modifying a text manager.""" param_names = ['n', 'string'] params = [ [2**i for i in range(4, 18, 2)], [ {'constant': 'test'}, 'string_property', 'float_property', '{string_property}: {float_property:.2f}', ], ] def setup(self, n, string): np.random.seed(0) categories = ('cat', 'car') self.features = pd.DataFrame( { 'string_property': pd.Series( np.random.choice(categories, n), dtype=pd.CategoricalDtype(categories), ), 'float_property': np.random.rand(n), } ) self.current_properties = self.features.iloc[[-1]].to_dict('list') self.manager = TextManager(string=string, features=self.features) self.indices_to_remove = list(range(0, n, 2)) def time_create(self, n, string): TextManager(string=string, features=self.features) def time_refresh(self, n, string): self.manager.refresh_text(self.features) def time_add_iteratively(self, n, string): for _ in range(512): self.manager.add(self.current_properties, 1) def time_remove_as_batch(self, n, string): self.manager.remove(self.indices_to_remove) # `time_remove_as_batch` can only run once per instance; # otherwise it fails because the indices were already removed: # # IndexError: index 32768 is out of bounds for axis 0 with size 32768 # # Why? ASV will run the same function after setup several times in two # occasions: warmup and timing itself. We disable warmup and only # allow one execution per state with these method-specific options: time_remove_as_batch.number = 1 time_remove_as_batch.warmup_time = 0 # See https://asv.readthedocs.io/en/stable/benchmarks.html#timing-benchmarks # for more details napari-0.5.0a1/napari/benchmarks/benchmark_tracks_layer.py000066400000000000000000000017261437041365600236650ustar00rootroot00000000000000import numpy as np from napari.layers import Tracks class TracksSuite: param_names = ['size', 'n_tracks'] params = [(5 * np.power(10, np.arange(7))).tolist(), [1, 10, 100, 1000]] def setup(self, size, n_tracks): """ Create tracks data """ if 5 * n_tracks > size: # not useful, tracks to short or larger than size raise NotImplementedError rng = np.random.default_rng(0) track_ids = rng.integers(n_tracks, size=size) time = np.zeros(len(track_ids)) for value, counts in zip(*np.unique(track_ids, return_counts=True)): t = rng.permutation(counts) time[track_ids == value] = t coordinates = rng.uniform(size=(size, 3)) data = np.concatenate( (track_ids[:, None], time[:, None], coordinates), axis=1, ) self.data = data def time_create_layer(self, *args) -> None: Tracks(self.data) napari-0.5.0a1/napari/benchmarks/benchmark_vectors_layer.py000066400000000000000000000051641437041365600240630ustar00rootroot00000000000000# See "Writing benchmarks" in the asv docs for more information. # https://asv.readthedocs.io/en/latest/writing_benchmarks.html # or the napari documentation on benchmarking # https://github.com/napari/napari/blob/main/docs/BENCHMARKS.md import numpy as np from napari.layers import Vectors class Vectors2DSuite: """Benchmarks for the Vectors layer with 2D data""" params = [2**i for i in range(4, 18, 2)] def setup(self, n): np.random.seed(0) self.data = np.random.random((n, 2, 2)) self.layer = Vectors(self.data) def time_create_layer(self, n): """Time to create an image layer.""" Vectors(self.data) def time_refresh(self, n): """Time to refresh view.""" self.layer.refresh() def time_set_view_slice(self, n): """Time to set view slice.""" self.layer._set_view_slice() def time_update_thumbnail(self, n): """Time to update thumbnail.""" self.layer._update_thumbnail() def time_get_value(self, n): """Time to get current value.""" self.layer.get_value((0,) * 2) def time_width(self, n): """Time to update width.""" self.layer.width = 2 def time_length(self, n): """Time to update length.""" self.layer.length = 2 def mem_layer(self, n): """Memory used by layer.""" return self.layer def mem_data(self, n): """Memory used by raw data.""" return self.data class Vectors3DSuite: """Benchmarks for the Vectors layer with 3D data.""" params = [2**i for i in range(4, 18, 2)] def setup(self, n): np.random.seed(0) self.data = np.random.random((n, 2, 3)) self.layer = Vectors(self.data) def time_create_layer(self, n): """Time to create a layer.""" Vectors(self.data) def time_refresh(self, n): """Time to refresh view.""" self.layer.refresh() def time_set_view_slice(self, n): """Time to set view slice.""" self.layer._set_view_slice() def time_update_thumbnail(self, n): """Time to update thumbnail.""" self.layer._update_thumbnail() def time_get_value(self, n): """Time to get current value.""" self.layer.get_value((0,) * 3) def time_width(self, n): """Time to update width.""" self.layer.width = 2 def time_length(self, n): """Time to update length.""" self.layer.length = 2 def mem_layer(self, n): """Memory used by layer.""" return self.layer def mem_data(self, n): """Memory used by raw data.""" return self.data napari-0.5.0a1/napari/components/000077500000000000000000000000001437041365600166605ustar00rootroot00000000000000napari-0.5.0a1/napari/components/__init__.py000066400000000000000000000017561437041365600210020ustar00rootroot00000000000000"""napari.components provides the public-facing models for widgets and other utilities that the user will be able to programmatically interact with. Classes ------- Dims Current indices along each data dimension, together with which dimensions are being displayed, projected, sliced... LayerList List of layers currently present in the viewer. ViewerModel Data viewer displaying the currently rendered scene and layer-related controls. """ from napari.components.camera import Camera from napari.components.dims import Dims from napari.components.layerlist import LayerList # Note that importing _viewer_key_bindings is needed as the Viewer gets # decorated with keybindings during that process, but it is not directly needed # by our users and so is deleted below from napari.components import _viewer_key_bindings # isort:skip from napari.components.viewer_model import ViewerModel # isort:skip del _viewer_key_bindings __all__ = ['Camera', 'Dims', 'LayerList', 'ViewerModel'] napari-0.5.0a1/napari/components/_layer_slicer.py000066400000000000000000000224231437041365600220510ustar00rootroot00000000000000from __future__ import annotations import logging from concurrent.futures import Executor, Future, ThreadPoolExecutor, wait from contextlib import contextmanager from threading import RLock from typing import ( Callable, Dict, Iterable, Optional, Protocol, Tuple, TypeVar, runtime_checkable, ) from napari.components import Dims from napari.layers import Layer from napari.utils.events.event import EmitterGroup, Event logger = logging.getLogger("napari.components._layer_slicer") # Layers that can be asynchronously sliced must be able to make # a slice request that can be called and will produce a slice # response. The request and response types will vary per layer # type, which means that the values of the dictionary result of # ``_slice_layers`` cannot be fixed to a single type. _SliceResponse = TypeVar('_SliceResponse') _SliceRequest = Callable[[], _SliceResponse] @runtime_checkable class _AsyncSliceable(Protocol[_SliceResponse]): def _make_slice_request(self, dims: Dims) -> _SliceRequest[_SliceResponse]: ... def _update_slice_response(self, response: _SliceResponse) -> None: ... class _LayerSlicer: """ High level class to control the creation of a slice (via a slice request), submit it (synchronously or asynchronously) to a thread pool, and emit the results when complete. Events ------ ready emitted after slicing is done with a dict value that maps from layer to slice response. Note that this may be emitted on the main or a non-main thread. If usage of this event relies on something happening on the main thread, actions should be taken to ensure that the callback is also executed on the main thread (e.g. by decorating the callback with `@ensure_main_thread`). """ def __init__(self) -> None: """ Attributes ---------- _executor : concurrent.futures.ThreadPoolExecutor manager for the slicing threading _force_sync: bool if true, forces slicing to execute synchronously _layers_to_task : dict task storage for cancellation logic _lock_layers_to_task : threading.RLock lock to guard against changes to `_layers_to_task` when finding, adding, or removing tasks. """ self.events = EmitterGroup(source=self, ready=Event) self._executor: Executor = ThreadPoolExecutor(max_workers=1) self._force_sync = False self._layers_to_task: Dict[Tuple[Layer], Future] = {} self._lock_layers_to_task = RLock() @contextmanager def force_sync(self): """Context manager to temporarily force slicing to be synchronous. This should only be used from the main thread. >>> layer_slicer = _LayerSlicer() >>> layer = Image(...) # an async-ready layer >>> with layer_slice.force_sync(): >>> layer_slicer.submit(layers=[layer], dims=Dims()) """ prev = self._force_sync self._force_sync = True try: yield None finally: self._force_sync = prev def wait_until_idle(self, timeout: Optional[float] = None) -> None: """Wait for all slicing tasks to complete before returning. Attributes ---------- timeout: float or None (Optional) time in seconds to wait before raising TimeoutError. If set as None, there is no limit to the wait time. Defaults to None Raises ------ TimeoutError: when the timeout limit has been exceeded and the task is not yet complete """ futures = self._layers_to_task.values() _, not_done_futures = wait(futures, timeout=timeout) if len(not_done_futures) > 0: raise TimeoutError( f'Slicing {len(not_done_futures)} tasks did not complete within timeout ({timeout}s).' ) def submit( self, *, layers: Iterable[Layer], dims: Dims ) -> Optional[Future[dict]]: """Slices the given layers with the given dims. Submitting multiple layers at one generates multiple requests, but only ONE task. This will attempt to cancel all pending slicing tasks that can be entirely replaced the new ones. If multiple layers are sliced, any task that contains only one of those layers can safely be cancelled. If a single layer is sliced, it will wait for any existing tasks that include that layer AND another layer, In other words, it will only cancel if the new task will replace the slices of all the layers in the pending task. This should only be called from the main thread. Parameters ---------- layers: iterable of layers The layers to slice. dims: Dims The dimensions values associated with the view to be sliced. Returns ------- future of dict or none A future with a result that maps from a layer to an async layer slice response. Or none if no async slicing tasks were submitted. """ if existing_task := self._find_existing_task(layers): logger.debug('Cancelling task for %s', layers) existing_task.cancel() # Not all layer types will initially be asynchronously sliceable. # The following logic gives us a way to handle those in the short # term as we develop, and also in the long term if there are cases # when we want to perform sync slicing anyway. requests = {} sync_layers = [] for layer in layers: if isinstance(layer, _AsyncSliceable) and not self._force_sync: requests[layer] = layer._make_slice_request(dims) else: sync_layers.append(layer) # First maybe submit an async slicing task to start it ASAP. task = None if len(requests) > 0: task = self._executor.submit(self._slice_layers, requests) # Store task before adding done callback to ensure there is always # a task to remove in the done callback. with self._lock_layers_to_task: self._layers_to_task[tuple(requests)] = task task.add_done_callback(self._on_slice_done) # Then execute sync slicing tasks to run concurrent with async ones. for layer in sync_layers: layer._slice_dims(dims.point, dims.ndisplay, dims.order) return task def shutdown(self) -> None: """Shuts this down, preventing any new slice tasks from being submitted. This should only be called from the main thread. """ # Replace with cancel_futures=True in shutdown when we drop support # for Python 3.8 with self._lock_layers_to_task: tasks = tuple(self._layers_to_task.values()) for task in tasks: task.cancel() self._executor.shutdown(wait=True) def _slice_layers(self, requests: Dict) -> Dict: """ Iterates through a dictionary of request objects and call the slice on each individual layer. Can be called from the main or slicing thread. Attributes ---------- requests: dict[Layer, SliceRequest] Dictionary of request objects to be used for constructing the slice Returns ------- dict[Layer, SliceResponse]: which contains the results of the slice """ return {layer: request() for layer, request in requests.items()} def _on_slice_done(self, task: Future[Dict]) -> None: """ This is the "done_callback" which is added to each task. Can be called from the main or slicing thread. """ if not self._try_to_remove_task(task): logger.debug('Task not found') return if task.cancelled(): logger.debug('Cancelled task') return result = task.result() self.events.ready(Event('ready', value=result)) def _try_to_remove_task(self, task: Future[Dict]) -> bool: """ Attempt to remove task, return false if task not found, return true if task is found and removed from layers_to_task dict. This function provides a lock to ensure that the layers_to_task dict is unmodified during this process. """ with self._lock_layers_to_task: for k_layers, v_task in self._layers_to_task.items(): if v_task == task: del self._layers_to_task[k_layers] return True return False def _find_existing_task( self, layers: Iterable[Layer] ) -> Optional[Future[Dict]]: """Find the task associated with a list of layers. Returns the first task found for which the layers of the task are a subset of the input layers. This function provides a lock to ensure that the layers_to_task dict is unmodified during this process. """ with self._lock_layers_to_task: layer_set = set(layers) for task_layers, task in self._layers_to_task.items(): if set(task_layers).issubset(layer_set): logger.debug('Found existing task for %s', task_layers) return task return None napari-0.5.0a1/napari/components/_tests/000077500000000000000000000000001437041365600201615ustar00rootroot00000000000000napari-0.5.0a1/napari/components/_tests/test_add_layers.py000066400000000000000000000071521437041365600237060ustar00rootroot00000000000000from unittest.mock import MagicMock, patch import numpy as np import pytest from napari_plugin_engine import HookImplementation from napari._tests.utils import layer_test_data from napari.components.viewer_model import ViewerModel from napari.layers._source import Source img = np.random.rand(10, 10) layer_data = [(lay[1], {}, lay[0].__name__.lower()) for lay in layer_test_data] def _impl(path): """just a dummy Hookimpl object to return from mocks""" pass _testimpl = HookImplementation(_impl, plugin_name='testimpl') @pytest.mark.parametrize("layer_datum", layer_data) def test_add_layers_with_plugins(layer_datum): """Test that add_layers_with_plugins adds the expected layer types.""" with patch( "napari.plugins.io.read_data_with_plugins", MagicMock(return_value=([layer_datum], _testimpl)), ): v = ViewerModel() v._add_layers_with_plugins(['mock_path'], stack=False) layertypes = [layer._type_string for layer in v.layers] assert layertypes == [layer_datum[2]] expected_source = Source(path='mock_path', reader_plugin='testimpl') assert all(lay.source == expected_source for lay in v.layers) @patch( "napari.plugins.io.read_data_with_plugins", MagicMock(return_value=([], _testimpl)), ) def test_plugin_returns_nothing(): """Test that a plugin returning nothing adds nothing to the Viewer.""" v = ViewerModel() v._add_layers_with_plugins(['mock_path'], stack=False) assert not v.layers @patch( "napari.plugins.io.read_data_with_plugins", MagicMock(return_value=([(img,)], _testimpl)), ) def test_viewer_open(): """Test that a plugin to returning an image adds stuff to the viewer.""" viewer = ViewerModel() assert len(viewer.layers) == 0 viewer.open('mock_path.tif') assert len(viewer.layers) == 1 # The name should be taken from the path name, stripped of extension assert viewer.layers[0].name == 'mock_path' # stack=True also works... and very long names are truncated viewer.open('mock_path.tif', stack=True) assert len(viewer.layers) == 2 assert viewer.layers[1].name.startswith('mock_path') expected_source = Source(path='mock_path.tif', reader_plugin='testimpl') assert all(lay.source == expected_source for lay in viewer.layers) def test_viewer_open_no_plugin(tmp_path): viewer = ViewerModel() fname = tmp_path / 'gibberish.gbrsh' fname.touch() with pytest.raises(ValueError, match='No plugin found capable of reading'): # will default to builtins viewer.open(fname) plugin_returns = [ ([(img, {'name': 'foo'})], {'name': 'bar'}), ([(img, {'blending': 'additive'}), (img,)], {'blending': 'translucent'}), ] @pytest.mark.parametrize("layer_data, kwargs", plugin_returns) def test_add_layers_with_plugins_and_kwargs(layer_data, kwargs): """Test that _add_layers_with_plugins kwargs override plugin kwargs. see also: napari.components._test.test_prune_kwargs """ with patch( "napari.plugins.io.read_data_with_plugins", MagicMock(return_value=(layer_data, _testimpl)), ): v = ViewerModel() v._add_layers_with_plugins(['mock_path'], kwargs=kwargs, stack=False) expected_source = Source(path='mock_path', reader_plugin='testimpl') for layer in v.layers: for key, val in kwargs.items(): assert getattr(layer, key) == val # if plugins don't provide "name", it falls back to path name if 'name' not in kwargs: assert layer.name.startswith('mock_path') assert layer.source == expected_source napari-0.5.0a1/napari/components/_tests/test_axes.py000066400000000000000000000002441437041365600225320ustar00rootroot00000000000000from napari.components.overlays.axes import AxesOverlay def test_axes(): """Test creating axes object""" axes = AxesOverlay() assert axes is not None napari-0.5.0a1/napari/components/_tests/test_camera.py000066400000000000000000000064731437041365600230340ustar00rootroot00000000000000import numpy as np from napari.components import Camera def test_camera(): """Test camera.""" camera = Camera() assert camera.center == (0, 0, 0) assert camera.zoom == 1 assert camera.angles == (0, 0, 90) center = (10, 20, 30) camera.center = center assert camera.center == center assert camera.angles == (0, 0, 90) zoom = 200 camera.zoom = zoom assert camera.zoom == zoom angles = (20, 90, 45) camera.angles = angles assert camera.angles == angles def test_calculate_view_direction_3d(): """Check that view direction is calculated properly from camera angles.""" # simple case camera = Camera(center=(0, 0, 0), angles=(90, 0, 0), zoom=1) assert np.allclose(camera.view_direction, (0, 1, 0)) # shouldn't change with zoom camera = Camera(center=(0, 0, 0), angles=(90, 0, 0), zoom=10) assert np.allclose(camera.view_direction, (0, 1, 0)) # shouldn't change with center camera = Camera(center=(15, 15, 15), angles=(90, 0, 0), zoom=1) assert np.allclose(camera.view_direction, (0, 1, 0)) def test_calculate_up_direction_3d(): """Check that up direction is calculated properly from camera angles.""" # simple case camera = Camera(center=(0, 0, 0), angles=(0, 0, 90), zoom=1) assert np.allclose(camera.up_direction, (0, -1, 0)) # shouldn't change with zoom camera = Camera(center=(0, 0, 0), angles=(0, 0, 90), zoom=10) assert np.allclose(camera.up_direction, (0, -1, 0)) # shouldn't change with center camera = Camera(center=(15, 15, 15), angles=(0, 0, 90), zoom=1) assert np.allclose(camera.up_direction, (0, -1, 0)) # more complex case with order dependent Euler angles camera = Camera(center=(0, 0, 0), angles=(10, 20, 30), zoom=1) assert np.allclose(camera.up_direction, (0.88, -0.44, 0.16), atol=0.01) def test_set_view_direction_3d(): """Check that view direction can be set properly.""" # simple case camera = Camera(center=(0, 0, 0), angles=(0, 0, 0), zoom=1) camera.set_view_direction(view_direction=(1, 0, 0)) assert np.allclose(camera.view_direction, (1, 0, 0)) assert np.allclose(camera.angles, (0, 0, 90)) # case with ordering and up direction setting view_direction = np.array([1, 2, 3], dtype=float) view_direction /= np.linalg.norm(view_direction) camera.set_view_direction(view_direction=view_direction) assert np.allclose(camera.view_direction, view_direction) assert np.allclose(camera.angles, (58.1, -53.3, 26.6), atol=0.1) def test_calculate_view_direction_nd(): """Check that nD view direction is calculated properly.""" camera = Camera(center=(0, 0, 0), angles=(90, 0, 0), zoom=1) # should return none if ndim == 2 view_direction = camera.calculate_nd_view_direction( ndim=2, dims_displayed=[0, 1] ) assert view_direction is None # should return 3d if ndim == 3 view_direction = camera.calculate_nd_view_direction( ndim=3, dims_displayed=[0, 1, 2] ) assert len(view_direction) == 3 assert np.allclose(view_direction, (0, 1, 0)) # should return nD with 3d embedded in nD if ndim > 3 view_direction = camera.calculate_nd_view_direction( ndim=5, dims_displayed=[0, 2, 4] ) assert len(view_direction) == 5 assert np.allclose(view_direction[[0, 2, 4]], (0, 1, 0)) napari-0.5.0a1/napari/components/_tests/test_cursor.py000066400000000000000000000002331437041365600231050ustar00rootroot00000000000000from napari.components.cursor import Cursor def test_cursor(): """Test creating cursor object""" cursor = Cursor() assert cursor is not None napari-0.5.0a1/napari/components/_tests/test_dims.py000066400000000000000000000216731437041365600225370ustar00rootroot00000000000000import pytest from napari.components import Dims from napari.components.dims import ( assert_axis_in_bounds, reorder_after_dim_reduction, ) def test_ndim(): """ Test number of dimensions including after adding and removing dimensions. """ dims = Dims() assert dims.ndim == 2 dims = Dims(ndim=4) assert dims.ndim == 4 dims = Dims(ndim=2) assert dims.ndim == 2 dims.ndim = 10 assert dims.ndim == 10 dims.ndim = 5 assert dims.ndim == 5 def test_display(): """ Test display setting. """ dims = Dims(ndim=4) assert dims.order == (0, 1, 2, 3) assert dims.ndisplay == 2 assert dims.displayed == (2, 3) assert dims.displayed_order == (0, 1) assert dims.not_displayed == (0, 1) dims.order = (2, 3, 1, 0) assert dims.order == (2, 3, 1, 0) assert dims.displayed == (1, 0) assert dims.displayed_order == (1, 0) assert dims.not_displayed == (2, 3) def test_order_with_init(): dims = Dims(ndim=3, order=(0, 2, 1)) assert dims.order == (0, 2, 1) def test_labels_with_init(): dims = Dims(ndim=3, axis_labels=('x', 'y', 'z')) assert dims.axis_labels == ('x', 'y', 'z') def test_bad_order(): dims = Dims(ndim=3) with pytest.raises(ValueError): dims.order = (0, 0, 1) def test_pad_bad_labels(): dims = Dims(ndim=3) dims.axis_labels = ('a', 'b') assert dims.axis_labels == ('0', 'a', 'b') def test_keyword_only_dims(): with pytest.raises(TypeError): Dims(3, (1, 2, 3)) def test_point(): """ Test point setting. """ dims = Dims(ndim=4) assert dims.point == (0,) * 4 dims.set_range(range(dims.ndim), ((0, 5, 1),) * dims.ndim) dims.set_point(3, 4) assert dims.point == (0, 0, 0, 4) dims.set_point(2, 1) assert dims.point == (0, 0, 1, 4) dims.set_point((0, 1, 2), (2.1, 2.6, 0.0)) assert dims.point == (2, 3, 0, 4) def test_point_variable_step_size(): dims = Dims(ndim=3) assert dims.point == (0,) * 3 desired_range = ((0, 6, 0.5), (0, 6, 1), (0, 6, 2)) dims.set_range(range(3), desired_range) assert dims.range == desired_range # set point updates current_step indirectly dims.set_point([0, 1, 2], (2.9, 2.9, 2.9)) assert dims.current_step == (6, 3, 1) # point is a property computed on demand from current_step assert dims.point == (3, 3, 2) # can set step directly as well # note that out of range values get clipped dims.set_current_step((0, 1, 2), (1, -3, 5)) assert dims.current_step == (1, 0, 2) assert dims.point == (0.5, 0, 4) dims.set_current_step(0, -1) assert dims.current_step == (0, 0, 2) assert dims.point == (0, 0, 4) # mismatched len(axis) vs. len(value) with pytest.raises(ValueError): dims.set_point((0, 1), (0, 0, 0)) with pytest.raises(ValueError): dims.set_current_step((0, 1), (0, 0, 0)) def test_range(): """ Tests range setting. """ dims = Dims(ndim=4) assert dims.range == ((0, 2, 1),) * 4 dims.set_range(3, (0, 4, 2)) assert dims.range == ((0, 2, 1),) * 3 + ((0, 4, 2),) def test_range_set_multiple(): """ Tests bulk range setting. """ dims = Dims(ndim=4) assert dims.range == ((0, 2, 1),) * 4 dims.set_range((0, 3), [(0, 6, 3), (0, 9, 3)]) assert dims.range == ((0, 6, 3),) + ((0, 2, 1),) * 2 + ((0, 9, 3),) # last_used will be set to the smallest axis in range dims.set_range(range(1, 4), ((0, 5, 1),) * 3) assert dims.range == ((0, 6, 3),) + ((0, 5, 1),) * 3 # test with descending axis order dims.set_range(axis=(3, 0), _range=[(0, 4, 1), (0, 6, 1)]) assert dims.range == ((0, 6, 1),) + ((0, 5, 1),) * 2 + ((0, 4, 1),) # out of range axis raises a ValueError with pytest.raises(ValueError): dims.set_range((dims.ndim, 0), [(0.0, 4.0, 1.0)] * 2) # sequence lengths for axis and _range do not match with pytest.raises(ValueError): dims.set_range((0, 1), [(0.0, 4.0, 1.0)] * 3) def test_axis_labels(): dims = Dims(ndim=4) assert dims.axis_labels == ('0', '1', '2', '3') dims.set_axis_label(0, 't') assert dims.axis_labels == ('t', '1', '2', '3') dims.set_axis_label((0, 1, 3), ('t', 'c', 'last')) assert dims.axis_labels == ('t', 'c', '2', 'last') # mismatched len(axis) vs. len(value) with pytest.raises(ValueError): dims.set_point((0, 1), ('x', 'y', 'z')) def test_order_when_changing_ndim(): """ Test order of the dims when changing the number of dimensions. """ dims = Dims(ndim=4) dims.set_range(0, (0, 4, 1)) dims.set_point(0, 2) dims.ndim = 5 # Test that new dims get appended to the beginning of lists assert dims.point == (0, 2, 0, 0, 0) assert dims.order == (0, 1, 2, 3, 4) assert dims.axis_labels == ('0', '1', '2', '3', '4') dims.set_range(2, (0, 4, 1)) dims.set_point(2, 3) dims.ndim = 3 # Test that dims get removed from the beginning of lists assert dims.point == (3, 0, 0) assert dims.order == (0, 1, 2) assert dims.axis_labels == ('2', '3', '4') def test_labels_order_when_changing_dims(): dims = Dims(ndim=4) dims.ndim = 5 assert dims.axis_labels == ('0', '1', '2', '3', '4') @pytest.mark.parametrize( "ndim, ax_input, expected", ((2, 1, 1), (2, -1, 1), (4, -3, 1)) ) def test_assert_axis_in_bounds(ndim, ax_input, expected): actual = assert_axis_in_bounds(ax_input, ndim) assert actual == expected @pytest.mark.parametrize("ndim, ax_input", ((2, 2), (2, -3))) def test_assert_axis_out_of_bounds(ndim, ax_input): with pytest.raises(ValueError): assert_axis_in_bounds(ax_input, ndim) def test_axis_labels_str_to_list(): dims = Dims() dims.axis_labels = 'TX' assert dims.axis_labels == ('T', 'X') def test_roll(): """Test basic roll behavior.""" dims = Dims(ndim=4) dims.set_range(0, (0, 10, 1)) dims.set_range(1, (0, 10, 1)) dims.set_range(2, (0, 10, 1)) dims.set_range(3, (0, 10, 1)) assert dims.order == (0, 1, 2, 3) dims._roll() assert dims.order == (3, 0, 1, 2) dims._roll() assert dims.order == (2, 3, 0, 1) def test_roll_skip_dummy_axis_1(): """Test basic roll skips axis with length 1.""" dims = Dims(ndim=4) dims.set_range(0, (0, 0, 1)) dims.set_range(1, (0, 10, 1)) dims.set_range(2, (0, 10, 1)) dims.set_range(3, (0, 10, 1)) assert dims.order == (0, 1, 2, 3) dims._roll() assert dims.order == (0, 3, 1, 2) dims._roll() assert dims.order == (0, 2, 3, 1) def test_roll_skip_dummy_axis_2(): """Test basic roll skips axis with length 1 when not first.""" dims = Dims(ndim=4) dims.set_range(0, (0, 10, 1)) dims.set_range(1, (0, 0, 1)) dims.set_range(2, (0, 10, 1)) dims.set_range(3, (0, 10, 1)) assert dims.order == (0, 1, 2, 3) dims._roll() assert dims.order == (3, 1, 0, 2) dims._roll() assert dims.order == (2, 1, 3, 0) def test_roll_skip_dummy_axis_3(): """Test basic roll skips all axes with length 1.""" dims = Dims(ndim=4) dims.set_range(0, (0, 10, 1)) dims.set_range(1, (0, 0, 1)) dims.set_range(2, (0, 10, 1)) dims.set_range(3, (0, 0, 1)) assert dims.order == (0, 1, 2, 3) dims._roll() assert dims.order == (2, 1, 0, 3) dims._roll() assert dims.order == (0, 1, 2, 3) def test_changing_focus(): """Test changing focus updates the last_used prop.""" # too-few dims, should have no sliders to update dims = Dims(ndim=2) assert dims.last_used == 0 dims._focus_down() dims._focus_up() assert dims.last_used == 0 dims.ndim = 5 # Note that with no view attached last used remains # None even though new non-displayed dimensions added assert dims.last_used == 0 dims._focus_down() assert dims.last_used == 2 dims._focus_down() assert dims.last_used == 1 dims._focus_up() assert dims.last_used == 2 dims._focus_up() assert dims.last_used == 0 dims._focus_down() assert dims.last_used == 2 def test_floating_point_edge_case(): # see #4889 dims = Dims(ndim=2) dims.set_range(0, (0.0, 17.665, 3.533)) assert dims.nsteps[0] == 5 @pytest.mark.parametrize( ('order', 'expected'), ( ((0, 1), (0, 1)), # 2D, increasing, default range ((3, 7), (0, 1)), # 2D, increasing, non-default range ((1, 0), (1, 0)), # 2D, decreasing, default range ((5, 2), (1, 0)), # 2D, decreasing, non-default range ((0, 1, 2), (0, 1, 2)), # 3D, increasing, default range ((3, 4, 6), (0, 1, 2)), # 3D, increasing, non-default range ((2, 1, 0), (2, 1, 0)), # 3D, decreasing, default range ((4, 2, 0), (2, 1, 0)), # 3D, decreasing, non-default range ((2, 0, 1), (2, 0, 1)), # 3D, non-monotonic, default range ((4, 0, 1), (2, 0, 1)), # 3D, non-monotonic, non-default range ), ) def test_reorder_after_dim_reduction(order, expected): actual = reorder_after_dim_reduction(order) assert actual == expected napari-0.5.0a1/napari/components/_tests/test_grid.py000066400000000000000000000047301437041365600225230ustar00rootroot00000000000000from napari.components.grid import GridCanvas def test_grid_creation(): """Test creating grid object""" grid = GridCanvas() assert grid is not None assert not grid.enabled assert grid.shape == (-1, -1) assert grid.stride == 1 def test_shape_stride_creation(): """Test creating grid object""" grid = GridCanvas(shape=(3, 4), stride=2) assert grid.shape == (3, 4) assert grid.stride == 2 def test_actual_shape_and_position(): """Test actual shape""" grid = GridCanvas(enabled=True) assert grid.enabled # 9 layers get put in a (3, 3) grid assert grid.actual_shape(9) == (3, 3) assert grid.position(0, 9) == (0, 0) assert grid.position(2, 9) == (0, 2) assert grid.position(3, 9) == (1, 0) assert grid.position(8, 9) == (2, 2) # 5 layers get put in a (2, 3) grid assert grid.actual_shape(5) == (2, 3) assert grid.position(0, 5) == (0, 0) assert grid.position(2, 5) == (0, 2) assert grid.position(3, 5) == (1, 0) # 10 layers get put in a (3, 4) grid assert grid.actual_shape(10) == (3, 4) assert grid.position(0, 10) == (0, 0) assert grid.position(2, 10) == (0, 2) assert grid.position(3, 10) == (0, 3) assert grid.position(8, 10) == (2, 0) def test_actual_shape_with_stride(): """Test actual shape""" grid = GridCanvas(enabled=True, stride=2) assert grid.enabled # 7 layers get put in a (2, 2) grid assert grid.actual_shape(7) == (2, 2) assert grid.position(0, 7) == (0, 0) assert grid.position(1, 7) == (0, 0) assert grid.position(2, 7) == (0, 1) assert grid.position(3, 7) == (0, 1) assert grid.position(6, 7) == (1, 1) # 3 layers get put in a (1, 2) grid assert grid.actual_shape(3) == (1, 2) assert grid.position(0, 3) == (0, 0) assert grid.position(1, 3) == (0, 0) assert grid.position(2, 3) == (0, 1) def test_actual_shape_and_position_negative_stride(): """Test actual shape""" grid = GridCanvas(enabled=True, stride=-1) assert grid.enabled # 9 layers get put in a (3, 3) grid assert grid.actual_shape(9) == (3, 3) assert grid.position(0, 9) == (2, 2) assert grid.position(2, 9) == (2, 0) assert grid.position(3, 9) == (1, 2) assert grid.position(8, 9) == (0, 0) def test_actual_shape_grid_disabled(): """Test actual shape with grid disabled""" grid = GridCanvas() assert not grid.enabled assert grid.actual_shape(9) == (1, 1) assert grid.position(3, 9) == (0, 0) napari-0.5.0a1/napari/components/_tests/test_interaction_box.py000066400000000000000000000053761437041365600247740ustar00rootroot00000000000000import numpy as np from napari.components.overlays.interaction_box import SelectionBoxOverlay from napari.layers.base._base_constants import InteractionBoxHandle from napari.layers.points import Points from napari.layers.utils.interaction_box import ( generate_interaction_box_vertices, generate_transform_box_from_layer, get_nearby_handle, ) def test_transform_box_vertices_from_bounds(): expected = np.array( [ [0, 0], [10, 0], [0, 10], [10, 10], [0, 5], [5, 0], [5, 10], [10, 5], [-1, 5], ] ) top_left = 0, 0 bottom_right = 10, 10 # works in vispy coordinates, so x and y are swapped vertices = generate_interaction_box_vertices( top_left, bottom_right, handles=False ) np.testing.assert_allclose(vertices, expected[:4, ::-1]) vertices = generate_interaction_box_vertices( top_left, bottom_right, handles=True ) np.testing.assert_allclose(vertices, expected[:, ::-1]) def test_transform_box_from_layer(): pts = np.array([[0, 0], [10, 10]]) translate = [-2, 3] scale = [4, 5] layer = Points(pts, translate=translate, scale=scale) vertices = generate_transform_box_from_layer(layer, dims_displayed=(0, 1)) # scale/translate should not affect vertices, cause they're in data space expected = np.array( [ [0, 0], [10, 0], [0, 10], [10, 10], [0, 5], [5, 0], [5, 10], [10, 5], [-1, 5], ] ) np.testing.assert_allclose(vertices, expected) def test_transform_box_get_nearby_handle(): # square box from (0, 0) to (10, 10) vertices = np.array( [ [0, 0], [10, 0], [0, 10], [10, 10], [0, 5], [5, 0], [5, 10], [10, 5], [-1, 5], ] ) near_top_left = [0.04, -0.05] top_left = get_nearby_handle(near_top_left, vertices) assert top_left == InteractionBoxHandle.TOP_LEFT near_rotation = [-1.05, 4.95] rotation = get_nearby_handle(near_rotation, vertices) assert rotation == InteractionBoxHandle.ROTATION middle = [5, 5] inside = get_nearby_handle(middle, vertices) assert inside == InteractionBoxHandle.INSIDE outside = [12, -1] none = get_nearby_handle(outside, vertices) assert none is None def test_selection_box_from_points(): points = np.array( [ [0, 5], [-3, 0], [0, 7], ] ) selection_box = SelectionBoxOverlay() selection_box.update_from_points(points) assert selection_box.bounds == ((-3, 0), (0, 7)) napari-0.5.0a1/napari/components/_tests/test_layer_slicer.py000066400000000000000000000306061437041365600242540ustar00rootroot00000000000000import time from concurrent.futures import Future, wait from dataclasses import dataclass from threading import RLock, current_thread, main_thread from typing import Any, Tuple, Union import numpy as np import pytest from napari._tests.utils import DEFAULT_TIMEOUT_SECS from napari.components import Dims from napari.components._layer_slicer import _LayerSlicer from napari.layers import Image, Points from napari.layers._data_protocols import Index, LayerDataProtocol from napari.types import DTypeLike # The following fakes are used to control execution of slicing across # multiple threads, while also allowing us to mimic real classes # (like layers) in the code base. This allows us to assert state and # conditions that may only be temporarily true at different stages of # an asynchronous task. @dataclass(frozen=True) class FakeSliceResponse: id: int @dataclass(frozen=True) class FakeSliceRequest: id: int lock: RLock def __call__(self) -> FakeSliceResponse: assert current_thread() != main_thread() with self.lock: return FakeSliceResponse(id=self.id) class FakeAsyncLayer: def __init__(self) -> None: self._slice_request_count: int = 0 self.slice_count: int = 0 self.lock: RLock = RLock() def _make_slice_request(self, dims: Dims) -> FakeSliceRequest: assert current_thread() == main_thread() self._slice_request_count += 1 return FakeSliceRequest(id=self._slice_request_count, lock=self.lock) def _update_slice_response(self, response: FakeSliceResponse): self.slice_count = response.id def _slice_dims(self, *args, **kwargs) -> None: self.slice_count += 1 class FakeSyncLayer: def __init__(self) -> None: self.slice_count: int = 0 def _slice_dims(self, *args, **kwargs) -> None: self.slice_count += 1 class LockableData: """A wrapper for napari layer data that blocks read-access with a lock. This is useful when testing async slicing with real napari layers because it allows us to control when slicing tasks complete. """ def __init__(self, data: LayerDataProtocol) -> None: self.data = data self.lock = RLock() @property def dtype(self) -> DTypeLike: return self.data.dtype @property def shape(self) -> Tuple[int, ...]: return self.data.shape def __getitem__( self, key: Union[Index, Tuple[Index, ...], LayerDataProtocol] ) -> LayerDataProtocol: with self.lock: return self.data[key] def __len__(self): return len(self.data) @pytest.fixture() def layer_slicer(): layer_slicer = _LayerSlicer() yield layer_slicer layer_slicer.shutdown() def test_submit_with_one_async_layer_no_block(layer_slicer): layer = FakeAsyncLayer() future = layer_slicer.submit(layers=[layer], dims=Dims()) assert _wait_for_result(future)[layer].id == 1 assert _wait_for_result(future)[layer].id == 1 def test_submit_with_multiple_async_layer_no_block(layer_slicer): layer1 = FakeAsyncLayer() layer2 = FakeAsyncLayer() future = layer_slicer.submit(layers=[layer1, layer2], dims=Dims()) assert _wait_for_result(future)[layer1].id == 1 assert _wait_for_result(future)[layer2].id == 1 def test_submit_emits_ready_event_when_done(layer_slicer): layer = FakeAsyncLayer() event_result = None def on_done(event): nonlocal event_result event_result = event.value layer_slicer.events.ready.connect(on_done) future = layer_slicer.submit(layers=[layer], dims=Dims()) actual_result = _wait_for_result(future) assert actual_result is event_result def test_submit_with_one_sync_layer(layer_slicer): layer = FakeSyncLayer() assert layer.slice_count == 0 future = layer_slicer.submit(layers=[layer], dims=Dims()) assert layer.slice_count == 1 assert future is None def test_submit_with_multiple_sync_layer(layer_slicer): layer1 = FakeSyncLayer() layer2 = FakeSyncLayer() assert layer1.slice_count == 0 assert layer2.slice_count == 0 future = layer_slicer.submit(layers=[layer1, layer2], dims=Dims()) assert layer1.slice_count == 1 assert layer2.slice_count == 1 assert future is None def test_submit_with_mixed_layers(layer_slicer): layer1 = FakeAsyncLayer() layer2 = FakeSyncLayer() assert layer1.slice_count == 0 assert layer2.slice_count == 0 future = layer_slicer.submit(layers=[layer1, layer2], dims=Dims()) assert layer2.slice_count == 1 assert _wait_for_result(future)[layer1].id == 1 assert layer2 not in _wait_for_result(future) def test_submit_lock_blocking(layer_slicer): dims = Dims() layer = FakeAsyncLayer() assert layer.slice_count == 0 with layer.lock: blocked = layer_slicer.submit(layers=[layer], dims=dims) assert not blocked.done() assert _wait_for_result(blocked)[layer].id == 1 def test_submit_multiple_calls_cancels_pending(layer_slicer): dims = Dims() layer = FakeAsyncLayer() with layer.lock: blocked = layer_slicer.submit(layers=[layer], dims=dims) _wait_until_running(blocked) pending = layer_slicer.submit(layers=[layer], dims=dims) assert not pending.running() layer_slicer.submit(layers=[layer], dims=dims) assert not blocked.done() assert pending.cancelled() def test_submit_mixed_allows_sync_to_run(layer_slicer): """ensure that a blocked async slice doesn't block sync slicing""" dims = Dims() layer1 = FakeAsyncLayer() layer2 = FakeSyncLayer() with layer1.lock: blocked = layer_slicer.submit(layers=[layer1], dims=dims) layer_slicer.submit(layers=[layer2], dims=dims) assert layer2.slice_count == 1 assert not blocked.done() assert _wait_for_result(blocked)[layer1].id == 1 def test_submit_mixed_allows_sync_to_run_one_slicer_call(layer_slicer): """ensure that a blocked async slice doesn't block sync slicing""" dims = Dims() layer1 = FakeAsyncLayer() layer2 = FakeSyncLayer() with layer1.lock: blocked = layer_slicer.submit(layers=[layer1, layer2], dims=dims) assert layer2.slice_count == 1 assert not blocked.done() assert _wait_for_result(blocked)[layer1].id == 1 def test_submit_with_multiple_async_layer_with_all_locked( layer_slicer, ): """ensure that if only all layers are locked, none continue""" dims = Dims() layer1 = FakeAsyncLayer() layer2 = FakeAsyncLayer() with layer1.lock, layer2.lock: blocked = layer_slicer.submit(layers=[layer1, layer2], dims=dims) assert not blocked.done() assert _wait_for_result(blocked)[layer1].id == 1 assert _wait_for_result(blocked)[layer2].id == 1 def test_submit_task_to_layers_lock(layer_slicer): """ensure that if only one layer has a lock, the non-locked layer can continue""" dims = Dims() layer = FakeAsyncLayer() with layer.lock: task = layer_slicer.submit(layers=[layer], dims=dims) assert task in layer_slicer._layers_to_task.values() assert _wait_for_result(task)[layer].id == 1 assert task not in layer_slicer._layers_to_task def test_submit_exception_main_thread(layer_slicer): """Exception is raised on the main thread from an error on the main thread immediately when the task is created.""" class FakeAsyncLayerError(FakeAsyncLayer): def _make_slice_request(self, dims) -> FakeSliceRequest: raise RuntimeError('_make_slice_request') layer = FakeAsyncLayerError() with pytest.raises(RuntimeError, match='_make_slice_request'): layer_slicer.submit(layers=[layer], dims=Dims()) def test_submit_exception_subthread_on_result(layer_slicer): """Exception is raised on the main thread from an error on a subthread only after result is called, not upon submission of the task.""" @dataclass(frozen=True) class FakeSliceRequestError(FakeSliceRequest): def __call__(self) -> FakeSliceResponse: assert current_thread() != main_thread() raise RuntimeError('FakeSliceRequestError') class FakeAsyncLayerError(FakeAsyncLayer): def _make_slice_request(self, dims: Dims) -> FakeSliceRequestError: self._slice_request_count += 1 return FakeSliceRequestError( id=self._slice_request_count, lock=self.lock ) layer = FakeAsyncLayerError() future = layer_slicer.submit(layers=[layer], dims=Dims()) done, _ = wait([future], timeout=DEFAULT_TIMEOUT_SECS) assert done, 'Test future did not complete within timeout.' with pytest.raises(RuntimeError, match='FakeSliceRequestError'): _wait_for_result(future) def test_wait_until_idle(layer_slicer, single_threaded_executor): dims = Dims() layer = FakeAsyncLayer() with layer.lock: slice_future = layer_slicer.submit(layers=[layer], dims=dims) _wait_until_running(slice_future) # The slice task has started, but has not finished yet # because we are holding the layer's slicing lock. assert len(layer_slicer._layers_to_task) > 0 # We can't call wait_until_idle on this thread because we're # holding the layer's slice lock, so submit it to be executed # on another thread and also wait for it to start. wait_future = single_threaded_executor.submit( layer_slicer.wait_until_idle, timeout=DEFAULT_TIMEOUT_SECS, ) _wait_until_running(wait_future) _wait_for_result(wait_future) assert len(layer_slicer._layers_to_task) == 0 def test_force_sync_on_sync_layer(layer_slicer): layer = FakeSyncLayer() with layer_slicer.force_sync(): assert layer_slicer._force_sync future = layer_slicer.submit(layers=[layer], dims=Dims()) assert layer.slice_count == 1 assert future is None assert not layer_slicer._force_sync def test_force_sync_on_async_layer(layer_slicer): layer = FakeAsyncLayer() with layer_slicer.force_sync(): assert layer_slicer._force_sync future = layer_slicer.submit(layers=[layer], dims=Dims()) assert layer.slice_count == 1 assert future is None def test_submit_with_one_3d_image(layer_slicer): np.random.seed(0) data = np.random.rand(8, 7, 6) lockable_data = LockableData(data) layer = Image(data=lockable_data, multiscale=False) dims = Dims( ndim=3, ndisplay=2, range=((0, 8, 1), (0, 7, 1), (0, 6, 1)), current_step=(2, 0, 0), ) with lockable_data.lock: future = layer_slicer.submit(layers=[layer], dims=dims) assert not future.done() layer_result = _wait_for_result(future)[layer] np.testing.assert_equal(layer_result.data, data[2, :, :]) def test_submit_with_one_3d_points(layer_slicer): """ensure that async slicing of points does not block""" np.random.seed(0) num_points = 100 data = np.rint(2.0 * np.random.rand(num_points, 3)) layer = Points(data=data) # Note: We are directly accessing and locking the _data of layer. This # forces a block to ensure that the async slicing call returns # before slicing is complete. lockable_internal_data = LockableData(layer._data) layer._data = lockable_internal_data dims = Dims( ndim=3, ndisplay=2, range=((0, 3, 1), (0, 3, 1), (0, 3, 1)), current_step=(1, 0, 0), ) with lockable_internal_data.lock: future = layer_slicer.submit(layers=[layer], dims=dims) assert not future.done() def test_submit_after_shutdown_raises(): layer_slicer = _LayerSlicer() layer_slicer._force_sync = False layer_slicer.shutdown() with pytest.raises(RuntimeError): layer_slicer.submit(layers=[FakeAsyncLayer()], dims=Dims()) def _wait_until_running(future: Future): """Waits until the given future is running using a default finite timeout.""" sleep_secs = 0.01 total_sleep_secs = 0 while not future.running(): time.sleep(sleep_secs) total_sleep_secs += sleep_secs if total_sleep_secs > DEFAULT_TIMEOUT_SECS: raise TimeoutError( f'Future did not start running after a timeout of {DEFAULT_TIMEOUT_SECS} seconds.' ) def _wait_for_result(future: Future) -> Any: """Waits until the given future is finished using a default finite timeout, and returns its result.""" return future.result(timeout=DEFAULT_TIMEOUT_SECS) napari-0.5.0a1/napari/components/_tests/test_layers_list.py000066400000000000000000000411641437041365600241320ustar00rootroot00000000000000import os import npe2 import numpy as np import pytest from napari.components import LayerList from napari.layers import Image def test_empty_layers_list(): """ Test instantiating an empty LayerList object """ layers = LayerList() assert len(layers) == 0 def test_initialize_from_list(): layers = LayerList( [Image(np.random.random((10, 10))), Image(np.random.random((10, 10)))] ) assert len(layers) == 2 def test_adding_layer(): layers = LayerList() layer = Image(np.random.random((10, 10))) layers.append(layer) # LayerList should err if you add anything other than a layer with pytest.raises(TypeError): layers.append('something') assert len(layers) == 1 def test_removing_layer(): layers = LayerList() layer = Image(np.random.random((10, 10))) layers.append(layer) layers.remove(layer) assert len(layers) == 0 def test_popping_layer(): """Test popping a layer off layerlist.""" layers = LayerList() layer = Image(np.random.random((10, 10))) layers.append(layer) assert len(layers) == 1 layers.pop(0) assert len(layers) == 0 def test_indexing(): """ Test indexing into a LayerList """ layers = LayerList() layer = Image(np.random.random((10, 10)), name='image') layers.append(layer) assert layers[0] == layer assert layers['image'] == layer def test_insert(): """ Test inserting into a LayerList """ layers = LayerList() layer_a = Image(np.random.random((10, 10)), name='image_a') layer_b = Image(np.random.random((15, 15)), name='image_b') layers.append(layer_a) layers.insert(0, layer_b) assert list(layers) == [layer_b, layer_a] def test_get_index(): """ Test getting indexing from LayerList """ layers = LayerList() layer_a = Image(np.random.random((10, 10)), name='image_a') layer_b = Image(np.random.random((15, 15)), name='image_b') layers.append(layer_a) layers.append(layer_b) assert layers.index(layer_a) == 0 assert layers.index('image_a') == 0 assert layers.index(layer_b) == 1 assert layers.index('image_b') == 1 def test_reordering(): """ Test indexing into a LayerList by name """ layers = LayerList() layer_a = Image(np.random.random((10, 10)), name='image_a') layer_b = Image(np.random.random((15, 15)), name='image_b') layer_c = Image(np.random.random((15, 15)), name='image_c') layers.append(layer_a) layers.append(layer_b) layers.append(layer_c) # Rearrange layers by tuple layers[:] = [layers[i] for i in (1, 0, 2)] assert list(layers) == [layer_b, layer_a, layer_c] # Reverse layers layers.reverse() assert list(layers) == [layer_c, layer_a, layer_b] def test_clearing_layerlist(): """Test clearing layer list.""" layers = LayerList() layer = Image(np.random.random((10, 10))) layer2 = Image(np.random.random((10, 10))) layers.append(layer) layers.append(layer2) assert len(layers) == 2 layers.clear() assert len(layers) == 0 def test_remove_selected(): """Test removing selected layers.""" layers = LayerList() layer_a = Image(np.random.random((10, 10))) layer_b = Image(np.random.random((15, 15))) layer_c = Image(np.random.random((15, 15))) layers.append(layer_a) layers.append(layer_b) layers.append(layer_c) # remove last added layer as only one selected layers.selection.clear() layers.selection.add(layer_c) layers.remove_selected() assert list(layers) == [layer_a, layer_b] # select and remove all layers layers.select_all() layers.remove_selected() assert len(layers) == 0 @pytest.mark.filterwarnings('ignore::FutureWarning') def test_move_selected(): """ Test removing selected layers """ layers = LayerList() layer_a = Image(np.random.random((10, 10))) layer_b = Image(np.random.random((15, 15))) layer_c = Image(np.random.random((15, 15))) layer_d = Image(np.random.random((15, 15))) layers.append(layer_a) layers.append(layer_b) layers.append(layer_c) layers.append(layer_d) # Check nothing moves if given same insert and origin layers.selection.clear() layers.move_selected(2, 2) assert list(layers) == [layer_a, layer_b, layer_c, layer_d] assert layers.selection == {layer_c} # Move middle element to front of list and back layers.selection.clear() layers.move_selected(2, 0) assert list(layers) == [layer_c, layer_a, layer_b, layer_d] assert layers.selection == {layer_c} layers.selection.clear() layers.move_selected(0, 2) assert list(layers) == [layer_a, layer_b, layer_c, layer_d] assert layers.selection == {layer_c} # Move middle element to end of list and back layers.selection.clear() layers.move_selected(2, 3) assert list(layers) == [layer_a, layer_b, layer_d, layer_c] assert layers.selection == {layer_c} layers.selection.clear() layers.move_selected(3, 2) assert list(layers) == [layer_a, layer_b, layer_c, layer_d] assert layers.selection == {layer_c} # Select first two layers only layers.selection = layers[:2] # Move unselected middle element to front of list even if others selected layers.move_selected(2, 0) assert list(layers) == [layer_c, layer_a, layer_b, layer_d] # Move selected first element back to middle of list layers.move_selected(0, 2) assert list(layers) == [layer_a, layer_b, layer_c, layer_d] # Select first two layers only layers.selection = layers[:2] # Check nothing moves if given same insert and origin and multiple selected layers.move_selected(0, 0) assert list(layers) == [layer_a, layer_b, layer_c, layer_d] assert layers.selection == {layer_a, layer_b} # Check nothing moves if given same insert and origin and multiple selected layers.move_selected(1, 1) assert list(layers) == [layer_a, layer_b, layer_c, layer_d] assert layers.selection == {layer_a, layer_b} # Move first two selected to middle of list layers.move_selected(0, 2) assert list(layers) == [layer_c, layer_a, layer_b, layer_d] assert layers.selection == {layer_a, layer_b} # Move middle selected to front of list layers.move_selected(2, 0) assert list(layers) == [layer_a, layer_b, layer_c, layer_d] assert layers.selection == {layer_a, layer_b} # Move first two selected to middle of list layers.move_selected(1, 2) assert list(layers) == [layer_c, layer_a, layer_b, layer_d] assert layers.selection == {layer_a, layer_b} # Move middle selected to front of list layers.move_selected(1, 0) assert list(layers) == [layer_a, layer_b, layer_c, layer_d] assert layers.selection == {layer_a, layer_b} # Select first and third layers only layers.selection = layers[::2] # Move selection together to middle layers.move_selected(2, 2) assert list(layers) == [layer_b, layer_a, layer_c, layer_d] assert layers.selection == {layer_a, layer_c} layers.move_multiple((1, 0, 2, 3), 0) # Move selection together to middle layers.move_selected(0, 1) assert list(layers) == [layer_b, layer_a, layer_c, layer_d] assert layers.selection == {layer_a, layer_c} layers.move_multiple((1, 0, 2, 3), 0) # Move selection together to end layers.move_selected(2, 3) assert list(layers) == [layer_b, layer_d, layer_a, layer_c] assert layers.selection == {layer_a, layer_c} layers.move_multiple((2, 0, 3, 1), 0) # Move selection together to end layers.move_selected(0, 3) assert list(layers) == [layer_b, layer_d, layer_a, layer_c] assert layers.selection == {layer_a, layer_c} layers.move_multiple((2, 0, 3, 1), 0) layer_e = Image(np.random.random((15, 15))) layer_f = Image(np.random.random((15, 15))) layers.append(layer_e) layers.append(layer_f) # Check current order is correct assert list(layers) == [ layer_a, layer_b, layer_c, layer_d, layer_e, layer_f, ] # Select second and firth layers only layers.selection = {layers[1], layers[4]} # Move selection together to middle layers.move_selected(1, 2) assert list(layers) == [ layer_a, layer_c, layer_b, layer_e, layer_d, layer_f, ] assert layers.selection == {layer_b, layer_e} def test_toggle_visibility(): """ Test toggling layer visibility """ layers = LayerList() layer_a = Image(np.random.random((10, 10))) layer_b = Image(np.random.random((15, 15))) layer_c = Image(np.random.random((15, 15))) layer_d = Image(np.random.random((15, 15))) layers.append(layer_a) layers.append(layer_b) layers.append(layer_c) layers.append(layer_d) layers[0].visible = False layers[1].visible = True layers[2].visible = False layers[3].visible = True layers.select_all() layers.selection.remove(layers[0]) layers.toggle_selected_visibility() assert [lay.visible for lay in layers] == [False, False, True, False] layers.toggle_selected_visibility() assert [lay.visible for lay in layers] == [False, True, False, True] # the layer_data_and_types fixture is defined in napari/conftest.py @pytest.mark.filterwarnings('ignore:distutils Version classes are deprecated') def test_layers_save(builtins, tmpdir, layer_data_and_types): """Test saving all layer data.""" list_of_layers, _, _, filenames = layer_data_and_types layers = LayerList(list_of_layers) path = os.path.join(tmpdir, 'layers_folder') # Check folder does not exist assert not os.path.isdir(path) # Write data layers.save(path, plugin=builtins.name) # Check folder now exists assert os.path.isdir(path) # Check individual files now exist for f in filenames: assert os.path.isfile(os.path.join(path, f)) # Check no additional files exist assert set(os.listdir(path)) == set(filenames) assert set(os.listdir(tmpdir)) == {'layers_folder'} # the layer_data_and_types fixture is defined in napari/conftest.py def test_layers_save_none_selected(builtins, tmpdir, layer_data_and_types): """Test saving all layer data.""" list_of_layers, _, _, filenames = layer_data_and_types layers = LayerList(list_of_layers) layers.selection.clear() path = os.path.join(tmpdir, 'layers_folder') # Check folder does not exist assert not os.path.isdir(path) # Write data (will get a warning that nothing is selected) with pytest.warns(UserWarning): layers.save(path, selected=True, plugin=builtins.name) # Check folder still does not exist assert not os.path.isdir(path) # Check individual files still do not exist for f in filenames: assert not os.path.isfile(os.path.join(path, f)) # Check no additional files exist assert set(os.listdir(tmpdir)) == set('') # the layer_data_and_types fixture is defined in napari/conftest.py def test_layers_save_selected(builtins, tmpdir, layer_data_and_types): """Test saving all layer data.""" list_of_layers, _, _, filenames = layer_data_and_types layers = LayerList(list_of_layers) layers.selection.clear() layers.selection.update({layers[0], layers[2]}) path = os.path.join(tmpdir, 'layers_folder') # Check folder does not exist assert not os.path.isdir(path) # Write data layers.save(path, selected=True, plugin=builtins.name) # Check folder exists assert os.path.isdir(path) # Check only appropriate files exist assert os.path.isfile(os.path.join(path, filenames[0])) assert not os.path.isfile(os.path.join(path, filenames[1])) assert os.path.isfile(os.path.join(path, filenames[2])) assert not os.path.isfile(os.path.join(path, filenames[1])) # Check no additional files exist assert set(os.listdir(path)) == {filenames[0], filenames[2]} assert set(os.listdir(tmpdir)) == {'layers_folder'} # the layers fixture is defined in napari/conftest.py @pytest.mark.filterwarnings('ignore:`np.int` is a deprecated alias for') def test_layers_save_svg(tmpdir, layers, napari_svg_name): """Test saving all layer data to an svg.""" pm = npe2.PluginManager.instance() pm.register(npe2.PluginManifest.from_distribution('napari-svg')) path = os.path.join(tmpdir, 'layers_file.svg') # Check file does not exist assert not os.path.isfile(path) # Write data layers.save(path, plugin=napari_svg_name) # Check file now exists assert os.path.isfile(path) def test_world_extent(): """Test world extent after adding layers.""" np.random.seed(0) layers = LayerList() # Empty data is taken to be 512 x 512 np.testing.assert_allclose(layers.extent.world[0], (-0.5, -0.5)) np.testing.assert_allclose(layers.extent.world[1], (511.5, 511.5)) np.testing.assert_allclose(layers.extent.step, (1, 1)) # Add one layer layer_a = Image( np.random.random((6, 10, 15)), scale=(3, 1, 1), translate=(10, 20, 5) ) layers.append(layer_a) np.testing.assert_allclose(layer_a.extent.world[0], (8.5, 19.5, 4.5)) np.testing.assert_allclose(layer_a.extent.world[1], (26.5, 29.5, 19.5)) np.testing.assert_allclose(layers.extent.world[0], (8.5, 19.5, 4.5)) np.testing.assert_allclose(layers.extent.world[1], (26.5, 29.5, 19.5)) np.testing.assert_allclose(layers.extent.step, (3, 1, 1)) # Add another layer layer_b = Image( np.random.random((8, 6, 15)), scale=(6, 2, 1), translate=(-5, -10, 10) ) layers.append(layer_b) np.testing.assert_allclose(layer_b.extent.world[0], (-8, -11, 9.5)) np.testing.assert_allclose(layer_b.extent.world[1], (40, 1, 24.5)) np.testing.assert_allclose(layers.extent.world[0], (-8, -11, 4.5)) np.testing.assert_allclose(layers.extent.world[1], (40, 29.5, 24.5)) np.testing.assert_allclose(layers.extent.step, (3, 1, 1)) def test_world_extent_mixed_ndim(): """Test world extent after adding layers of different dimensionality.""" np.random.seed(0) layers = LayerList() # Add 3D layer layer_a = Image(np.random.random((15, 15, 15)), scale=(4, 12, 2)) layers.append(layer_a) np.testing.assert_allclose(layers.extent.world[1], (58, 174, 29)) np.testing.assert_allclose( layers.extent.world[1] - layers.extent.world[0], (60, 180, 30) ) # Add 2D layer layer_b = Image(np.random.random((10, 10)), scale=(6, 4)) layers.append(layer_b) np.testing.assert_allclose(layers.extent.world[1], (58, 174, 38)) np.testing.assert_allclose( layers.extent.world[1] - layers.extent.world[0], (60, 180, 40) ) np.testing.assert_allclose(layers.extent.step, (4, 6, 2)) def test_world_extent_mixed_flipped(): """Test world extent after adding data with a flip.""" # Flipped data results in a negative scale value which should be # made positive when taking into consideration for the step size # calculation np.random.seed(0) layers = LayerList() layer = Image( np.random.random((15, 15)), affine=[[0, 1, 0], [1, 0, 0], [0, 0, 1]] ) layers.append(layer) np.testing.assert_allclose(layer._data_to_world.scale, (1, -1)) np.testing.assert_allclose(layers.extent.step, (1, 1)) def test_ndim(): """Test world extent after adding layers.""" np.random.seed(0) layers = LayerList() assert layers.ndim == 2 # Add one layer layer_a = Image(np.random.random((10, 15))) layers.append(layer_a) assert layers.ndim == 2 # Add another layer layer_b = Image(np.random.random((8, 6, 15))) layers.append(layer_b) assert layers.ndim == 3 # Remove layer layers.remove(layer_b) assert layers.ndim == 2 def test_name_uniqueness(): layers = LayerList() layers.append(Image(np.random.random((10, 15)), name="Image [1]")) layers.append(Image(np.random.random((10, 15)), name="Image")) layers.append(Image(np.random.random((10, 15)), name="Image")) assert [x.name for x in layers] == ['Image [1]', 'Image', 'Image [2]'] def test_readd_layers(): layers = LayerList() imgs = [] for _i in range(5): img = Image(np.random.rand(10, 10, 10)) layers.append(img) imgs.append(img) assert layers == imgs with pytest.raises(ValueError): layers.append(imgs[1]) assert layers == imgs layers[1] = layers[1] assert layers == imgs with pytest.raises(ValueError): layers[1] = layers[2] assert layers == imgs layers[:3] = layers[:3] assert layers == imgs # invert a section layers[:3] = layers[2::-1] assert set(layers) == set(imgs) with pytest.raises(ValueError): layers[:3] = layers[:] assert set(layers) == set(imgs) napari-0.5.0a1/napari/components/_tests/test_multichannel.py000066400000000000000000000200271437041365600242560ustar00rootroot00000000000000import dask.array as da import numpy as np import pytest from napari.components import ViewerModel from napari.utils.colormaps import ( AVAILABLE_COLORMAPS, CYMRGB, MAGENTA_GREEN, SIMPLE_COLORMAPS, Colormap, ensure_colormap, ) from napari.utils.misc import ensure_iterable, ensure_sequence_of_iterables base_colormaps = CYMRGB two_colormaps = MAGENTA_GREEN green_cmap = SIMPLE_COLORMAPS['green'] red_cmap = SIMPLE_COLORMAPS['red'] blue_cmap = AVAILABLE_COLORMAPS['blue'] cmap_tuple = ("my_colormap", Colormap(['g', 'm', 'y'])) cmap_dict = {"your_colormap": Colormap(['g', 'r', 'y'])} MULTI_TUPLES = [[0.3, 0.7], [0.1, 0.9], [0.3, 0.9], [0.4, 0.9], [0.2, 0.9]] # data shape is (15, 10, 5) unless otherwise set # channel_axis = -1 is implied unless otherwise set multi_channel_test_data = [ # basic multichannel image ((), {}), # single channel ((15, 10, 1), {}), # two channels ((15, 10, 2), {}), # Test adding multichannel image with color channel set. ((5, 10, 15), {'channel_axis': 0}), # split single RGB image ((15, 10, 3), {'colormap': ['red', 'green', 'blue']}), # multiple RGB images ((15, 10, 5, 3), {'channel_axis': 2, 'rgb': True}), # Test adding multichannel image with custom names. ((), {'name': ['multi ' + str(i + 3) for i in range(5)]}), # Test adding multichannel image with custom contrast limits. ((), {'contrast_limits': [0.3, 0.7]}), ((), {'contrast_limits': MULTI_TUPLES}), ((), {'gamma': 0.5}), ((), {'gamma': [0.3, 0.4, 0.5, 0.6, 0.7]}), ((), {'visible': [True, False, False, True, True]}), # Test adding multichannel image with custom colormaps. ((), {'colormap': 'gray'}), ((), {'colormap': green_cmap}), ((), {'colormap': cmap_tuple}), ((), {'colormap': cmap_dict}), ((), {'colormap': ['gray', 'blue', 'red', 'green', 'yellow']}), ( (), {'colormap': [green_cmap, red_cmap, blue_cmap, blue_cmap, green_cmap]}, ), ((), {'colormap': [green_cmap, 'gray', cmap_tuple, blue_cmap, cmap_dict]}), ((), {'scale': MULTI_TUPLES}), ((), {'translate': MULTI_TUPLES}), ((), {'blending': 'translucent'}), ((), {'metadata': {'hi': 'there'}}), ((), {'metadata': {k: v for k, v in MULTI_TUPLES}}), ((), {'experimental_clipping_planes': []}), ] ids = [ 'basic_multichannel', 'one_channel', 'two_channel', 'specified_multichannel', 'split_RGB', 'list_RGB', 'names', 'contrast_limits_broadcast', 'contrast_limits_list', 'gamma_broadcast', 'gamma_list', 'visibility', 'colormap_string_broadcast', 'colormap_cmap_broadcast', 'colormap_tuple_broadcast', 'colormap_dict_broadcast', 'colormap_string_list', 'colormap_cmap_list', 'colormap_variable_list', 'scale', 'translate', 'blending', 'metadata_broadcast', 'metadata_multi', 'empty_clipping_planes', ] @pytest.mark.parametrize('shape, kwargs', multi_channel_test_data, ids=ids) def test_multichannel(shape, kwargs): """Test adding multichannel image.""" viewer = ViewerModel() np.random.seed(0) data = np.random.random(shape or (15, 10, 5)) channel_axis = kwargs.pop('channel_axis', -1) viewer.add_image(data, channel_axis=channel_axis, **kwargs) # make sure the right number of layers got added n_channels = data.shape[channel_axis] assert len(viewer.layers) == n_channels for i in range(n_channels): # make sure that the data has been divided into layers assert np.all(viewer.layers[i].data == data.take(i, axis=channel_axis)) # make sure colors have been assigned properly if 'colormap' not in kwargs: if n_channels == 1: assert viewer.layers[i].colormap.name == 'gray' elif n_channels == 2: assert viewer.layers[i].colormap.name == two_colormaps[i] else: assert viewer.layers[i].colormap.name == base_colormaps[i] if 'blending' not in kwargs: assert ( viewer.layers[i].blending == 'translucent_no_depth' if i == 0 else 'additive' ) for key, expectation in kwargs.items(): # broadcast exceptions if key in { 'scale', 'translate', 'rotate', 'shear', 'contrast_limits', 'metadata', 'experimental_clipping_planes', }: expectation = ensure_sequence_of_iterables( expectation, repeat_empty=True ) elif key == 'colormap' and expectation is not None: if isinstance(expectation, list): exp = [ensure_colormap(c).name for c in expectation] else: exp = ensure_colormap(expectation).name expectation = ensure_iterable(exp) else: expectation = ensure_iterable(expectation) expectation = [v for i, v in zip(range(i + 1), expectation)] result = getattr(viewer.layers[i], key) if key == 'colormap': # colormaps are tuples of (name, cmap) result = result.name if isinstance(result, np.ndarray): np.testing.assert_almost_equal(result, expectation[i]) else: assert result == expectation[i] def test_multichannel_multiscale(): """Test adding multichannel multiscale.""" viewer = ViewerModel() np.random.seed(0) shapes = [(40, 20, 4), (20, 10, 4), (10, 5, 4)] np.random.seed(0) data = [np.random.random(s) for s in shapes] viewer.add_image(data, channel_axis=-1, multiscale=True) assert len(viewer.layers) == data[0].shape[-1] for i in range(data[0].shape[-1]): assert np.all( [ np.all(l_d == d) for l_d, d in zip( viewer.layers[i].data, [data[j].take(i, axis=-1) for j in range(len(data))], ) ] ) assert viewer.layers[i].colormap.name == base_colormaps[i] def test_multichannel_implicit_multiscale(): """Test adding multichannel implicit multiscale.""" viewer = ViewerModel() np.random.seed(0) shapes = [(40, 20, 4), (20, 10, 4), (10, 5, 4)] np.random.seed(0) data = [np.random.random(s) for s in shapes] viewer.add_image(data, channel_axis=-1) assert len(viewer.layers) == data[0].shape[-1] for i in range(data[0].shape[-1]): assert np.all( [ np.all(l_d == d) for l_d, d in zip( viewer.layers[i].data, [data[j].take(i, axis=-1) for j in range(len(data))], ) ] ) assert viewer.layers[i].colormap.name == base_colormaps[i] def test_multichannel_dask_array(): """Test adding multichannel dask array.""" viewer = ViewerModel() np.random.seed(0) data = da.random.random((2, 10, 10, 5)) viewer.add_image(data, channel_axis=0) assert len(viewer.layers) == data.shape[0] for i in range(data.shape[0]): assert viewer.layers[i].data.shape == data.shape[1:] assert isinstance(viewer.layers[i].data, type(data)) def test_forgot_multichannel_error_hint(): """Test that a helpful error is raised when channel_axis is not used.""" viewer = ViewerModel() np.random.seed(0) data = da.random.random((15, 10, 5)) with pytest.raises(TypeError) as e: viewer.add_image(data, name=['a', 'b', 'c']) assert "did you mean to specify a 'channel_axis'" in str(e) def test_multichannel_index_error_hint(): """Test multichannel error when arg length != n_channels.""" viewer = ViewerModel() np.random.seed(0) data = da.random.random((5, 10, 5)) with pytest.raises(IndexError) as e: viewer.add_image(data, channel_axis=0, name=['a', 'b']) assert ( "Requested channel_axis (0) had length 5, but the " "'name' argument only provided 2 values." in str(e) ) napari-0.5.0a1/napari/components/_tests/test_prune_kwargs.py000066400000000000000000000036321437041365600243050ustar00rootroot00000000000000import pytest from napari.components.viewer_model import prune_kwargs TEST_KWARGS = { 'scale': (0.75, 1), 'blending': 'translucent', 'num_colors': 10, 'edge_color': 'red', 'z_index': 20, 'edge_width': 2, 'face_color': 'white', 'multiscale': False, 'name': 'name', 'extra_kwarg': 'never_included', } EXPECTATIONS = [ ( 'image', { 'scale': (0.75, 1), 'blending': 'translucent', 'multiscale': False, 'name': 'name', }, ), ( 'labels', { 'scale': (0.75, 1), 'num_colors': 10, 'multiscale': False, 'name': 'name', 'blending': 'translucent', }, ), ( 'points', { 'scale': (0.75, 1), 'blending': 'translucent', 'edge_color': 'red', 'edge_width': 2, 'face_color': 'white', 'name': 'name', }, ), ( 'shapes', { 'scale': (0.75, 1), 'edge_color': 'red', 'z_index': 20, 'edge_width': 2, 'face_color': 'white', 'name': 'name', 'blending': 'translucent', }, ), ( 'vectors', { 'scale': (0.75, 1), 'edge_color': 'red', 'edge_width': 2, 'name': 'name', 'blending': 'translucent', }, ), ( 'surface', {'blending': 'translucent', 'scale': (0.75, 1), 'name': 'name'}, ), ] ids = [i[0] for i in EXPECTATIONS] @pytest.mark.parametrize('label_type, expectation', EXPECTATIONS, ids=ids) def test_prune_kwargs(label_type, expectation): assert prune_kwargs(TEST_KWARGS, label_type) == expectation def test_prune_kwargs_raises(): with pytest.raises(ValueError): prune_kwargs({}, 'nonexistent_layer_type') napari-0.5.0a1/napari/components/_tests/test_scale_bar.py000066400000000000000000000003051437041365600235030ustar00rootroot00000000000000from napari.components.overlays.scale_bar import ScaleBarOverlay def test_scale_bar(): """Test creating scale bar object""" scale_bar = ScaleBarOverlay() assert scale_bar is not None napari-0.5.0a1/napari/components/_tests/test_text_overlay.py000066400000000000000000000002121437041365600243120ustar00rootroot00000000000000from napari.components.overlays.text import TextOverlay def test_text_overlay(): label = TextOverlay() assert label is not None napari-0.5.0a1/napari/components/_tests/test_viewer_keybindings.py000066400000000000000000000041711437041365600254640ustar00rootroot00000000000000from napari.components._viewer_key_bindings import toggle_theme from napari.components.viewer_model import ViewerModel from napari.settings import get_settings from napari.utils.theme import available_themes, get_system_theme def test_theme_toggle_keybinding(): viewer = ViewerModel() assert viewer.theme == get_settings().appearance.theme assert not viewer.theme == 'light' toggle_theme(viewer) # toggle_theme should not change settings assert not get_settings().appearance.theme == 'light' # toggle_theme should change the viewer theme assert viewer.theme == 'light' # ensure toggle_theme loops through all themes initial_theme = viewer.theme number_of_actual_themes = len(available_themes()) if 'system' in available_themes(): number_of_actual_themes = len(available_themes()) - 1 for _i in range(number_of_actual_themes): current_theme = viewer.theme toggle_theme(viewer) # theme should have changed assert not viewer.theme == current_theme # toggle_theme should toggle only actual themes assert not viewer.theme == 'system' # ensure we're back at the initial theme assert viewer.theme == initial_theme def test_theme_toggle_from_system_theme(): get_settings().appearance.theme = 'system' viewer = ViewerModel() assert viewer.theme == 'system' actual_initial_theme = get_system_theme() toggle_theme(viewer) # ensure that theme has changed assert not viewer.theme == actual_initial_theme assert not viewer.theme == 'system' number_of_actual_themes = len(available_themes()) if 'system' in available_themes(): number_of_actual_themes = len(available_themes()) - 1 for _i in range(number_of_actual_themes - 1): # we've already toggled once current_theme = viewer.theme toggle_theme(viewer) # theme should have changed assert not viewer.theme == current_theme # toggle_theme should toggle only actual themes assert not viewer.theme == 'system' # ensure we have looped back to whatever system was assert viewer.theme == actual_initial_theme napari-0.5.0a1/napari/components/_tests/test_viewer_labels_io.py000066400000000000000000000013231437041365600251030ustar00rootroot00000000000000import numpy as np import pytest from imageio import imwrite from scipy import ndimage as ndi from skimage.data import binary_blobs from napari.components import ViewerModel from napari.layers import Labels @pytest.mark.parametrize('suffix', ['.png', '.tiff']) def test_open_labels(builtins, suffix, tmp_path): viewer = ViewerModel() blobs = binary_blobs(length=128, volume_fraction=0.1, n_dim=2) labeled = ndi.label(blobs)[0].astype(np.uint8) fout = str(tmp_path / f'test{suffix}') imwrite(fout, labeled, format=suffix) viewer.open(fout, layer_type='labels') assert len(viewer.layers) == 1 assert np.all(labeled == viewer.layers[0].data) assert isinstance(viewer.layers[0], Labels) napari-0.5.0a1/napari/components/_tests/test_viewer_model.py000066400000000000000000000725641437041365600242710ustar00rootroot00000000000000import time import numpy as np import pytest from npe2 import DynamicPlugin from napari._tests.utils import good_layer_data, layer_test_data from napari.components import ViewerModel from napari.errors import MultipleReaderError, ReaderPluginError from napari.errors.reader_errors import NoAvailableReaderError from napari.layers import Image from napari.settings import get_settings from napari.utils.colormaps import AVAILABLE_COLORMAPS, Colormap from napari.utils.events.event import WarningEmitter def test_viewer_model(): """Test instantiating viewer model.""" viewer = ViewerModel() assert viewer.title == 'napari' assert len(viewer.layers) == 0 assert viewer.dims.ndim == 2 # Create viewer model with custom title viewer = ViewerModel(title='testing') assert viewer.title == 'testing' def test_add_image(): """Test adding image.""" viewer = ViewerModel() np.random.seed(0) data = np.random.random((10, 15)) viewer.add_image(data) assert len(viewer.layers) == 1 assert np.all(viewer.layers[0].data == data) assert viewer.dims.ndim == 2 def test_add_image_multichannel_share_memory(): viewer = ViewerModel() image = np.random.random((10, 5, 64, 64)) layers = viewer.add_image(image, channel_axis=1) for layer in layers: assert np.may_share_memory(image, layer.data) def test_add_image_colormap_variants(): """Test adding image with all valid colormap argument types.""" viewer = ViewerModel() np.random.seed(0) data = np.random.random((10, 15)) # as string assert viewer.add_image(data, colormap='green') # as string that is valid, but not a default colormap assert viewer.add_image(data, colormap='fire') # as tuple cmap_tuple = ("my_colormap", Colormap(['g', 'm', 'y'])) assert viewer.add_image(data, colormap=cmap_tuple) # as dict cmap_dict = {"your_colormap": Colormap(['g', 'r', 'y'])} assert viewer.add_image(data, colormap=cmap_dict) # as Colormap instance blue_cmap = AVAILABLE_COLORMAPS['blue'] assert viewer.add_image(data, colormap=blue_cmap) # string values must be known colormap types with pytest.raises(KeyError) as err: viewer.add_image(data, colormap='nonsense') assert 'Colormap "nonsense" not found' in str(err.value) # lists are only valid with channel_axis with pytest.raises(TypeError) as err: viewer.add_image(data, colormap=['green', 'red']) assert "did you mean to specify a 'channel_axis'" in str(err.value) def test_add_volume(): """Test adding volume.""" viewer = ViewerModel(ndisplay=3) np.random.seed(0) data = np.random.random((10, 15, 20)) viewer.add_image(data) assert len(viewer.layers) == 1 assert np.all(viewer.layers[0].data == data) assert viewer.dims.ndim == 3 def test_add_multiscale(): """Test adding image multiscale.""" viewer = ViewerModel() shapes = [(40, 20), (20, 10), (10, 5)] np.random.seed(0) data = [np.random.random(s) for s in shapes] viewer.add_image(data, multiscale=True) assert len(viewer.layers) == 1 assert np.all(viewer.layers[0].data == data) assert viewer.dims.ndim == 2 def test_add_multiscale_image_with_negative_floats(): """See https://github.com/napari/napari/issues/5257""" viewer = ViewerModel() shapes = [(20, 10), (10, 5)] data = [np.zeros(s, dtype=np.float64) for s in shapes] data[0][-4:, -2:] = -1 data[1][-2:, -1:] = -1 viewer.add_image(data, multiscale=True) assert len(viewer.layers) == 1 assert np.all(viewer.layers[0].data == data) assert viewer.dims.ndim == 2 def test_add_labels(): """Test adding labels image.""" viewer = ViewerModel() np.random.seed(0) data = np.random.randint(20, size=(10, 15)) viewer.add_labels(data) assert len(viewer.layers) == 1 assert np.all(viewer.layers[0].data == data) assert viewer.dims.ndim == 2 def test_add_points(): """Test adding points.""" viewer = ViewerModel() np.random.seed(0) data = 20 * np.random.random((10, 2)) viewer.add_points(data) assert len(viewer.layers) == 1 assert np.all(viewer.layers[0].data == data) assert viewer.dims.ndim == 2 def test_single_point_dims(): """Test dims of a Points layer with a single 3D point.""" viewer = ViewerModel() shape = (1, 3) data = np.zeros(shape) viewer.add_points(data) assert all(r == (0.0, 1.0, 1.0) for r in viewer.dims.range) def test_add_empty_points_to_empty_viewer(): viewer = ViewerModel() layer = viewer.add_points(name='empty points') assert layer.ndim == 2 layer.add([1000.0, 27.0]) assert layer.data.shape == (1, 2) def test_add_empty_points_on_top_of_image(): viewer = ViewerModel() image = np.random.random((8, 64, 64)) # add_image always returns the corresponding layer _ = viewer.add_image(image) layer = viewer.add_points(ndim=3) assert layer.ndim == 3 layer.add([5.0, 32.0, 61.0]) assert layer.data.shape == (1, 3) def test_add_empty_shapes_layer(): viewer = ViewerModel() image = np.random.random((8, 64, 64)) # add_image always returns the corresponding layer _ = viewer.add_image(image) layer = viewer.add_shapes(ndim=3) assert layer.ndim == 3 def test_add_vectors(): """Test adding vectors.""" viewer = ViewerModel() np.random.seed(0) data = 20 * np.random.random((10, 2, 2)) viewer.add_vectors(data) assert len(viewer.layers) == 1 assert np.all(viewer.layers[0].data == data) assert viewer.dims.ndim == 2 def test_add_shapes(): """Test adding shapes.""" viewer = ViewerModel() np.random.seed(0) data = 20 * np.random.random((10, 4, 2)) viewer.add_shapes(data) assert len(viewer.layers) == 1 assert np.all(viewer.layers[0].data == data) assert viewer.dims.ndim == 2 def test_add_surface(): """Test adding 3D surface.""" viewer = ViewerModel() np.random.seed(0) vertices = np.random.random((10, 3)) faces = np.random.randint(10, size=(6, 3)) values = np.random.random(10) data = (vertices, faces, values) viewer.add_surface(data) assert len(viewer.layers) == 1 assert np.all( [np.all(vd == d) for vd, d in zip(viewer.layers[0].data, data)] ) assert viewer.dims.ndim == 3 def test_mix_dims(): """Test adding images of mixed dimensionality.""" viewer = ViewerModel() np.random.seed(0) data = np.random.random((10, 15)) viewer.add_image(data) assert len(viewer.layers) == 1 assert np.all(viewer.layers[0].data == data) assert viewer.dims.ndim == 2 data = np.random.random((6, 10, 15)) viewer.add_image(data) assert len(viewer.layers) == 2 assert np.all(viewer.layers[1].data == data) assert viewer.dims.ndim == 3 def test_new_labels_empty(): """Test adding new labels layer to empty viewer.""" viewer = ViewerModel() viewer._new_labels() assert len(viewer.layers) == 1 assert np.max(viewer.layers[0].data) == 0 assert viewer.dims.ndim == 2 # Default shape when no data is present is 512x512 np.testing.assert_equal(viewer.layers[0].data.shape, (512, 512)) def test_new_labels_image(): """Test adding new labels layer with image present.""" viewer = ViewerModel() np.random.seed(0) data = np.random.random((10, 15)) viewer.add_image(data) viewer._new_labels() assert len(viewer.layers) == 2 assert np.max(viewer.layers[1].data) == 0 assert viewer.dims.ndim == 2 np.testing.assert_equal(viewer.layers[1].data.shape, (10, 15)) np.testing.assert_equal(viewer.layers[1].scale, (1, 1)) np.testing.assert_equal(viewer.layers[1].translate, (0, 0)) def test_new_labels_scaled_image(): """Test adding new labels layer with scaled image present.""" viewer = ViewerModel() np.random.seed(0) data = np.random.random((10, 15)) viewer.add_image(data, scale=(3, 3)) viewer._new_labels() assert len(viewer.layers) == 2 assert np.max(viewer.layers[1].data) == 0 assert viewer.dims.ndim == 2 np.testing.assert_equal(viewer.layers[1].data.shape, (10, 15)) np.testing.assert_equal(viewer.layers[1].scale, (3, 3)) np.testing.assert_equal(viewer.layers[1].translate, (0, 0)) def test_new_labels_scaled_translated_image(): """Test adding new labels layer with transformed image present.""" viewer = ViewerModel() np.random.seed(0) data = np.random.random((10, 15)) viewer.add_image(data, scale=(3, 3), translate=(20, -5)) viewer._new_labels() assert len(viewer.layers) == 2 assert np.max(viewer.layers[1].data) == 0 assert viewer.dims.ndim == 2 np.testing.assert_almost_equal(viewer.layers[1].data.shape, (10, 15)) np.testing.assert_almost_equal(viewer.layers[1].scale, (3, 3)) np.testing.assert_almost_equal(viewer.layers[1].translate, (20, -5)) def test_new_points(): """Test adding new points layer.""" # Add labels to empty viewer viewer = ViewerModel() viewer.add_points() assert len(viewer.layers) == 1 assert len(viewer.layers[0].data) == 0 assert viewer.dims.ndim == 2 # Add points with image already present viewer = ViewerModel() np.random.seed(0) data = np.random.random((10, 15)) viewer.add_image(data) viewer.add_points() assert len(viewer.layers) == 2 assert len(viewer.layers[1].data) == 0 assert viewer.dims.ndim == 2 def test_view_centering_with_points_add(): """Test if the viewer is only centered when the first points were added Regression test for issue #3803 """ image = np.zeros((5, 10, 10)) viewer = ViewerModel() viewer.add_image(image) assert tuple(viewer.dims.point) == (2, 5, 5) viewer.dims.set_point(0, 0) # viewer point shouldn't change after this assert tuple(viewer.dims.point) == (0, 5, 5) pts_layer = viewer.add_points(ndim=3) assert tuple(viewer.dims.point) == (0, 5, 5) pts_layer.add([(0, 8, 8)]) assert tuple(viewer.dims.point) == (0, 5, 5) def test_new_shapes(): """Test adding new shapes layer.""" # Add labels to empty viewer viewer = ViewerModel() viewer.add_shapes() assert len(viewer.layers) == 1 assert len(viewer.layers[0].data) == 0 assert viewer.dims.ndim == 2 # Add points with image already present viewer = ViewerModel() np.random.seed(0) data = np.random.random((10, 15)) viewer.add_image(data) viewer.add_shapes() assert len(viewer.layers) == 2 assert len(viewer.layers[1].data) == 0 assert viewer.dims.ndim == 2 def test_swappable_dims(): """Test swapping dims after adding layers.""" viewer = ViewerModel() np.random.seed(0) image_data = np.random.random((7, 12, 10, 15)) image_name = viewer.add_image(image_data).name assert np.all( viewer.layers[image_name]._data_view == image_data[3, 6, :, :] ) points_data = np.random.randint(6, size=(10, 4)) viewer.add_points(points_data) vectors_data = np.random.randint(6, size=(10, 2, 4)) viewer.add_vectors(vectors_data) labels_data = np.random.randint(20, size=(7, 12, 10, 15)) labels_name = viewer.add_labels(labels_data).name # midpoints indices into the data below depend on the data range. # This depends on the values in vectors_data and thus the random seed. assert np.all( viewer.layers[labels_name]._slice.image.raw == labels_data[3, 6, :, :] ) # Swap dims viewer.dims.order = [0, 2, 1, 3] assert viewer.dims.order == (0, 2, 1, 3) assert np.all( viewer.layers[image_name]._data_view == image_data[3, :, 5, :] ) assert np.all( viewer.layers[labels_name]._slice.image.raw == labels_data[3, :, 5, :] ) def test_grid(): "Test grid_view" viewer = ViewerModel() np.random.seed(0) # Add image for _i in range(6): data = np.random.random((15, 15)) viewer.add_image(data) assert not viewer.grid.enabled assert viewer.grid.actual_shape(6) == (1, 1) assert viewer.grid.stride == 1 translations = [layer._translate_grid for layer in viewer.layers] expected_translations = np.zeros((6, 2)) np.testing.assert_allclose(translations, expected_translations) # enter grid view viewer.grid.enabled = True assert viewer.grid.enabled assert viewer.grid.actual_shape(6) == (2, 3) assert viewer.grid.stride == 1 translations = [layer._translate_grid for layer in viewer.layers] expected_translations = [ [0, 0], [0, 15], [0, 30], [15, 0], [15, 15], [15, 30], ] np.testing.assert_allclose(translations, expected_translations[::-1]) # return to stack view viewer.grid.enabled = False assert not viewer.grid.enabled assert viewer.grid.actual_shape(6) == (1, 1) assert viewer.grid.stride == 1 translations = [layer._translate_grid for layer in viewer.layers] expected_translations = np.zeros((6, 2)) np.testing.assert_allclose(translations, expected_translations) # reenter grid view with new stride viewer.grid.stride = -2 viewer.grid.enabled = True assert viewer.grid.enabled assert viewer.grid.actual_shape(6) == (2, 2) assert viewer.grid.stride == -2 translations = [layer._translate_grid for layer in viewer.layers] expected_translations = [ [0, 0], [0, 0], [0, 15], [0, 15], [15, 0], [15, 0], ] np.testing.assert_allclose(translations, expected_translations) def test_add_remove_layer_dims_change(): """Test dims change appropriately when adding and removing layers.""" np.random.seed(0) viewer = ViewerModel() # Check ndim starts at 2 assert viewer.dims.ndim == 2 # Check ndim increase to 3 when 3D data added data = np.random.random((10, 15, 20)) layer = viewer.add_image(data) assert len(viewer.layers) == 1 assert np.all(viewer.layers[0].data == data) assert viewer.dims.ndim == 3 # Remove layer and check ndim returns to 2 viewer.layers.remove(layer) assert len(viewer.layers) == 0 assert viewer.dims.ndim == 2 @pytest.mark.parametrize('data', good_layer_data) def test_add_layer_from_data(data): # make sure adding valid layer data calls the proper corresponding add_* # method for all layer types viewer = ViewerModel() viewer._add_layer_from_data(*data) # make sure a layer of the correct type got added assert len(viewer.layers) == 1 expected_layer_type = data[2] if len(data) > 2 else 'image' assert viewer.layers[0]._type_string == expected_layer_type def test_add_layer_from_data_raises(): # make sure that adding invalid data or kwargs raises the right errors viewer = ViewerModel() # unrecognized layer type raises Value Error with pytest.raises(ValueError): # 'layer' is not a valid type # (even though there is an add_layer method) viewer._add_layer_from_data( np.random.random((10, 10)), layer_type='layer' ) # even with the correct meta kwargs, the underlying add_* method may raise with pytest.raises(ValueError): # improper dims for rgb data viewer._add_layer_from_data( np.random.random((10, 10, 6)), {'rgb': True} ) # using a kwarg in the meta dict that is invalid for the corresponding # add_* method raises a TypeError with pytest.raises(TypeError): viewer._add_layer_from_data( np.random.random((10, 2, 2)) * 20, {'rgb': True}, # vectors do not have an 'rgb' kwarg layer_type='vectors', ) def test_naming(): """Test unique naming in LayerList.""" viewer = ViewerModel() viewer.add_image(np.random.random((10, 10)), name='img') viewer.add_image(np.random.random((10, 10)), name='img') assert [lay.name for lay in viewer.layers] == ['img', 'img [1]'] viewer.layers[1].name = 'chg' assert [lay.name for lay in viewer.layers] == ['img', 'chg'] viewer.layers[0].name = 'chg' assert [lay.name for lay in viewer.layers] == ['chg [1]', 'chg'] def test_selection(): """Test only last added is selected.""" viewer = ViewerModel() viewer.add_image(np.random.random((10, 10))) assert viewer.layers[0] in viewer.layers.selection viewer.add_image(np.random.random((10, 10))) assert viewer.layers.selection == {viewer.layers[-1]} viewer.add_image(np.random.random((10, 10))) assert viewer.layers.selection == {viewer.layers[-1]} viewer.layers.selection.update(viewer.layers) viewer.add_image(np.random.random((10, 10))) assert viewer.layers.selection == {viewer.layers[-1]} def test_add_delete_layers(): """Test adding and deleting layers with different dims.""" viewer = ViewerModel() np.random.seed(0) viewer.add_image(np.random.random((5, 5, 10, 15))) assert len(viewer.layers) == 1 assert viewer.dims.ndim == 4 viewer.add_image(np.random.random((5, 6, 5, 10, 15))) assert len(viewer.layers) == 2 assert viewer.dims.ndim == 5 viewer.layers.remove_selected() assert len(viewer.layers) == 1 assert viewer.dims.ndim == 4 def test_active_layer(): """Test active layer is correct as layer selections change.""" viewer = ViewerModel() np.random.seed(0) # Check no active layer present assert viewer.layers.selection.active is None # Check added layer is active viewer.add_image(np.random.random((5, 5, 10, 15))) assert len(viewer.layers) == 1 assert viewer.layers.selection.active == viewer.layers[0] # Check newly added layer is active viewer.add_image(np.random.random((5, 6, 5, 10, 15))) assert len(viewer.layers) == 2 assert viewer.layers.selection.active == viewer.layers[1] # Check no active layer after unselecting all viewer.layers.selection.clear() assert viewer.layers.selection.active is None # Check selected layer is active viewer.layers.selection.add(viewer.layers[0]) assert viewer.layers.selection.active == viewer.layers[0] # Check no layer is active if both layers are selected viewer.layers.selection.add(viewer.layers[1]) assert viewer.layers.selection.active is None def test_active_layer_status_update(): """Test status updates from active layer on cursor move.""" viewer = ViewerModel() np.random.seed(0) viewer.add_image(np.random.random((5, 5, 10, 15))) viewer.add_image(np.random.random((5, 6, 5, 10, 15))) assert len(viewer.layers) == 2 assert viewer.layers.selection.active == viewer.layers[1] # wait 1 s to avoid the cursor event throttling time.sleep(1) viewer.mouse_over_canvas = True viewer.cursor.position = [1, 1, 1, 1, 1] assert viewer.status == viewer.layers.selection.active.get_status( viewer.cursor.position, world=True ) def test_active_layer_cursor_size(): """Test cursor size update on active layer.""" viewer = ViewerModel() np.random.seed(0) viewer.add_image(np.random.random((10, 10))) # Base layer has a default cursor size of 1 assert viewer.cursor.size == 1 viewer.add_labels(np.random.randint(0, 10, size=(10, 10))) assert len(viewer.layers) == 2 assert viewer.layers.selection.active == viewer.layers[1] viewer.layers[1].mode = 'paint' # Labels layer has a default cursor size of 10 # due to paintbrush assert viewer.cursor.size == 10 def test_cursor_ndim_matches_layer(): """Test cursor position ndim matches viewer ndim after update.""" viewer = ViewerModel() np.random.seed(0) im = viewer.add_image(np.random.random((10, 10))) assert viewer.dims.ndim == 2 assert len(viewer.cursor.position) == 2 im.data = np.random.random((10, 10, 10)) assert viewer.dims.ndim == 3 assert len(viewer.cursor.position) == 3 im.data = np.random.random((10, 10)) assert viewer.dims.ndim == 2 assert len(viewer.cursor.position) == 2 def test_sliced_world_extent(): """Test world extent after adding layers and slicing.""" np.random.seed(0) viewer = ViewerModel() # Empty data is taken to be 512 x 512 np.testing.assert_allclose(viewer._sliced_extent_world[0], (-0.5, -0.5)) np.testing.assert_allclose(viewer._sliced_extent_world[1], (511.5, 511.5)) # Add one layer viewer.add_image( np.random.random((6, 10, 15)), scale=(3, 1, 1), translate=(10, 20, 5) ) np.testing.assert_allclose(viewer.layers.extent.world[0], (8.5, 19.5, 4.5)) np.testing.assert_allclose( viewer.layers.extent.world[1], (26.5, 29.5, 19.5) ) np.testing.assert_allclose(viewer._sliced_extent_world[0], (19.5, 4.5)) np.testing.assert_allclose(viewer._sliced_extent_world[1], (29.5, 19.5)) # Change displayed dims order viewer.dims.order = (1, 2, 0) np.testing.assert_allclose(viewer._sliced_extent_world[0], (4.5, 8.5)) np.testing.assert_allclose(viewer._sliced_extent_world[1], (19.5, 26.5)) def test_camera(): """Test camera.""" viewer = ViewerModel() np.random.seed(0) data = np.random.random((10, 15, 20)) viewer.add_image(data) assert len(viewer.layers) == 1 assert np.all(viewer.layers[0].data == data) assert viewer.dims.ndim == 3 assert viewer.dims.ndisplay == 2 assert viewer.camera.center == (0, 7, 9.5) assert viewer.camera.angles == (0, 0, 90) viewer.dims.ndisplay = 3 assert viewer.dims.ndisplay == 3 assert viewer.camera.center == (4.5, 7, 9.5) assert viewer.camera.angles == (0, 0, 90) viewer.dims.ndisplay = 2 assert viewer.dims.ndisplay == 2 assert viewer.camera.center == (0, 7, 9.5) assert viewer.camera.angles == (0, 0, 90) def test_update_scale(): viewer = ViewerModel() np.random.seed(0) shape = (10, 15, 20) data = np.random.random(shape) viewer.add_image(data) assert viewer.dims.range == tuple((0.0, x, 1.0) for x in shape) scale = (3.0, 2.0, 1.0) viewer.layers[0].scale = scale assert viewer.dims.range == tuple( (0.0, x * s, s) for x, s in zip(shape, scale) ) @pytest.mark.parametrize('Layer, data, ndim', layer_test_data) def test_add_remove_layer_no_callbacks(Layer, data, ndim): """Test all callbacks for layer emmitters removed.""" viewer = ViewerModel() layer = Layer(data) # Check layer has been correctly created assert layer.ndim == ndim # Check that no internal callbacks have been registered assert len(layer.events.callbacks) == 0 for em in layer.events.emitters.values(): assert len(em.callbacks) == 0 viewer.layers.append(layer) # Check layer added correctly assert len(viewer.layers) == 1 # check that adding a layer created new callbacks assert any(len(em.callbacks) > 0 for em in layer.events.emitters.values()) viewer.layers.remove(layer) # Check layer added correctly assert len(viewer.layers) == 0 # Check that all callbacks have been removed assert len(layer.events.callbacks) == 0 for em in layer.events.emitters.values(): assert len(em.callbacks) == 0 @pytest.mark.parametrize('Layer, data, ndim', layer_test_data) def test_add_remove_layer_external_callbacks(Layer, data, ndim): """Test external callbacks for layer emmitters preserved.""" viewer = ViewerModel() layer = Layer(data) # Check layer has been correctly created assert layer.ndim == ndim # Connect a custom callback def my_custom_callback(): return layer.events.connect(my_custom_callback) # Check that no internal callbacks have been registered assert len(layer.events.callbacks) == 1 for em in layer.events.emitters.values(): if not isinstance(em, WarningEmitter): assert len(em.callbacks) == 1 viewer.layers.append(layer) # Check layer added correctly assert len(viewer.layers) == 1 # check that adding a layer created new callbacks assert any(len(em.callbacks) > 0 for em in layer.events.emitters.values()) viewer.layers.remove(layer) # Check layer added correctly assert len(viewer.layers) == 0 # Check that all internal callbacks have been removed assert len(layer.events.callbacks) == 1 for em in layer.events.emitters.values(): if not isinstance(em, WarningEmitter): assert len(em.callbacks) == 1 @pytest.mark.parametrize( 'field', ['camera', 'cursor', 'dims', 'grid', 'layers'] ) def test_not_mutable_fields(field): """Test appropriate fields are not mutable.""" viewer = ViewerModel() # Check attribute lives on the viewer assert hasattr(viewer, field) # Check attribute does not have an event emitter assert not hasattr(viewer.events, field) # Check attribute is not settable with pytest.raises((TypeError, ValueError)) as err: setattr(viewer, field, 'test') assert 'has allow_mutation set to False and cannot be assigned' in str( err.value ) @pytest.mark.parametrize('Layer, data, ndim', layer_test_data) def test_status_tooltip(Layer, data, ndim): viewer = ViewerModel() viewer.tooltip.visible = True layer = Layer(data) viewer.layers.append(layer) viewer.cursor.position = (1,) * ndim def test_viewer_object_event_sources(): viewer = ViewerModel() assert viewer.cursor.events.source is viewer.cursor assert viewer.camera.events.source is viewer.camera def test_open_or_get_error_multiple_readers(tmp_plugin: DynamicPlugin): """Assert error is returned when multiple plugins are available to read.""" viewer = ViewerModel() tmp2 = tmp_plugin.spawn(register=True) @tmp_plugin.contribute.reader(filename_patterns=['*.fake']) def _(path): ... @tmp2.contribute.reader(filename_patterns=['*.fake']) def _(path): ... with pytest.raises( MultipleReaderError, match='Multiple plugins found capable' ): viewer._open_or_raise_error(['my_file.fake']) def test_open_or_get_error_no_plugin(): """Assert error is raised when no plugin is available.""" viewer = ViewerModel() with pytest.raises( NoAvailableReaderError, match='No plugin found capable of reading' ): viewer._open_or_raise_error(['my_file.fake']) def test_open_or_get_error_builtins(builtins: DynamicPlugin, tmp_path): """Test builtins is available to read npy files.""" viewer = ViewerModel() f_pth = tmp_path / 'my-file.npy' data = np.random.random((10, 10)) np.save(f_pth, data) added = viewer._open_or_raise_error([str(f_pth)]) assert len(added) == 1 layer = added[0] assert isinstance(layer, Image) np.testing.assert_allclose(layer.data, data) assert layer.source.reader_plugin == builtins.name def test_open_or_get_error_prefered_plugin( tmp_path, builtins: DynamicPlugin, tmp_plugin: DynamicPlugin ): """Test plugin preference is respected.""" viewer = ViewerModel() pth = tmp_path / 'my-file.npy' np.save(pth, np.random.random((10, 10))) @tmp_plugin.contribute.reader(filename_patterns=['*.npy']) def _(path): ... get_settings().plugins.extension2reader = {'*.npy': builtins.name} added = viewer._open_or_raise_error([str(pth)]) assert len(added) == 1 assert added[0].source.reader_plugin == builtins.name def test_open_or_get_error_cant_find_plugin(tmp_path, builtins: DynamicPlugin): """Test user is warned and only plugin used if preferred plugin missing.""" viewer = ViewerModel() pth = tmp_path / 'my-file.npy' np.save(pth, np.random.random((10, 10))) get_settings().plugins.extension2reader = {'*.npy': 'fake-reader'} with pytest.warns(RuntimeWarning, match="Can't find fake-reader plugin"): added = viewer._open_or_raise_error([str(pth)]) assert len(added) == 1 assert added[0].source.reader_plugin == builtins.name def test_open_or_get_error_no_prefered_plugin_many_available( tmp_plugin: DynamicPlugin, ): """Test MultipleReaderError raised if preferred plugin missing.""" viewer = ViewerModel() tmp2 = tmp_plugin.spawn(register=True) @tmp_plugin.contribute.reader(filename_patterns=['*.fake']) def _(path): ... @tmp2.contribute.reader(filename_patterns=['*.fake']) def _(path): ... get_settings().plugins.extension2reader = {'*.fake': 'not-a-plugin'} with pytest.warns(RuntimeWarning, match="Can't find not-a-plugin plugin"): with pytest.raises( MultipleReaderError, match='Multiple plugins found capable' ): viewer._open_or_raise_error(['my_file.fake']) def test_open_or_get_error_preferred_fails(builtins, tmp_path): viewer = ViewerModel() pth = tmp_path / 'my-file.npy' get_settings().plugins.extension2reader = {'*.npy': builtins.name} with pytest.raises( ReaderPluginError, match='Tried opening with napari, but failed.' ): viewer._open_or_raise_error([str(pth)]) def test_slice_order_with_mixed_dims(): viewer = ViewerModel(ndisplay=2) image_2d = viewer.add_image(np.zeros((4, 5))) image_3d = viewer.add_image(np.zeros((3, 4, 5))) image_4d = viewer.add_image(np.zeros((2, 3, 4, 5))) # With standard ordering, the shapes of the slices match, # so are trivially numpy-broadcastable. assert image_2d._slice.image.view.shape == (4, 5) assert image_3d._slice.image.view.shape == (4, 5) assert image_4d._slice.image.view.shape == (4, 5) viewer.dims.order = (2, 1, 0, 3) # With non-standard ordering, the shapes of the slices do not match, # and are not numpy-broadcastable. assert image_2d._slice.image.view.shape == (4, 5) assert image_3d._slice.image.view.shape == (3, 5) assert image_4d._slice.image.view.shape == (2, 5) napari-0.5.0a1/napari/components/_tests/test_viewer_mouse_bindings.py000066400000000000000000000047121437041365600261640ustar00rootroot00000000000000import collections import numpy as np import pytest from napari.components import ViewerModel from napari.utils._proxies import ReadOnlyWrapper from napari.utils.interactions import mouse_wheel_callbacks @pytest.fixture def mouse_event(): """Create a subclass for simulating vispy mouse events. Returns ------- Event : Type A new tuple subclass named Event that can be used to create a NamedTuple object with fields "delta", "modifiers" and "inverted". """ return collections.namedtuple( 'Event', field_names=['delta', 'modifiers', 'native'] ) class WheelEvent: def __init__(self, inverted) -> None: self._inverted = inverted def inverted(self): return self._inverted @pytest.mark.parametrize( "modifiers, native, expected_dim", [ ([], WheelEvent(True), [[5, 5, 5], [5, 5, 5], [5, 5, 5], [5, 5, 5]]), ( ["Control"], WheelEvent(False), [[5, 5, 5], [5, 5, 4], [5, 5, 3], [5, 5, 0]], ), ( ["Control"], WheelEvent(True), [[5, 5, 5], [5, 5, 6], [5, 5, 7], [5, 5, 9]], ), ], ) def test_paint(mouse_event, modifiers, native, expected_dim): """Test painting labels with circle/square brush.""" viewer = ViewerModel() data = np.random.random((10, 10, 10)) viewer.add_image(data) viewer.dims.last_used = 2 viewer.dims.set_point(axis=0, value=5) viewer.dims.set_point(axis=1, value=5) viewer.dims.set_point(axis=2, value=5) # Simulate tiny scroll event = ReadOnlyWrapper( mouse_event(delta=[0, 0.6], modifiers=modifiers, native=native) ) mouse_wheel_callbacks(viewer, event) assert np.equal(viewer.dims.point, expected_dim[0]).all() # Simulate tiny scroll event = ReadOnlyWrapper( mouse_event(delta=[0, 0.6], modifiers=modifiers, native=native) ) mouse_wheel_callbacks(viewer, event) assert np.equal(viewer.dims.point, expected_dim[1]).all() # Simulate tiny scroll event = ReadOnlyWrapper( mouse_event(delta=[0, 0.9], modifiers=modifiers, native=native) ) mouse_wheel_callbacks(viewer, event) assert np.equal(viewer.dims.point, expected_dim[2]).all() # Simulate large scroll event = ReadOnlyWrapper( mouse_event(delta=[0, 3], modifiers=modifiers, native=native) ) mouse_wheel_callbacks(viewer, event) assert np.equal(viewer.dims.point, expected_dim[3]).all() napari-0.5.0a1/napari/components/_tests/test_world_coordinates.py000066400000000000000000000067441437041365600253260ustar00rootroot00000000000000import warnings import numpy as np import pytest from napari.components import ViewerModel def test_translated_images(): """Test two translated images.""" viewer = ViewerModel() np.random.seed(0) data = np.random.random((10, 10, 10)) viewer.add_image(data) viewer.add_image(data, translate=[10, 0, 0]) assert viewer.dims.range[0] == (0, 20, 1) assert viewer.dims.range[1] == (0, 10, 1) assert viewer.dims.range[2] == (0, 10, 1) assert viewer.dims.nsteps == (20, 10, 10) for i in range(viewer.dims.nsteps[0]): viewer.dims.set_current_step(0, i) assert viewer.dims.current_step[0] == i def test_scaled_images(): """Test two scaled images.""" viewer = ViewerModel() np.random.seed(0) data = np.random.random((10, 10, 10)) viewer.add_image(data) viewer.add_image(data[::2], scale=[2, 1, 1]) assert viewer.dims.range[0] == ( -0.5, 10, 1, ) # TODO: non-integer with mixed scale? assert viewer.dims.range[1] == (0, 10, 1) assert viewer.dims.range[2] == (0, 10, 1) assert viewer.dims.nsteps == (10, 10, 10) for i in range(viewer.dims.nsteps[0]): viewer.dims.set_current_step(0, i) assert viewer.dims.current_step[0] == i def test_scaled_and_translated_images(): """Test scaled and translated images.""" viewer = ViewerModel() np.random.seed(0) data = np.random.random((10, 10, 10)) viewer.add_image(data) viewer.add_image(data[::2], scale=[2, 1, 1], translate=[10, 0, 0]) assert viewer.dims.range[0] == ( 0, 19.5, 1, ) # TODO: non-integer with mixed scale? assert viewer.dims.range[1] == (0, 10, 1) assert viewer.dims.range[2] == (0, 10, 1) assert viewer.dims.nsteps == (19, 10, 10) for i in range(viewer.dims.nsteps[0]): viewer.dims.set_current_step(0, i) assert viewer.dims.current_step[0] == i def test_both_scaled_and_translated_images(): """Test both scaled and translated images.""" viewer = ViewerModel() np.random.seed(0) data = np.random.random((10, 10, 10)) viewer.add_image(data, scale=[2, 1, 1]) viewer.add_image(data, scale=[2, 1, 1], translate=[20, 0, 0]) assert viewer.dims.range[0] == (0, 40, 2) assert viewer.dims.range[1] == (0, 10, 1) assert viewer.dims.range[2] == (0, 10, 1) assert viewer.dims.nsteps == (20, 10, 10) for i in range(viewer.dims.nsteps[0]): viewer.dims.set_current_step(0, i) assert viewer.dims.current_step[0] == i def test_no_warning_non_affine_slicing(): """Test no warning if not slicing into an affine.""" viewer = ViewerModel() np.random.seed(0) data = np.random.random((10, 10, 10)) viewer.add_image(data, scale=[2, 1, 1], translate=[10, 15, 20]) with warnings.catch_warnings(record=True) as recorded_warnings: viewer.layers[0].refresh() assert len(recorded_warnings) == 0 def test_warning_affine_slicing(): """Test warning if slicing into an affine.""" viewer = ViewerModel() np.random.seed(0) data = np.random.random((10, 10, 10)) with pytest.warns(UserWarning) as wrn: viewer.add_image( data, scale=[2, 1, 1], translate=[10, 15, 20], shear=[[1, 0, 0], [0, 1, 0], [4, 0, 1]], ) assert 'Non-orthogonal slicing is being requested' in str(wrn[0].message) with pytest.warns(UserWarning) as recorded_warnings: viewer.layers[0].refresh() assert len(recorded_warnings) == 1 napari-0.5.0a1/napari/components/_viewer_constants.py000066400000000000000000000022541437041365600227710ustar00rootroot00000000000000from enum import Enum class CanvasPosition(str, Enum): """Canvas overlay position. Sets the position of an object in the canvas * top_left: Top left of the canvas * top_right: Top right of the canvas * top_center: Top center of the canvas * bottom_right: Bottom right of the canvas * bottom_left: Bottom left of the canvas * bottom_center: Bottom center of the canvas """ TOP_LEFT = 'top_left' TOP_CENTER = "top_center" TOP_RIGHT = 'top_right' BOTTOM_RIGHT = 'bottom_right' BOTTOM_CENTER = "bottom_center" BOTTOM_LEFT = 'bottom_left' class CursorStyle(str, Enum): """CursorStyle: Style on the cursor. Sets the style of the cursor * square: A square * circle: A circle * cross: A cross * forbidden: A forbidden symbol * pointing: A finger for pointing * standard: The standard cursor # crosshair: A crosshair """ SQUARE = 'square' CIRCLE = 'circle' CROSS = 'cross' FORBIDDEN = 'forbidden' POINTING = 'pointing' STANDARD = 'standard' CROSSHAIR = 'crosshair' napari-0.5.0a1/napari/components/_viewer_key_bindings.py000066400000000000000000000072261437041365600234260ustar00rootroot00000000000000from __future__ import annotations from typing import TYPE_CHECKING from napari.components.viewer_model import ViewerModel from napari.utils.action_manager import action_manager from napari.utils.theme import available_themes, get_system_theme from napari.utils.translations import trans if TYPE_CHECKING: from napari.viewer import Viewer def register_viewer_action(description): """ Convenient decorator to register an action with the current ViewerModel It will use the function name as the action name. We force the description to be given instead of function docstring for translation purpose. """ def _inner(func): action_manager.register_action( name=f'napari:{func.__name__}', command=func, description=description, keymapprovider=ViewerModel, ) return func return _inner @register_viewer_action(trans._("Reset scroll.")) def reset_scroll_progress(viewer: Viewer): # on key press viewer.dims._scroll_progress = 0 yield # on key release viewer.dims._scroll_progress = 0 reset_scroll_progress.__doc__ = trans._("Reset dims scroll progress") @register_viewer_action(trans._("Toggle ndisplay.")) def toggle_ndisplay(viewer: Viewer): viewer.dims.ndisplay = 2 + (viewer.dims.ndisplay == 2) # Making this an action makes vispy really unhappy during the tests # on mac only with: # ``` # RuntimeError: wrapped C/C++ object of type CanvasBackendDesktop has been deleted # ``` @register_viewer_action(trans._("Toggle current viewer theme.")) def toggle_theme(viewer: ViewerModel): """Toggle theme for current viewer""" themes = available_themes() current_theme = viewer.theme # Check what the system theme is, to toggle properly if current_theme == 'system': current_theme = get_system_theme() idx = themes.index(current_theme) idx = (idx + 1) % len(themes) # Don't toggle to system, just among actual themes if themes[idx] == 'system': idx = (idx + 1) % len(themes) viewer.theme = themes[idx] @register_viewer_action(trans._("Reset view to original state.")) def reset_view(viewer: Viewer): viewer.reset_view() @register_viewer_action(trans._("Increment dimensions slider to the left.")) def increment_dims_left(viewer: Viewer): viewer.dims._increment_dims_left() @register_viewer_action(trans._("Increment dimensions slider to the right.")) def increment_dims_right(viewer: Viewer): viewer.dims._increment_dims_right() @register_viewer_action(trans._("Move focus of dimensions slider up.")) def focus_axes_up(viewer: Viewer): viewer.dims._focus_up() @register_viewer_action(trans._("Move focus of dimensions slider down.")) def focus_axes_down(viewer: Viewer): viewer.dims._focus_down() @register_viewer_action( trans._("Change order of the visible axes, e.g. [0, 1, 2] -> [2, 0, 1]."), ) def roll_axes(viewer: Viewer): viewer.dims._roll() @register_viewer_action( trans._( "Transpose order of the last two visible axes, e.g. [0, 1] -> [1, 0]." ), ) def transpose_axes(viewer: Viewer): viewer.dims.transpose() @register_viewer_action(trans._("Toggle grid mode.")) def toggle_grid(viewer: Viewer): viewer.grid.enabled = not viewer.grid.enabled @register_viewer_action(trans._("Toggle visibility of selected layers")) def toggle_selected_visibility(viewer: Viewer): viewer.layers.toggle_selected_visibility() @register_viewer_action( trans._( "Show/Hide IPython console (only available when napari started as standalone application)" ) ) def toggle_console_visibility(viewer: Viewer): viewer.window._qt_viewer.toggle_console_visibility() napari-0.5.0a1/napari/components/_viewer_mouse_bindings.py000066400000000000000000000011001437041365600237470ustar00rootroot00000000000000def dims_scroll(viewer, event): """Scroll the dimensions slider.""" if 'Control' not in event.modifiers: return if event.native.inverted(): viewer.dims._scroll_progress += event.delta[1] else: viewer.dims._scroll_progress -= event.delta[1] while abs(viewer.dims._scroll_progress) >= 1: if viewer.dims._scroll_progress < 0: viewer.dims._increment_dims_left() viewer.dims._scroll_progress += 1 else: viewer.dims._increment_dims_right() viewer.dims._scroll_progress -= 1 napari-0.5.0a1/napari/components/camera.py000066400000000000000000000151061437041365600204650ustar00rootroot00000000000000from typing import Optional, Tuple import numpy as np from pydantic import validator from scipy.spatial.transform import Rotation as R from napari.utils.events import EventedModel from napari.utils.misc import ensure_n_tuple from napari.utils.translations import trans class Camera(EventedModel): """Camera object modeling position and view of the camera. Attributes ---------- center : 3-tuple Center of rotation for the camera. In 2D viewing the last two values are used. zoom : float Scale from canvas pixels to world pixels. angles : 3-tuple Euler angles of camera in 3D viewing (rx, ry, rz), in degrees. Only used during 3D viewing. Note that Euler angles's intrinsic degeneracy means different sets of Euler angles may lead to the same view. perspective : float Perspective (aka "field of view" in vispy) of the camera (if 3D). interactive : bool If the camera interactivity is enabled or not. """ # fields center: Tuple[float, float, float] = (0.0, 0.0, 0.0) zoom: float = 1.0 angles: Tuple[float, float, float] = (0.0, 0.0, 90.0) perspective: float = 0 interactive: bool = True # validators @validator('center', 'angles', pre=True) def _ensure_3_tuple(v): return ensure_n_tuple(v, n=3) @property def view_direction(self) -> Tuple[float, float, float]: """3D view direction vector of the camera. View direction is calculated from the Euler angles and returned as a 3-tuple. This direction is in 3D scene coordinates, the world coordinate system for three currently displayed dimensions. """ ang = np.deg2rad(self.angles) view_direction = ( np.sin(ang[2]) * np.cos(ang[1]), np.cos(ang[2]) * np.cos(ang[1]), -np.sin(ang[1]), ) return view_direction @property def up_direction(self) -> Tuple[float, float, float]: """3D direction vector pointing up on the canvas. Up direction is calculated from the Euler angles and returned as a 3-tuple. This direction is in 3D scene coordinates, the world coordinate system for three currently displayed dimensions. """ rotation_matrix = R.from_euler( seq='yzx', angles=self.angles, degrees=True ).as_matrix() return tuple(rotation_matrix[:, 2][::-1]) def set_view_direction( self, view_direction: Tuple[float, float, float], up_direction: Tuple[float, float, float] = (0, -1, 0), ): """Set camera angles from direction vectors. Both the view direction and the up direction are specified in 3D scene coordinates, the world coordinate system for three currently displayed dimensions. The provided up direction must not be parallel to the provided view direction. The provided up direction does not need to be orthogonal to the view direction. The final up direction will be a vector orthogonal to the view direction, aligned with the provided up direction. Parameters ---------- view_direction : 3-tuple of float The desired view direction vector in 3D scene coordinates, the world coordinate system for three currently displayed dimensions. up_direction : 3-tuple of float A direction vector which will point upwards on the canvas. Defaults to (0, -1, 0) unless the view direction is parallel to the y-axis, in which case will default to (-1, 0, 0). """ # default behaviour of up direction view_direction_along_y_axis = ( view_direction[0], view_direction[2], ) == (0, 0) up_direction_along_y_axis = (up_direction[0], up_direction[2]) == ( 0, 0, ) if view_direction_along_y_axis and up_direction_along_y_axis: up_direction = (-1, 0, 0) # align up direction along z axis # xyz ordering for vispy, normalise vectors for rotation matrix view_direction = np.asarray(view_direction, dtype=float)[::-1] view_direction /= np.linalg.norm(view_direction) up_direction = np.asarray(up_direction, dtype=float)[::-1] up_direction = np.cross(view_direction, up_direction) up_direction /= np.linalg.norm(up_direction) # explicit check for parallel view direction and up direction if np.allclose(np.cross(view_direction, up_direction), 0): raise ValueError( trans._( "view direction and up direction are parallel", deferred=True, ) ) x_direction = np.cross(up_direction, view_direction) x_direction /= np.linalg.norm(x_direction) # construct rotation matrix, convert to euler angles rotation_matrix = np.column_stack( (up_direction, view_direction, x_direction) ) euler_angles = R.from_matrix(rotation_matrix).as_euler( seq='yzx', degrees=True ) self.angles = euler_angles def calculate_nd_view_direction( self, ndim: int, dims_displayed: Tuple[int] ) -> np.ndarray: """Calculate the nD view direction vector of the camera. Parameters ---------- ndim : int Number of dimensions in which to embed the 3D view vector. dims_displayed : Tuple[int] Dimensions in which to embed the 3D view vector. Returns ------- view_direction_nd : np.ndarray nD view direction vector as an (ndim, ) ndarray """ if len(dims_displayed) != 3: return None view_direction_nd = np.zeros(ndim) view_direction_nd[list(dims_displayed)] = self.view_direction return view_direction_nd def calculate_nd_up_direction( self, ndim: int, dims_displayed: Tuple[int] ) -> Optional[np.ndarray]: """Calculate the nD up direction vector of the camera. Parameters ---------- ndim : int Number of dimensions in which to embed the 3D view vector. dims_displayed : Tuple[int] Dimensions in which to embed the 3D view vector. Returns ------- up_direction_nd : np.ndarray nD view direction vector as an (ndim, ) ndarray """ if len(dims_displayed) != 3: return None up_direction_nd = np.zeros(ndim) up_direction_nd[list(dims_displayed)] = self.up_direction return up_direction_nd napari-0.5.0a1/napari/components/cursor.py000066400000000000000000000026121437041365600205500ustar00rootroot00000000000000from typing import Optional, Tuple from napari.components._viewer_constants import CursorStyle from napari.utils.events import EventedModel class Cursor(EventedModel): """Cursor object with position and properties of the cursor. Attributes ---------- position : tuple or None Position of the cursor in world coordinates. None if outside the world. scaled : bool Flag to indicate whether cursor size should be scaled to zoom. Only relevant for circle and square cursors which are drawn with a particular size. size : float Size of the cursor in canvas pixels.Only relevant for circle and square cursors which are drawn with a particular size. style : str Style of the cursor. Must be one of * square: A square * circle: A circle * cross: A cross * forbidden: A forbidden symbol * pointing: A finger for pointing * standard: The standard cursor # crosshair: A crosshair _view_direction : Optional[Tuple[float, ...]] The vector describing the direction of the camera in the scene. This is None when viewing in 2D. """ # fields position: Tuple[float, ...] = (1, 1) scaled: bool = True size: int = 1 style: CursorStyle = CursorStyle.STANDARD _view_direction: Optional[Tuple[float, ...]] = None napari-0.5.0a1/napari/components/dims.py000066400000000000000000000367271437041365600202050ustar00rootroot00000000000000from numbers import Integral from typing import ( Literal, Sequence, Tuple, Union, ) import numpy as np from pydantic import root_validator, validator from napari.utils.events import EventedModel from napari.utils.misc import argsort, reorder_after_dim_reduction from napari.utils.translations import trans class Dims(EventedModel): """Dimensions object modeling slicing and displaying. Parameters ---------- ndim : int Number of dimensions. ndisplay : int Number of displayed dimensions. last_used : int Dimension which was last used. range : tuple of 3-tuple of float List of tuples (min, max, step), one for each dimension. In a world coordinates space. As with Python's `range` and `slice`, max is not included. current_step : tuple of int Tuple of the slider position for each dims slider, in slider coordinates. order : tuple of int Tuple of ordering the dimensions, where the last dimensions are rendered. axis_labels : tuple of str Tuple of labels for each dimension. Attributes ---------- ndim : int Number of dimensions. ndisplay : int Number of displayed dimensions. last_used : int Dimension which was last used. range : tuple of 3-tuple of float List of tuples (min, max, step), one for each dimension. In a world coordinates space. As with Python's `range` and `slice`, max is not included. current_step : tuple of int Tuple the slider position for each dims slider, in slider coordinates. order : tuple of int Tuple of ordering the dimensions, where the last dimensions are rendered. axis_labels : tuple of str Tuple of labels for each dimension. nsteps : tuple of int Number of steps available to each slider. These are calculated from the ``range``. point : tuple of float List of floats setting the current value of the range slider when in POINT mode, one for each dimension. In a world coordinates space. These are calculated from the ``current_step`` and ``range``. displayed : tuple of int List of dimensions that are displayed. These are calculated from the ``order`` and ``ndisplay``. not_displayed : tuple of int List of dimensions that are not displayed. These are calculated from the ``order`` and ``ndisplay``. displayed_order : tuple of int Order of only displayed dimensions. These are calculated from the ``displayed`` dimensions. """ # fields ndim: int = 2 ndisplay: Literal[2, 3] = 2 last_used: int = 0 range: Tuple[Tuple[float, float, float], ...] = () current_step: Tuple[int, ...] = () order: Tuple[int, ...] = () axis_labels: Tuple[str, ...] = () # private vars _scroll_progress: int = 0 # validators @validator('axis_labels', pre=True) def _string_to_list(v): if isinstance(v, str): return list(v) return v @root_validator def _check_dims(cls, values): """Check the consitency of dimensionaity for all attributes Parameters ---------- values : dict Values dictionary to update dims model with. """ ndim = values['ndim'] # Check the range tuple has same number of elements as ndim if len(values['range']) < ndim: values['range'] = ((0, 2, 1),) * ( ndim - len(values['range']) ) + values['range'] elif len(values['range']) > ndim: values['range'] = values['range'][-ndim:] # Check the current step tuple has same number of elements as ndim if len(values['current_step']) < ndim: values['current_step'] = (0,) * ( ndim - len(values['current_step']) ) + values['current_step'] elif len(values['current_step']) > ndim: values['current_step'] = values['current_step'][-ndim:] # Check the order tuple has same number of elements as ndim if len(values['order']) < ndim: values['order'] = tuple( range(ndim - len(values['order'])) ) + tuple(o + ndim - len(values['order']) for o in values['order']) elif len(values['order']) > ndim: values['order'] = reorder_after_dim_reduction( values['order'][-ndim:] ) # Check the order is a permutation of 0, ..., ndim - 1 if not set(values['order']) == set(range(ndim)): raise ValueError( trans._( "Invalid ordering {order} for {ndim} dimensions", deferred=True, order=values['order'], ndim=ndim, ) ) # Check the axis labels tuple has same number of elements as ndim if len(values['axis_labels']) < ndim: # Append new "default" labels to existing ones if values['axis_labels'] == tuple( map(str, range(len(values['axis_labels']))) ): values['axis_labels'] = tuple(map(str, range(ndim))) else: values['axis_labels'] = ( tuple(map(str, range(ndim - len(values['axis_labels'])))) + values['axis_labels'] ) elif len(values['axis_labels']) > ndim: values['axis_labels'] = values['axis_labels'][-ndim:] return values @property def nsteps(self) -> Tuple[int, ...]: """Tuple of int: Number of slider steps for each dimension.""" return tuple( int((max_val - min_val) / step_size) for min_val, max_val, step_size in self.range ) @property def point(self) -> Tuple[int, ...]: """Tuple of float: Value of each dimension.""" # The point value is computed from the range and current_step point = tuple( min_val + step_size * value for (min_val, max_val, step_size), value in zip( self.range, self.current_step ) ) return point @property def displayed(self) -> Tuple[int, ...]: """Tuple: Dimensions that are displayed.""" return self.order[-self.ndisplay :] @property def not_displayed(self) -> Tuple[int, ...]: """Tuple: Dimensions that are not displayed.""" return self.order[: -self.ndisplay] @property def displayed_order(self) -> Tuple[int, ...]: return tuple(argsort(self.displayed)) def set_range( self, axis: Union[int, Sequence[int]], _range: Union[ Sequence[Union[int, float]], Sequence[Sequence[Union[int, float]]] ], ): """Sets ranges (min, max, step) for the given dimensions. Parameters ---------- axis : int or sequence of int Dimension index or a sequence of axes whos range will be set. _range : tuple or sequence of tuple Range specified as (min, max, step) or a sequence of these range tuples. """ if isinstance(axis, Integral): axis = assert_axis_in_bounds(axis, self.ndim) # type: ignore if self.range[axis] != _range: full_range = list(self.range) full_range[axis] = _range self.range = full_range else: full_range = list(self.range) # cast range to list for list comparison below _range = list(_range) # type: ignore axis = tuple(axis) # type: ignore if len(axis) != len(_range): raise ValueError( trans._("axis and _range sequences must have equal length") ) if _range != full_range: for ax, r in zip(axis, _range): ax = assert_axis_in_bounds(int(ax), self.ndim) full_range[ax] = r self.range = full_range def set_point( self, axis: Union[int, Sequence[int]], value: Union[Union[int, float], Sequence[Union[int, float]]], ): """Sets point to slice dimension in world coordinates. The desired point gets transformed into an integer step of the slider and stored in the current_step. Parameters ---------- axis : int or sequence of int Dimension index or a sequence of axes whos point will be set. value : scalar or sequence of scalars Value of the point for each axis. """ if isinstance(axis, Integral): axis = assert_axis_in_bounds(axis, self.ndim) # type: ignore (min_val, max_val, step_size) = self.range[axis] raw_step = (value - min_val) / step_size self.set_current_step(axis, raw_step) else: value = tuple(value) # type: ignore axis = tuple(axis) # type: ignore if len(axis) != len(value): raise ValueError( trans._("axis and value sequences must have equal length") ) raw_steps = [] for ax, val in zip(axis, value): ax = assert_axis_in_bounds(int(ax), self.ndim) min_val, _, step_size = self.range[ax] raw_steps.append((val - min_val) / step_size) self.set_current_step(axis, raw_steps) def set_current_step( self, axis: Union[int, Sequence[int]], value: Union[Union[int, float], Sequence[Union[int, float]]], ): """Set the slider steps at which to slice this dimension. The position of the slider in world coordinates gets calculated from the current_step of the slider. Parameters ---------- axis : int or sequence of int Dimension index or a sequence of axes whos step will be set. value : scalar or sequence of scalars Value of the step for each axis. """ if isinstance(axis, Integral): axis = assert_axis_in_bounds(axis, self.ndim) step = round(min(max(value, 0), self.nsteps[axis] - 1)) if self.current_step[axis] != step: full_current_step = list(self.current_step) full_current_step[axis] = step self.current_step = full_current_step else: full_current_step = list(self.current_step) # cast value to list for list comparison below value = list(value) # type: ignore axis = tuple(axis) # type: ignore if len(axis) != len(value): raise ValueError( trans._("axis and value sequences must have equal length") ) if value != full_current_step: # (computed) nsteps property outside of the loop for efficiency nsteps = self.nsteps for ax, val in zip(axis, value): ax = assert_axis_in_bounds(int(ax), self.ndim) step = round(min(max(val, 0), nsteps[ax] - 1)) full_current_step[ax] = step self.current_step = full_current_step def set_axis_label( self, axis: Union[int, Sequence[int]], label: Union[str, Sequence[str]], ): """Sets new axis labels for the given axes. Parameters ---------- axis : int or sequence of int Dimension index or a sequence of axes whos labels will be set. label : str or sequence of str Given labels for the specified axes. """ if isinstance(axis, Integral): axis = assert_axis_in_bounds(axis, self.ndim) if self.axis_labels[axis] != str(label): full_axis_labels = list(self.axis_labels) full_axis_labels[axis] = str(label) self.axis_labels = full_axis_labels self.last_used = axis else: full_axis_labels = list(self.axis_labels) # cast label to list for list comparison below label = list(label) # type: ignore axis = tuple(axis) # type: ignore if len(axis) != len(label): raise ValueError( trans._("axis and label sequences must have equal length") ) if label != full_axis_labels: for ax, val in zip(axis, label): ax = assert_axis_in_bounds(int(ax), self.ndim) full_axis_labels[ax] = val self.axis_labels = full_axis_labels def reset(self): """Reset dims values to initial states.""" # Don't reset axis labels self.range = ((0, 2, 1),) * self.ndim self.current_step = (0,) * self.ndim self.order = tuple(range(self.ndim)) def transpose(self): """Transpose displayed dimensions. This swaps the order of the last two displayed dimensions. The order of the displayed is taken from Dims.order. """ order = list(self.order) order[-2], order[-1] = order[-1], order[-2] self.order = order def _increment_dims_right(self, axis: int = None): """Increment dimensions to the right along given axis, or last used axis if None Parameters ---------- axis : int, optional Axis along which to increment dims, by default None """ if axis is None: axis = self.last_used self.set_current_step(axis, self.current_step[axis] + 1) def _increment_dims_left(self, axis: int = None): """Increment dimensions to the left along given axis, or last used axis if None Parameters ---------- axis : int, optional Axis along which to increment dims, by default None """ if axis is None: axis = self.last_used self.set_current_step(axis, self.current_step[axis] - 1) def _focus_up(self): """Shift focused dimension slider to be the next slider above.""" sliders = [d for d in self.not_displayed if self.nsteps[d] > 1] if len(sliders) == 0: return index = (sliders.index(self.last_used) + 1) % len(sliders) self.last_used = sliders[index] def _focus_down(self): """Shift focused dimension slider to be the next slider bellow.""" sliders = [d for d in self.not_displayed if self.nsteps[d] > 1] if len(sliders) == 0: return index = (sliders.index(self.last_used) - 1) % len(sliders) self.last_used = sliders[index] def _roll(self): """Roll order of dimensions for display.""" order = np.array(self.order) nsteps = np.array(self.nsteps) order[nsteps > 1] = np.roll(order[nsteps > 1], 1) self.order = order.tolist() def assert_axis_in_bounds(axis: int, ndim: int) -> int: """Assert a given value is inside the existing axes of the image. Returns ------- axis : int The axis which was checked for validity. ndim : int The dimensionality of the layer. Raises ------ ValueError The given axis index is out of bounds. """ if axis not in range(-ndim, ndim): msg = trans._( 'Axis {axis} not defined for dimensionality {ndim}. Must be in [{ndim_lower}, {ndim}).', deferred=True, axis=axis, ndim=ndim, ndim_lower=-ndim, ) raise ValueError(msg) return axis % ndim napari-0.5.0a1/napari/components/experimental/000077500000000000000000000000001437041365600213555ustar00rootroot00000000000000napari-0.5.0a1/napari/components/experimental/__init__.py000066400000000000000000000000001437041365600234540ustar00rootroot00000000000000napari-0.5.0a1/napari/components/experimental/chunk/000077500000000000000000000000001437041365600224655ustar00rootroot00000000000000napari-0.5.0a1/napari/components/experimental/chunk/__init__.py000066400000000000000000000006671437041365600246070ustar00rootroot00000000000000"""chunk module""" from napari.components.experimental.chunk._loader import ( chunk_loader, synchronous_loading, wait_for_async, ) from napari.components.experimental.chunk._request import ( ChunkLocation, ChunkRequest, LayerRef, OctreeLocation, ) __all__ = [ 'ChunkLocation', 'OctreeLocation', 'ChunkRequest', 'LayerRef', 'chunk_loader', 'wait_for_async', 'synchronous_loading', ] napari-0.5.0a1/napari/components/experimental/chunk/_cache.py000066400000000000000000000077661437041365600242610ustar00rootroot00000000000000"""ChunkCache stores loaded chunks. """ from __future__ import annotations import logging from typing import TYPE_CHECKING, Dict, Optional from napari._vendor.experimental.cachetools import LRUCache if TYPE_CHECKING: from napari.components.experimental.chunk._request import ChunkRequest from napari.types import ArrayLike # A ChunkRequest is just a dict of the arrays we need to load. We allow # loading multiple arrays in one request so the caller does not have to # deal with partial loads, where it has received some arrays but it cannot # use them until other arrays have finished loading. # # The caller is free to use whatever names it wants to organize the arrays, # for example "image" and "thumbnail", or spatially neighboring tiles like # "tile.1.1", "tile1.2", "tile2.1", "tile2.2". ChunkArrays = Dict[str, ArrayLike] LOGGER = logging.getLogger("napari.loader.cache") # ChunkCache size as a fraction of total RAM. Keep it small for now until # we figure out how ChunkCache will work with the Dask cache, and do # a lot more testing. CACHE_MEM_FRACTION = 0.1 def _get_cache_size_bytes(mem_fraction: float) -> int: """Return the max number of bytes the cache should use. Parameters ---------- mem_fraction : float The cache should use this fraction of RAM, for example 0.5. Returns ------- int The max number of bytes the cache should use. """ import psutil return psutil.virtual_memory().total * mem_fraction def _getsizeof_chunks(chunks: ChunkArrays) -> int: """This tells the LRUCache know how big our chunks are. Parameters ---------- chunks : ChunkArrays The arrays stored in one cache entry. Returns ------- int How many bytes the arrays take up in memory. """ return sum(array.nbytes for array in chunks.values()) class ChunkCache: """Cache of previously loaded chunks. We use a cachetools LRUCache to implement a least recently used cache that will grow in memory usage up to some limit. Then it will free the least recently used entries so total usage does not exceed that limit. TODO_OCTREE: 1) For dynamically computed data the cache should be disabled. So should the default be off? Or can we detect dynamic computations? 2) Can we use the Dask cache instead of our own? The problem with having two caches is how to manage their sizes? They can't both be 0.5 * RAM for example! Attributes ---------- chunks : LRUCache The cache of chunks. enabled : bool True if the cache is enabled. """ def __init__(self) -> None: nbytes = _get_cache_size_bytes(CACHE_MEM_FRACTION) self.chunks = LRUCache(maxsize=nbytes, getsizeof=_getsizeof_chunks) self.enabled = True def add_chunks(self, request: ChunkRequest) -> None: """Add the chunks in this request to the cache. Parameters ---------- request : ChunkRequest Add the data in this request to the cache. """ if not self.enabled: LOGGER.debug("ChunkCache.add_chunk: cache is disabled") return LOGGER.debug("add_chunk: %s", request.location) self.chunks[request.location] = request.chunks def get_chunks(self, request: ChunkRequest) -> Optional[ChunkArrays]: """Return the cached data for this request if it was cached. Parameters ---------- request : ChunkRequest Look for cached data for this request. Returns ------- Optional[ChunkArrays] The cached data or None of it was not found in the cache. """ if not self.enabled: LOGGER.info("ChunkCache.get_chunk: disabled") return None data = self.chunks.get(request.location) LOGGER.info( "get_chunk: %s %s", request.location, "found" if data is not None else "not found", ) return data napari-0.5.0a1/napari/components/experimental/chunk/_commands/000077500000000000000000000000001437041365600244255ustar00rootroot00000000000000napari-0.5.0a1/napari/components/experimental/chunk/_commands/__init__.py000066400000000000000000000002471437041365600265410ustar00rootroot00000000000000"""Commands for napari's IPython console.""" from napari.components.experimental.chunk._commands._loader import ( LoaderCommands, ) __all__ = ["LoaderCommands"] napari-0.5.0a1/napari/components/experimental/chunk/_commands/_loader.py000066400000000000000000000243401437041365600264070ustar00rootroot00000000000000"""LoaderCommands class and helpers. """ from typing import List from napari._vendor.experimental.humanize.src.humanize import naturalsize from napari.components.experimental.chunk._commands._tables import ( RowTable, print_property_table, ) from napari.components.experimental.chunk._commands._utils import highlight from napari.components.experimental.chunk._info import LayerInfo, LoadType from napari.components.experimental.chunk._loader import chunk_loader from napari.layers.base import Layer from napari.layers.image import Image from napari.utils.config import octree_config LOAD_TYPE_STR = { LoadType.AUTO: "auto", LoadType.SYNC: "sync", LoadType.ASYNC: "async", } HELP_STR = f""" {highlight("Available Commands:")} loader.help loader.cache loader.config loader.layers loader.levels(index) loader.loads(index) loader.set_default(index) loader.set_sync(index) loader.set_async(index) """ def format_bytes(num_bytes): """Return formatted string like K, M, G. The gnu=True flag produces GNU-style single letter suffixes which are more compact then KiB, MiB, GiB. """ return naturalsize(num_bytes, gnu=True) class InfoDisplayer: """Display LayerInfo values nicely for the table. This mainly exist so we can have NoInfoDisplay which displays "--" for all the values. Seemed like the easiest way to handle the case when we have info and the case when we don't. Parameters ---------- layer_info : LayerInfo The LayerInfo to display. """ def __init__(self, info: LayerInfo) -> None: self.info = info stats = info.stats counts = stats.counts self.data_type = "???" # We need to add this back... self.num_loads = counts.loads self.num_chunks = counts.chunks self.sync = LOAD_TYPE_STR[self.info.load_type] self.total = format_bytes(counts.bytes) self.avg_ms = f"{stats.window_ms.average:.1f}" self.mbits = f"{stats.mbits:.1f}" self.load_str = stats.recent_load_str class NoInfoDisplayer: """When we have no LayerInfo every field is just blank.""" def __getattr__(self, name): return "--" def _get_shape_str(layer): """Get shape string for the data. Either "NONE" or a tuple like "(10, 100, 100)". """ # We only care about Image/Labels layers for now. if not isinstance(layer, Image): return "--" data = layer.data if isinstance(data, list): if len(data) == 0: return "NONE" # Shape layer is empty list? return f"{data[0].shape}" # Multi-scale # Not a list. return str(data.shape) class ChunkLoaderLayers: """Table showing information about each layer. Parameters ---------- layers : List[Layer] The layers to list in the table. Attributes ---------- table : TextTable Formats our table for printing. """ def __init__(self, layers: List[Layer]) -> None: self.layers = layers self.table = RowTable( [ "ID", "MODE", "LOADS", {"name": "NAME", "align": "left"}, "LAYER", "DATA", "LEVELS", "LOADS", "CHUNKS", "TOTAL", "AVG (ms)", "MBIT/s", "SHAPE", ] ) for i, layer in enumerate(self.layers): self._add_row(i, layer) @staticmethod def _get_num_levels(data) -> int: """Get the number of levels of the data. Parameters ---------- data Layer data. Returns ------- int The number of levels of the data. """ if isinstance(data, list): return len(data) return 1 def _add_row(self, index: int, layer: Layer) -> int: """Add row describing one layer. Parameters ---------- index : int The layer id (from view.cmd.layers). layer : Layer The layer itself. """ layer_type = type(layer).__name__ num_levels = self._get_num_levels(layer.data) shape_str = _get_shape_str(layer) # Use InfoDisplayer to display LayerInfo info = chunk_loader.get_info(id(layer)) disp = InfoDisplayer(info) if info is not None else NoInfoDisplayer() self.table.add_row( [ index, disp.sync, disp.load_str, layer.name, layer_type, disp.data_type, num_levels, disp.num_loads, disp.num_chunks, disp.total, disp.avg_ms, disp.mbits, shape_str, ] ) def print(self): """Print the whole table.""" self.table.print() class LevelsTable: """Table showing the levels in a single layer. Parameters ---------- layer_id : int The ID of this layer. layer : Layer Show the levels of this layer. """ def __init__(self, layer) -> None: self.layer = layer self.table = RowTable(["LEVEL", "SHAPE", "TOTAL"]) self.table = RowTable( ["LEVEL", {"name": "SHAPE", "align": "left"}, "TOTAL"] ) data = layer.data if isinstance(data, list): for i, level in enumerate(data): shape_str = level.shape if level.shape else "NONE" size_str = naturalsize(level.nbytes, gnu=True) self.table.add_row([i, shape_str, size_str]) def print(self): """Print the whole table.""" self.table.print() class LoaderCommands: """Layer related commands for the CommandProcessor. Parameters ---------- layerlist : List[Layer] The current list of layers. """ def __init__(self, layerlist: List[Layer]) -> None: self.layerlist = layerlist def __repr__(self): return HELP_STR @property def help(self): """The help message.""" print(HELP_STR) @property def config(self): """Print the current list of layers.""" config = octree_config['loader'] config = [ ('log_path', config['log_path']), ('synchronous', config['synchronous']), ('num_workers', config['num_workers']), ('use_processes', config['use_processes']), ('auto_sync_ms', config['auto_sync_ms']), ('delay_queue_ms', config['delay_queue_ms']), ] print_property_table(config) @property def cache(self): """The cache status.""" chunk_cache = chunk_loader.cache cur_str = format_bytes(chunk_cache.chunks.currsize) max_str = format_bytes(chunk_cache.chunks.maxsize) table = [ ('enabled', chunk_cache.enabled), ('currsize', cur_str), ('maxsize', max_str), ] print_property_table(table) @property def layers(self): """Print the current list of layers.""" ChunkLoaderLayers(self.layerlist).print() def _get_layer(self, layer_index) -> Layer: try: return self.layerlist[layer_index] except KeyError: print(f"Layer index {layer_index} is invalid.") return None def _get_layer_info(self, layer_index) -> LayerInfo: """Return the LayerInfo at this index.""" layer = self._get_layer(layer_index) if layer is None: return None layer_id = id(layer) info = chunk_loader.get_info(layer_id) if info is None: print(f"Layer index {layer_index} has no LayerInfo.") return None return info def loads(self, layer_index: int) -> None: """Print recent loads for this layer. Attributes ---------- layer_index : int The index from the viewer.cmd.loader table. """ info = self._get_layer_info(layer_index) if info is None: return table = RowTable(["INDEX", "TYPE", "SIZE", "DURATION (ms)", "Mbit/s"]) for i, load in enumerate(info.recent_loads): load_str = "sync" if load.sync else "async" duration_str = f"{load.duration_ms:.1f}" mbits_str = f"{load.mbits:.1f}" table.add_row( (i, load_str, load.num_bytes, duration_str, mbits_str) ) table.print() def _set_load_type(self, index, load_type) -> None: """Set this layer to this load type.""" info = self._get_layer_info(index) if info is not None: info.load_type = load_type def set_sync(self, index) -> None: """Set this layer to sync loading.""" self._set_load_type(index, LoadType.SYNC) def set_async(self, index) -> None: """Set this layer to async loading.""" self._set_load_type(index, LoadType.ASYNC) def set_auto(self, index) -> None: """Set this layer to auto loading.""" self._set_load_type(index, LoadType.AUTO) def levels(self, layer_index: int) -> None: """Print information on a single layer. Prints summary and if multiscale prints a table of the levels: Layer ID: 0 Name: LaminB1 Levels: 2 LEVEL SHAPE 0 (1, 236, 275, 271) 1 (1, 236, 137, 135) Parameters ---------- layer_id : int ConsoleCommand's id for the layer. """ layer = self._get_layer(layer_index) if layer is None: return num_levels = len(layer.data) if layer.multiscale else 1 # Common to both multi-scale and single-scale. summary = [ ("Layer ID", layer_index), ("Name", layer.name), ("Levels", num_levels), ] if layer.multiscale: # Print summary and level table. print_property_table(summary) print("") # blank line LevelsTable(layer).print() else: # Print summary with shape, no level table. summary.append(("Shape", layer.data.shape)) print_property_table(summary) napari-0.5.0a1/napari/components/experimental/chunk/_commands/_tables.py000066400000000000000000000117201437041365600264110ustar00rootroot00000000000000"""print_property_table() function and RowTable class. These are two styles of nicely formatted text tables meant for printing to the IPython console window. """ from typing import Any, List, Tuple, Union from napari.components.experimental.chunk._commands._utils import highlight from napari.utils.translations import trans def print_property_table(table: List[Tuple[str, Any]]) -> None: """Print names and values. Example output: Layer ID: 0 Name: numbered slices Levels: 1 Shape: (20, 1024, 1024, 3) Parameters ---------- table """ heading_width = max(len(x) for x, _ in table) for heading, value in table: aligned = f"{heading:>{heading_width}}" print(f"{highlight(aligned)}: {value}") class ColumnSpec: """Specification for one column in a RowTable. Parameters ---------- spec : Union[str, dict] String column name, or a dict specification. """ def __init__(self, spec: Union[str, dict]) -> None: if isinstance(spec, str): spec = {'name': spec} # Spec is the name, then we use defaults. self.name = spec.get('name', "") self.align = spec.get('align', "right") self.width = spec.get('width') def format(self, value, width): """Return formatted value with alignment.""" value_str = str(value) if self.align == "left": return f"{value_str:<{width}}" return f"{value_str:>{width}}" class RowTable: """A printable text table with a header and rows. Example usage: table = table(["NAME", "AGE"], [10, 5]) table.add_row["Mary", "25"] table.add_row["Alice", "32"] table.print() Example output: NAME AGE Mary 25 Alice 32 Parameters ---------- headers : List[str] The column headers such as ["NAME", "AGE"]. widths: Optional[List[int]] Use these widths instead of automatic widths, 0 means auto for that column. """ # Leave room between columns. PADDING = 2 def __init__(self, columns: List[Union[str, dict]]) -> None: self.columns = [ColumnSpec(x) for x in columns] self.rows: List[list] = [] self.padding = " " * self.PADDING def add_row(self, row: List[str]) -> None: """Add one row of data to the table. Parameters ---------- row : List[str] The row values such as ["Fred", "25"]. """ row_cols = len(row) header_cols = len(self.columns) if row_cols != header_cols: raise ValueError( trans._( "Row with {row_cols} columns not compatible with headers ({header_cols} columns)", deferred=True, row_cols=row_cols, header_cols=header_cols, ) ) self.rows.append(row) def _get_max_data_width(self, index: int) -> int: """Return maximum width of this column in the data. Parameters ---------- index : int Return width of this column. Returns ------- int The maximum width of this column. """ if self.rows: return max(len(str(row[index])) for row in self.rows) return 0 def _get_widths(self) -> List[int]: """Return widths of all the columns." Returns ------- List[int] The width of each column in order. """ widths = [] for i, spec in enumerate(self.columns): if spec.width is not None: width = spec.width # A fixed width column. else: # Auto sized column so whichever is wider: data or header. data_width = self._get_max_data_width(i) width = max(data_width, len(self.columns[i].name)) widths.append(width) return widths def get_header_str(self, widths: List[int]) -> str: """Return header string with all the column names. Parameters ---------- widths : List[int] The column widths. Returns ------- str The header string. """ header_str = "" for i, spec in enumerate(self.columns): width = widths[i] value = str(spec.name) header_str += f"{value:<{width}}" + self.padding return header_str def get_row_str(self, row, widths: List[int]) -> str: """Get string depicting one row on the table.""" row_str = "" for i, spec in enumerate(self.columns): row_str += spec.format(row[i], widths[i]) + self.padding return row_str def print(self): """Print the entire table both header and rows.""" widths = self._get_widths() print(highlight(self.get_header_str(widths))) for row in self.rows: print(self.get_row_str(row, widths)) napari-0.5.0a1/napari/components/experimental/chunk/_commands/_tests/000077500000000000000000000000001437041365600257265ustar00rootroot00000000000000napari-0.5.0a1/napari/components/experimental/chunk/_commands/_tests/__init__.py000066400000000000000000000000001437041365600300250ustar00rootroot00000000000000napari-0.5.0a1/napari/components/experimental/chunk/_commands/_tests/test_loader.py000066400000000000000000000037061437041365600306130ustar00rootroot00000000000000import numpy as np import pytest from napari.components.experimental.chunk._commands import LoaderCommands from napari.viewer import ViewerModel @pytest.mark.async_only def test_help(capsys): """Test loader.help.""" viewer = ViewerModel() loader = viewer.experimental.cmds.loader loader.help out, _ = capsys.readouterr() assert out.count('\n') >= 4 @pytest.mark.async_only def test_no_layers(capsys): """Test loader.layers with no layers.""" viewer = ViewerModel() LoaderCommands(viewer.layers).layers out, _ = capsys.readouterr() for x in ["ID", "NAME", "LAYER"]: # Just check a few. assert x in out @pytest.mark.async_only def test_one_layer(capsys): """Test loader.layer with one layer.""" viewer = ViewerModel() loader = viewer.experimental.cmds.loader data = np.random.random((10, 15)) viewer.add_image(data, name="pizza") loader.layers out, _ = capsys.readouterr() assert "pizza" in out assert "(10, 15)" in out @pytest.mark.async_only def test_many_layers(capsys): """Test loader.layer with many layers.""" viewer = ViewerModel() loader = viewer.experimental.cmds.loader num_images = 10 for _ in range(num_images): data = np.random.random((10, 15)) viewer.add_image(data) loader.layers out, _ = capsys.readouterr() assert out.count("(10, 15)") == num_images @pytest.mark.async_only def test_levels(capsys): """Test loader.levels.""" viewer = ViewerModel() loader = viewer.experimental.cmds.loader data = np.random.random((10, 15)) viewer.add_image(data, name="pizza") loader.levels(0) out, _ = capsys.readouterr() # Output has color escape codes to have to check one word at a time. assert out.count("Levels") == 1 assert out.count(": 1") == 1 assert out.count("Name") == 1 assert out.count(": pizza") == 1 assert out.count("Shape") == 1 assert out.count(": (10, 15)") == 1 napari-0.5.0a1/napari/components/experimental/chunk/_commands/_utils.py000066400000000000000000000026751437041365600263100ustar00rootroot00000000000000"""Command related utilities. Notes ----- There are many packages for text formatting such as colorit, printy, ansicolors, termcolor if we decide to use one. """ # Colors in "escape code order" from 30 to 37 COLORS = [ "black", "red", "green", "yellow", "blue", "magenta", "cyan", "white", ] def _code(color: str) -> str: """Return color escape code like '[31m' for red. Parameters ---------- color : str A supported color like 'red'. Returns ------- str The formatted string. """ # Escape codes for the 8 main colors go from 30 to 37. num_str = str(30 + COLORS.index(color)) return f"[{num_str}m" def text_color(string: str, color: str) -> str: """Return string formatted with the given color. Parameters ---------- string : str The string to format. color : str A supported color such as 'red' Returns ------- str The formatted string """ return f"\x1b{_code(color)}{string}\x1b[0m" def highlight(string: str) -> str: """Return string highlighted with some accent color. We have this function so we can change "the highlight color" in one place and all commands will use the new color. Parameters ---------- string : str The string to return highlighted. Returns ------- str The colorized string. """ return text_color(string, "cyan") napari-0.5.0a1/napari/components/experimental/chunk/_delay_queue.py000066400000000000000000000163501437041365600255050ustar00rootroot00000000000000"""DelayQueue class. Delay load requests a configurable amount of time before submitting them. """ from __future__ import annotations import logging import threading import time from typing import TYPE_CHECKING, Callable, List, NamedTuple, Optional from napari.utils.perf import add_counter_event LOGGER = logging.getLogger("napari.loader") if TYPE_CHECKING: from napari.components.experimental.chunk._request import ChunkRequest class QueueEntry(NamedTuple): """The request we are doing to submit and when to submit it. Parameters ---------- request : ChunkRequest The request to submit. submit_time : float The time to submit the request in time.time() seconds. """ request: ChunkRequest submit_time: float class DelayQueue(threading.Thread): """A threaded queue that delays request submission. The DelayQueue exists so we can avoid spamming the ChunkLoader loader pools with requests for chunks that are potentially going to be out of view before they are loaded. For example, when rapidly scrolling through slices, it would be pointless to submit requests for every slice we pass through. Instead, by using a small delay, we hold back submitting our requests until the user has settled on a specific slice. Similarly with the Octree, when panning and zooming rapidly we might choose to delay loads so that we don't waste time loading chunks that will quickly be out of view. With the Octree however we do want to show something as you pan and zoom around. For this reason, ChunkLoader can have multiple loader pools each with different delays. Typically we we delay the "ideal level" chunks the most, but we load coarser levels sooner. We want to show the user something quickly, but we only want to load the full set of ideal chunks when the camera movement has settled down. Parameters ---------- delay_queue_ms : float Delay the request for this many milliseconds. submit_func Call this function to submit the request. Attributes ---------- delay_seconds : float Delay each request by this many seconds. _submit_func : Callable[[ChunkRequest], None] Call this function to submit the request. _entries : List[QueueEntry] The entries in the queue. _lock : threading.Lock Lock access to the self.entires queue. _event : threading.Event Event we signal to wake up the worker. """ def __init__( self, delay_queue_ms: float, submit_func: Callable[[ChunkRequest], None], ) -> None: super().__init__(daemon=True) self._shutdown = False self.delay_seconds: float = delay_queue_ms / 1000 self._submit_func = submit_func self._entries: List[QueueEntry] = [] self._lock = threading.Lock() self._wakeup = threading.Event() self._exit = threading.Event() self.start() def add(self, request) -> None: """Insert the request into the queue. Parameters ---------- request : ChunkRequest Insert this request into the queue. """ if self.delay_seconds == 0: self._submit_func(request) # Submit with no delay. return LOGGER.info("DelayQueue.add: %s", request.location) # Create entry with the time to submit it. submit_time = time.time() + self.delay_seconds entry = QueueEntry(request, submit_time) with self._lock: self._entries.append(entry) num_entries = len(self._entries) add_counter_event("delay_queue", entries=num_entries) if num_entries == 1: self._wakeup.set() # The list was empty so wake up the worker. def cancel_requests( self, should_cancel: Callable[[ChunkRequest], bool] ) -> List[ChunkRequest]: """Cancel pending requests based on the given filter. Parameters ---------- should_cancel : Callable[[ChunkRequest], bool] Cancel the request if this returns True. Returns ------- List[ChunkRequests] The requests that were cancelled, if any. """ keep = [] cancel = [] with self._lock: for entry in self._entries: if should_cancel(entry.request): cancel.append(entry.request) else: keep.append(entry) self._entries = keep return cancel def submit(self, entry: QueueEntry, now: float) -> bool: """Submit and return True if entry is ready to be submitted. Parameters ---------- entry : QueueEntry The entry to potentially submit. now : float Current time in seconds. Returns ------- bool True if the entry was submitted. """ # If entry is due to be submitted. if entry.submit_time < now: LOGGER.info("DelayQueue.submit: %s", entry.request.location) self._submit_func(entry.request) return True # We submitted this request. return False def run(self): """The DelayQueue thread's main method. Submit all due entires, then sleep or wait on self._wakeup for new entries. """ while self._shutdown is False: now = time.time() with self._lock: seconds = self._submit_due_entries(now) num_entries = len(self._entries) add_counter_event("delay_queue", entries=num_entries) if seconds is None: # There were no entries left, so wait until there is one. self._wakeup.wait() self._wakeup.clear() else: # Sleep until the next entry is due. This will tend to # oversleep by a few milliseconds, but close enough for our # purposes. Once we wake up we'll submit all due entries. # So we won't miss any. time.sleep(seconds) self._exit.set() # We are exiting now. def shutdown(self) -> None: """Shutdown the DelayQueue's thread.""" self._shutdown = True self._wakeup.set() self._exit.wait() def _submit_due_entries(self, now: float) -> Optional[float]: """Submit all due entries, oldest to newest. Parameters ---------- now : float Current time in seconds. Returns ------- Optional[float] Seconds until next entry is due, or None if no next entry. """ while self._entries: # Submit the oldest entry if it's due. if self.submit(self._entries[0], now): self._entries.pop(0) # Remove the one we just submitted. else: # Oldest entry is not due, return time until it is. return self._entries[0].submit_time - now return None # There are no more entries. def flush(self): """Submit all entries right now.""" with self._lock: for entry in self._entries: self._submit_func(entry.request) self._entries = [] napari-0.5.0a1/napari/components/experimental/chunk/_info.py000066400000000000000000000136121437041365600241340ustar00rootroot00000000000000"""LoadCounts, LoadType, LoadInfo, LoadStats and LayerInfo. """ import logging import time from enum import Enum from napari.components.experimental.chunk._request import ( ChunkRequest, LayerRef, ) from napari.components.experimental.chunk._utils import StatWindow from napari.components.experimental.monitor import monitor from napari.layers.base import Layer LOGGER = logging.getLogger("napari.loader") class LoadCounts: """Count statistics about loaded chunks.""" def __init__(self) -> None: self.loads: int = 0 self.chunks: int = 0 self.bytes: int = 0 def _mbits(num_bytes, duration_ms) -> float: """Return Mbit/s.""" mbits = (num_bytes * 8) / (1024 * 1024) seconds = duration_ms / 1000 if seconds == 0: return 0 return mbits / seconds class LoadType(Enum): """How ChunkLoader should load this layer.""" AUTO = 0 # Decide based on load speed. SYNC = 1 # Always load synchronously. ASYNC = 2 # Always load asynchronously. class LoadInfo: """Info about loading one ChunkRequest. Parameters ---------- num_bytes : int How many bytes were loaded. duration_ms : float How long did the load take in milliseconds. sync : bool True if the load was synchronous. """ def __init__(self, num_bytes: int, duration_ms: float, sync: bool) -> None: self.num_bytes = num_bytes self.duration_ms = duration_ms self.sync = sync @property def mbits(self) -> float: """Return Mbits/second.""" return _mbits(self.num_bytes, self.duration_ms) class LoadStats: """Statistics about the recent loads for one layer. Attributes ---------- window_ms : StatWindow Keeps track of the average load time over the window. """ WINDOW_SIZE = 10 # Only keeps stats for this many loads. NUM_RECENT_LOADS = 10 # Save details on this many recent loads. def __init__(self) -> None: self.window_ms: StatWindow = StatWindow(self.WINDOW_SIZE) self.window_bytes: StatWindow = StatWindow(self.WINDOW_SIZE) self.recent_loads: list = [] self.counts: LoadCounts = LoadCounts() def on_load_finished(self, request: ChunkRequest, sync: bool) -> None: """Record stats on this request that was just loaded. Parameters ---------- request : ChunkRequest The request that was just loaded. sync : bool True if the load was synchronous. """ self.window_ms.add(request.load_ms) # Update our StatWindow. # Record the number of loads and chunks. self.counts.loads += 1 self.counts.chunks += request.num_chunks # Increment total bytes loaded. num_bytes = request.num_bytes self.counts.bytes += num_bytes # Time to load all chunks. load_ms = request.load_ms # Update our StatWindows. self.window_bytes.add(num_bytes) self.window_ms.add(load_ms) # Add LoadInfo, keep only NUM_RECENT_LOADS of them. load_info = LoadInfo(num_bytes, load_ms, sync=sync) keep = self.NUM_RECENT_LOADS - 1 self.recent_loads = self.recent_loads[-keep:] + [load_info] if monitor: # Send stats about this one load. monitor.send_message( { "load_chunk": { "time": time.time(), "num_bytes": num_bytes, "load_ms": load_ms, } } ) @property def mbits(self) -> float: """Return Mbit/second.""" return _mbits(self.window_bytes.average, self.window_ms.average) @property def recent_load_str(self) -> str: """Return string describing the sync/async nature of recent loads. Returns ------- str Return "sync", "async" or "mixed". """ num_sync = num_async = 0 for load in self.recent_loads: if load.sync: num_sync += 1 else: num_async += 1 if num_async == 0: return "sync" if num_sync == 0: return "async" return "mixed" class LayerInfo: """Information about one layer the ChunkLoader is tracking. Parameters ---------- layer : Layer The layer we are loading chunks for. Attributes ---------- layer_id : int The id of the layer. layer_ref : weakref Weak reference to the layer. load_type : LoadType Enum for whether to do auto/sync/async loads. auto_sync_ms : int If load takes longer than this many milliseconds make it async. stats : LoadStats Statistics related the loads. Notes ----- We store a weak reference because we do not want an in-progress request to prevent a layer from being deleted. Meanwhile, once a request has finished, we can de-reference the weakref to make sure the layer was not deleted during the load process. """ def __init__(self, layer_ref: LayerRef, auto_sync_ms) -> None: self.layer_ref = layer_ref self.load_type: LoadType = LoadType.AUTO self.auto_sync_ms = auto_sync_ms self.stats = LoadStats() def get_layer(self) -> Layer: """Resolve our weakref to get the layer. Returns ------- layer : Layer The layer for this ChunkRequest. """ layer = self.layer_ref.layer if layer is None: layer_id = self.layer_ref.layer_id LOGGER.debug("LayerInfo.get_layer: layer %d was deleted", layer_id) return layer @property def loads_fast(self) -> bool: """Return True if this layer has been loading very fast.""" average = self.stats.window_ms.average return average is not None and average <= self.auto_sync_ms napari-0.5.0a1/napari/components/experimental/chunk/_loader.py000066400000000000000000000300071437041365600244440ustar00rootroot00000000000000"""ChunkLoader class. Loads chunks synchronously, or asynchronously using worker threads or processes. A chunk could be an OctreeChunk or it could be a pre-Octree array from the Image class, time-series or multi-scale. """ import logging from concurrent.futures import Future from contextlib import contextmanager from typing import Callable, Dict, List, Optional, Tuple from napari.components.experimental.chunk._cache import ChunkCache from napari.components.experimental.chunk._info import LayerInfo, LoadType from napari.components.experimental.chunk._pool_group import LoaderPoolGroup from napari.components.experimental.chunk._request import ChunkRequest from napari.utils.config import octree_config from napari.utils.events import EmitterGroup LOGGER = logging.getLogger("napari.loader") class ChunkLoader: """Loads chunks in worker threads or processes. A ChunkLoader contains one or more LoaderPools. Each LoaderPool has a thread or process pool. Attributes ---------- layer_map : Dict[int, LayerInfo] Stores a LayerInfo about each layer we are tracking. cache : ChunkCache Cache of previously loaded chunks. events : EmitterGroup We only signal one event: chunk_loaded. """ def __init__(self) -> None: _setup_logging(octree_config) loader_config = octree_config['loader_defaults'] self.force_synchronous: bool = bool(loader_config['force_synchronous']) self.auto_sync_ms = loader_config['auto_sync_ms'] self.octree_enabled = octree_config['octree']['enabled'] self.layer_map: Dict[int, LayerInfo] = {} self.cache: ChunkCache = ChunkCache() self.events = EmitterGroup(source=self, chunk_loaded=None) self._loaders = LoaderPoolGroup(octree_config, self._on_done) def get_info(self, layer_id: int) -> Optional[LayerInfo]: """Get LayerInfo for this layer or None. Parameters ---------- layer_id : int The the LayerInfo for this layer. Returns ------- Optional[LayerInfo] The LayerInfo if the layer has one. """ return self.layer_map.get(layer_id) def load_request( self, request: ChunkRequest ) -> Tuple[Optional[ChunkRequest], Optional[Future]]: """Load the given request sync or async. Parameters ---------- request : ChunkRequest Contains one or more arrays to load. Returns ------- Tuple[Optional[ChunkRequest], Optional[Future]] The ChunkRequest if loaded sync or the Future if loaded async. Notes ----- We return a ChunkRequest if the load was performed synchronously, otherwise we return a Future meaning an asynchronous load was intitiated. When the async load finishes the layer's on_chunk_loaded() will be called from the GUI thread. """ self._add_layer_info(request) if self._load_synchronously(request): return request # Check the cache first. chunks = self.cache.get_chunks(request) if chunks is not None: request.chunks = chunks return request self._loaders.load_async(request) return None # None means load was async. def _add_layer_info(self, request: ChunkRequest) -> None: """Add a new LayerInfo entry in our layer map. Parameters ---------- request : ChunkRequest Add a LayerInfo for this request. """ layer_id = request.location.layer_id if layer_id not in self.layer_map: self.layer_map[layer_id] = LayerInfo( request.location.layer_ref, self.auto_sync_ms ) def cancel_requests( self, should_cancel: Callable[[ChunkRequest], bool] ) -> List[ChunkRequest]: """Cancel pending requests based on the given filter. Parameters ---------- should_cancel : Callable[[ChunkRequest], bool] Cancel the request if this returns True. Returns ------- List[ChunkRequests] The requests that were cancelled, if any. """ return self._loaders.cancel_requests(should_cancel) def _load_synchronously(self, request: ChunkRequest) -> bool: """Return True if we loaded the request. Attempt to load the request synchronously. Parameters ---------- request : ChunkRequest The request to load. Returns ------- bool True if we loaded it. """ info = self._get_layer_info(request) if self._should_load_sync(request, info): request.load_chunks() info.stats.on_load_finished(request, sync=True) return True return False def _should_load_sync( self, request: ChunkRequest, info: LayerInfo ) -> bool: """Return True if this layer should load synchronously. Parameters ---------- request : ChunkRequest The request we are loading. info : LayerInfo The layer we are loading the chunk into. """ if info.load_type == LoadType.SYNC: return True # Layer is forcing sync loads. if info.load_type == LoadType.ASYNC: return False # Layer is forcing async loads. assert info.load_type == LoadType.AUTO # AUTO is the only other type. # If forcing synchronous then AUTO always means synchronous. if self.force_synchronous: return True # If it's been loading "fast" then load synchronously. There's no # point is loading async if it loads really fast. # TODO_OCTREE: we no longer do auto-sync, this would be need to be # implement in a nice way for octree? # if info.loads_fast: # return True # Finally, load synchronously if it's an ndarray (in memory) otherwise # it's Dask or something else and we load async. return request.in_memory def _on_done(self, request: ChunkRequest) -> None: """Called when a future finishes with success or was cancelled. Parameters ---------- request : Future The future that finished or was cancelled. Notes ----- This method MAY be called in a worker thread. The concurrent.futures documentation intentionally does not specify which thread the future's done callback will be called in, only that it will be called in some thread in the current process. """ LOGGER.debug( "_done: load=%.3fms elapsed=%.3fms %s", request.load_ms, request.elapsed_ms, request.location, ) # Add chunks to the cache in the worker thread. For now it's safe # to do this in the worker. Later we might need to arrange for this # to be done in the GUI thread if cache access becomes more # complicated. self.cache.add_chunks(request) # Lookup this request's LayerInfo. info = self._get_layer_info(request) # Resolve the weakref. layer = info.get_layer() if layer is None: return # Ignore chunks since layer was deleted. info.stats.on_load_finished(request, sync=False) # Fire chunk_loaded event to tell QtChunkReceiver to forward this # chunk to its layer in the GUI thread. self.events.chunk_loaded(layer=layer, request=request) def _get_layer_info(self, request: ChunkRequest) -> LayerInfo: """Return LayerInfo associated with this request or None. Parameters ---------- request : ChunkRequest Return Layer_info for this request. Raises ------ KeyError If the layer is not found. """ layer_id = request.location.layer_id # Raises KeyError if not found. This should never happen because we # add the layer to the layer_map in ChunkLoader.create_request(). return self.layer_map[layer_id] def on_layer_deleted(self, layer): """The layer was deleted, delete it from our map. Layer The layer that was deleted. """ try: del self.layer_map[id(layer)] except KeyError: pass # We weren't tracking that layer yet. def wait_for_all(self): """Wait for all in-progress requests to finish.""" self.delay_queue.flush() for future_list in self._futures.values(): # Result blocks until the future is done or cancelled map(lambda x: x.result(), future_list) def wait_for_data_id(self, data_id: int) -> None: """Wait for the given data to be loaded. Parameters ---------- data_id : int Wait on chunks for this data_id. """ try: future_list = self._futures[data_id] except KeyError: LOGGER.warning( "wait_for_data_id: no futures for data_id=%d", data_id ) return LOGGER.info( "wait_for_data_id: waiting on %d futures for data_id=%d", len(future_list), data_id, ) # Calling result() will block until the future has finished or was # cancelled. map(lambda x: x.result(), future_list) del self._futures[data_id] def shutdown(self) -> None: """When napari is shutting down.""" self._loaders.shutdown() @contextmanager def synchronous_loading(enabled): """Context object to enable or disable async loading. with synchronous_loading(True): layer = Image(data) ... use layer ... """ previous = chunk_loader.force_synchronous chunk_loader.force_synchronous = enabled try: yield finally: chunk_loader.force_synchronous = previous def wait_for_async(): """Wait for all asynchronous loads to finish.""" chunk_loader.wait_for_all() def _setup_logging(config: dict) -> None: """Setup logging. Notes ----- It's recommended to use the oldest style of string formatting with logging. With f-strings you'd pay the price of formatting the string even if the log statement is disabled due to the log level, etc. In our case the log will almost always be disabled unless debugging. https://docs.python.org/3/howto/logging.html#optimization https://blog.pilosus.org/posts/2020/01/24/python-f-strings-in-logging/ Parameters ---------- config : dict The configuration data. """ try: log_path = config['loader_defaults']['log_path'] if log_path is not None: _log_to_file("napari.loader", log_path) except KeyError: pass try: log_path = config['octree']['log_path'] if log_path is not None: _log_to_file("napari.octree", log_path) except KeyError: pass def _log_to_file(name: str, path: str) -> None: """Log "name" messages to the given file path. Parameters ---------- path : str Log to this file path. """ log_format = "%(levelname)s - %(name)s - %(message)s" logger = logging.getLogger(name) fh = logging.FileHandler(path) formatter = logging.Formatter(log_format) fh.setFormatter(formatter) logger.addHandler(fh) logger.setLevel(logging.DEBUG) """ There is one global chunk_loader instance to handle async loading for all Viewer instances. There are two main reasons we do this instead of one ChunkLoader per Viewer: 1. We size the ChunkCache as a fraction of RAM, so having more than one cache would use too much RAM. 2. We might size the thread pool for optimal performance, and having multiple pools would result in more workers than we want. Think of the ChunkLoader as a shared resource like "the filesystem" where multiple clients can be access it at the same time, but it is the interface to just one physical resource. """ chunk_loader = ChunkLoader() if octree_config else None napari-0.5.0a1/napari/components/experimental/chunk/_pool.py000066400000000000000000000157351437041365600241620ustar00rootroot00000000000000"""LoaderPool class. ChunkLoader has one or more of these. They load data in worker pools. """ from __future__ import annotations import logging from concurrent.futures import ( CancelledError, Future, ProcessPoolExecutor, ThreadPoolExecutor, ) from typing import TYPE_CHECKING, Callable, Dict, List, Optional, Union # Executor for either a thread pool or a process pool. PoolExecutor = Union[ThreadPoolExecutor, ProcessPoolExecutor] LOGGER = logging.getLogger("napari.loader") DoneCallback = Optional[Callable[[Future], None]] if TYPE_CHECKING: from napari.components.experimental.chunk._request import ChunkRequest class LoaderPool: """Loads chunks asynchronously in worker threads or processes. We cannot call np.asarray() in the GUI thread because it might block on IO or a computation. So we call np.asarray() in _chunk_loader_worker() instead. Parameters ---------- config : dict Our configuration, see napari.utils._octree.py for format. on_done_loader : Callable[[Future], None] Called when a future finishes. Attributes ---------- force_synchronous : bool If True all requests are loaded synchronously. num_workers : int The number of workers. use_processes | bool Use processess as workers, otherwise use threads. _executor : PoolExecutor The thread or process pool executor. _futures : Dict[ChunkRequest, Future] In progress futures for each layer (data_id). _delay_queue : DelayQueue Requests sit in here for a bit before submission. """ def __init__( self, config: dict, on_done_loader: DoneCallback = None ) -> None: from napari.components.experimental.chunk._delay_queue import ( DelayQueue, ) self.config = config self._on_done_loader = on_done_loader self.num_workers: int = int(config['num_workers']) self.use_processes: bool = bool(config.get('use_processes', False)) self._executor: PoolExecutor = _create_executor( self.use_processes, self.num_workers ) self._futures: Dict[ChunkRequest, Future] = {} self._delay_queue = DelayQueue(config['delay_queue_ms'], self._submit) def load_async(self, request: ChunkRequest) -> None: """Load this request asynchronously. Parameters ---------- request : ChunkRequest The request to load. """ # Add to the DelayQueue which will call our self._submit() method # right away, if zero delay, or after the configured delay. self._delay_queue.add(request) def cancel_requests( self, should_cancel: Callable[[ChunkRequest], bool] ) -> List[ChunkRequest]: """Cancel pending requests based on the given filter. Parameters ---------- should_cancel : Callable[[ChunkRequest], bool] Cancel the request if this returns True. Returns ------- List[ChunkRequests] The requests that were cancelled, if any. """ # Cancelling requests in the delay queue is fast and easy. cancelled = self._delay_queue.cancel_requests(should_cancel) num_before = len(self._futures) # Cancelling futures may or may not work. Future.cancel() will # return False if the worker is already loading the request and it # cannot be cancelled. for request in list(self._futures.keys()): if self._futures[request].cancel(): del self._futures[request] cancelled.append(request) num_after = len(self._futures) num_cancelled = num_before - num_after LOGGER.debug( "cancel_requests: %d -> %d futures (cancelled %d)", num_before, num_after, num_cancelled, ) return cancelled def _submit(self, request: ChunkRequest) -> Optional[Future]: """Initiate an asynchronous load of the given request. Parameters ---------- request : ChunkRequest Contains the arrays to load. """ # Submit the future. Have it call self._done when finished. future = self._executor.submit(_chunk_loader_worker, request) future.add_done_callback(self._on_done) self._futures[request] = future LOGGER.debug( "_submit_async: %s elapsed=%.3fms num_futures=%d", request.location, request.elapsed_ms, len(self._futures), ) return future def _on_done(self, future: Future) -> None: """Called when a future finishes. future : Future This is the future that finished. """ try: request = self._get_request(future) except ValueError: return # Pool not running? App exit in progress? if request is None: return # Future was cancelled, nothing to do. # Tell the loader this request finished. if self._on_done_loader is not None: self._on_done_loader(request) def shutdown(self) -> None: """Shutdown the pool.""" # Avoid crashes or hangs on exit. self._delay_queue.shutdown() self._executor.shutdown(wait=True) @staticmethod def _get_request(future: Future) -> Optional[ChunkRequest]: """Return the ChunkRequest for this future. Parameters ---------- future : Future Get the request from this future. Returns ------- Optional[ChunkRequest] The ChunkRequest or None if the future was cancelled. """ try: # Our future has already finished since this is being # called from Chunk_Request._done(), so result() will # never block. But we can see if it finished or was # cancelled. Although we don't care right now. return future.result() except CancelledError: return None def _create_executor(use_processes: bool, num_workers: int) -> PoolExecutor: """Return the thread or process pool executor. Parameters ---------- use_processes : bool If True use processes, otherwise threads. num_workers : int The number of worker threads or processes. """ if use_processes: LOGGER.debug("Process pool num_workers=%d", num_workers) return ProcessPoolExecutor(max_workers=num_workers) LOGGER.debug("Thread pool num_workers=%d", num_workers) return ThreadPoolExecutor(max_workers=num_workers) def _chunk_loader_worker(request: ChunkRequest) -> ChunkRequest: """This is the worker thread or process that loads the array. We call np.asarray() in a worker because it might lead to IO or computation which would block the GUI thread. Parameters ---------- request : ChunkRequest The request to load. """ request.load_chunks() # loads all chunks in the request return request napari-0.5.0a1/napari/components/experimental/chunk/_pool_group.py000066400000000000000000000115201437041365600253620ustar00rootroot00000000000000"""LoaderPoolGroup class. """ from __future__ import annotations import bisect from functools import lru_cache from typing import TYPE_CHECKING, Callable, Dict, List from napari.utils.translations import trans if TYPE_CHECKING: from napari.components.experimental.chunk._pool import ( DoneCallback, LoaderPool, ) from napari.components.experimental.chunk._request import ChunkRequest class LoaderPoolGroup: """Holds the LoaderPools that the ChunkLoader is using. Parameters ---------- octree_config : dict The full octree config data. Attributes ---------- _pools : Dict[int, LoaderPool] The mapping from priority to loader pool. """ def __init__( self, octree_config: dict, on_done: DoneCallback = None ) -> None: self._pools = self._create_pools(octree_config, on_done) self._get_loader_priority = lru_cache(maxsize=64)( self._get_loader_priority_impl ) def _create_pools( self, octree_config: dict, on_done: DoneCallback ) -> Dict[int, LoaderPool]: """Return the mapping from priorities to loaders. Parameters ---------- octree_config : dict Octree configuration data. Returns ------- Dict[int, LoaderPool] The loader to use for each priority """ from napari.components.experimental.chunk._pool import LoaderPool configs = _get_loader_configs(octree_config) # Create a LoaderPool for each priority. return { priority: LoaderPool(config, on_done) for (priority, config) in configs.items() } def get_loader(self, priority) -> LoaderPool: """Return the LoaderPool for the given priority. Returns ------- LoaderPool The LoaderPool for the given priority. """ use_priority = self._get_loader_priority(priority) return self._pools[use_priority] def _get_loader_priority_impl(self, priority: int) -> int: """Return the loader priority to use. This method is pretty fast, but since the mapping from priority to LoaderPool is static, use lru_cache. """ priority = max(priority, 0) # No negative priorities. keys = sorted(self._pools.keys()) index = bisect.bisect_left(keys, priority) if index < len(keys) and keys[index] == priority: return priority # Exact hit on a pool, so use it. return keys[index - 1] # Use the pool just before the insertion point. def load_async(self, request: ChunkRequest) -> None: """Load this request asynchronously. Parameters ---------- request : ChunkRequest The request to load. """ self.get_loader(request.priority).load_async(request) def cancel_requests( self, should_cancel: Callable[[ChunkRequest], bool] ) -> List[ChunkRequest]: """Cancel pending requests based on the given filter. Parameters ---------- should_cancel : Callable[[ChunkRequest], bool] Cancel the request if this returns True. Returns ------- List[ChunkRequests] The requests that were cancelled, if any. """ cancelled = [] for pool in self._pools.values(): cancelled.extend(pool.cancel_requests(should_cancel)) return cancelled def shutdown(self) -> None: """Shutdown the pools.""" for pool in self._pools.values(): pool.shutdown() def _get_loader_configs(octree_config) -> Dict[int, dict]: """Return dict of loader configs for the octree. We merge each loader config with the defaults, so that each loader config only needs to specify non-default values. Parameters ---------- octree_config : dict The octree configuration data. Returns ------- Dict[int, dict] A dictionary of loader configs. """ try: defaults = octree_config['loader_defaults'] except KeyError as exc: raise KeyError( trans._( "Missing 'loader_defaults' in octree config.", deferred=True, ) ) from exc try: configs = octree_config['octree']['loaders'] except KeyError: # No octree specific loaders were specificed. We we just have one loader # with default, zero priority should catch everything. return {0: defaults} def merge(config: dict) -> dict: """Return config merged with the defaults. Can't use dict constructor since we have int keys. """ merged = defaults.copy() merged.update(config) return merged # Return merged configs. return {int(key): merge(config) for (key, config) in configs.items()} napari-0.5.0a1/napari/components/experimental/chunk/_request.py000066400000000000000000000163661437041365600247020ustar00rootroot00000000000000"""LayerRef, ChunkLocation and ChunkRequest classes. """ from __future__ import annotations import contextlib import logging import time import weakref from typing import TYPE_CHECKING, Dict, NamedTuple, Optional, Tuple import numpy as np from napari.utils.perf import PerfEvent, block_timer LOGGER = logging.getLogger("napari.loader") if TYPE_CHECKING: from napari.types import ArrayLike # We convert slices to tuple for hashing. SliceTuple = Tuple[Optional[int], Optional[int], Optional[int]] class LayerRef(NamedTuple): """A weakref to a layer and its id.""" layer_id: int layer_ref: weakref.ReferenceType @property def layer(self): return self.layer_ref() @classmethod def from_layer(cls, layer): return cls(id(layer), weakref.ref(layer)) class ChunkLocation: """Location of the chunk. ChunkLocation is the base class for two classes: ImageLocation - pre-octree async loading OctreeLocation - octree async loading Parameters ---------- layer_id : int The id of the layer containing the chunks. layer_ref : weakref.ReferenceType Weak reference to the layer. """ def __init__(self, layer_ref: LayerRef) -> None: self.layer_ref = layer_ref def __eq__(self, other) -> bool: return self.layer_ref.layer_id == other.layer_ref.layer_id @property def layer_id(self) -> int: return self.layer_ref.layer_id @classmethod def from_layer(cls, layer): return cls(LayerRef.from_layer(layer)) class OctreeLocation(ChunkLocation): """Location of one chunk within the octree. Parameters ---------- layer_ref : LayerRef Referen to the layer this location is in. slice_id : int The id of the OctreeSlice we are in. level_index : int The octree level index. row : int The chunk row. col : int The chunk col. """ def __init__( self, layer_ref: LayerRef, slice_id: int, level_index: int, row: int, col: int, ) -> None: super().__init__(layer_ref) self.slice_id: int = slice_id self.level_index: int = level_index self.row: int = row self.col: int = col def __str__(self): return f"location=({self.level_index}, {self.row}, {self.col}) " def __eq__(self, other) -> bool: return ( self.slice_id == other.slice_id and self.level_index == other.level_index and self.row == other.row and self.col == other.col ) def __hash__(self) -> int: return hash((self.slice_id, self.level_index, self.row, self.col)) class ChunkRequest: """A request asking the ChunkLoader to load data. Parameters ---------- location : ChunkLocation The location of this chunk. Probably a class derived from ChunkLocation such as ImageLocation or OctreeLocation. chunks : Dict[str, ArrayLike] One or more arrays that we need to load. Attributes ---------- location : ChunkLocation The location of the chunks. chunks : Dict[str, ArrayLike] One or more arrays that we need to load. create_time : float The time the request was created. _timers : Dict[str, PerfEvent] Timing information about chunk load time. """ def __init__( self, location: ChunkLocation, chunks: Dict[str, ArrayLike], priority: int = 0, ) -> None: # Make sure chunks dict is valid. for chunk_key, array in chunks.items(): assert isinstance(chunk_key, str) assert array is not None self.location = location self.chunks = chunks self.create_time = time.time() self._timers: Dict[str, PerfEvent] = {} self.priority = priority @property def elapsed_ms(self) -> float: """The total time elapsed since the request was created. Returns ------- float The total time elapsed since the chunk was created. """ return (time.time() - self.create_time) * 1000 @property def load_ms(self) -> float: """The total time it took to load all chunks. Returns ------- float The total time it took to return all chunks. """ return sum( perf_timer.duration_ms for perf_timer in self._timers.values() ) @property def num_chunks(self) -> int: """The number of chunks in this request. Returns ------- int The number of chunks in this request. """ return len(self.chunks) @property def num_bytes(self) -> int: """The number of bytes that were loaded. Returns ------- int The number of bytes that were loaded. """ return sum(array.nbytes for array in self.chunks.values()) @property def in_memory(self) -> bool: """True if all chunks are ndarrays. Returns ------- bool True if all chunks are ndarrays. """ return all(isinstance(x, np.ndarray) for x in self.chunks.values()) @contextlib.contextmanager def _chunk_timer(self, name): """Time a block of code and save the PerfEvent in self._timers. We want to time our loads whether perfmon is enabled or not, since the auto-async feature needs to work in all cases. Parameters ---------- name : str The name of the timer. Yields ------ PerfEvent The timing event for the block. """ with block_timer(name) as event: yield event self._timers[name] = event def load_chunks(self): """Load all of our chunks now in this thread. We time the overall load with the special name "load_chunks" and then we time each chunk as it loads, using it's array name as the key. """ for key, array in self.chunks.items(): with self._chunk_timer(key): loaded_array = np.asarray(array) self.chunks[key] = loaded_array def transpose_chunks(self, order: tuple) -> None: """Transpose all our chunks. Parameters ---------- order Transpose the chunks into this order. """ for key, array in self.chunks.items(): self.chunks[key] = array.transpose(order) @property def image(self) -> Optional[ArrayLike]: """The image chunk or None. Returns ------- Optional[ArrayLike] The image chunk or None if we don't have one. """ return self.chunks.get('image') @property def thumbnail_source(self): """The chunk to use as the thumbnail_source or None. Returns ------- Optional[ArrayLike] The thumbnail_source chunk or None if we don't have one. """ try: return self.chunks['thumbnail_source'] except KeyError: # No thumbnail_source so return the image instead. For single-scale # we use the image as the thumbnail_source. return self.chunks.get('image') napari-0.5.0a1/napari/components/experimental/chunk/_tests/000077500000000000000000000000001437041365600237665ustar00rootroot00000000000000napari-0.5.0a1/napari/components/experimental/chunk/_tests/__init__.py000066400000000000000000000000001437041365600260650ustar00rootroot00000000000000napari-0.5.0a1/napari/components/experimental/chunk/_tests/test_chunk.py000066400000000000000000000041741437041365600265150ustar00rootroot00000000000000"""Tests for components.experimental.chunk.""" import numpy as np import pytest from napari.components.experimental.chunk import ( ChunkLocation, ChunkRequest, LayerRef, chunk_loader, ) from napari.layers.image import Image def _create_layer() -> Image: """Return a small random Image layer.""" data = np.random.random((32, 16)) return Image(data) def test_base_location(): """Test the base ChunkLocation class. The base ChunkLocation is not really used, only the derived ImageLocation and OctreeLocation are, but test it anyway. """ layer1 = _create_layer() layer2 = _create_layer() layer_ref1 = LayerRef.from_layer(layer1) layer_ref2 = LayerRef.from_layer(layer2) location1a = ChunkLocation(layer_ref1) location1b = ChunkLocation(layer_ref1) location2 = ChunkLocation(layer_ref2) assert location1a == location1b assert location1a != location2 assert location1b != location2 @pytest.mark.async_only def test_loader(): """Test ChunkRequest and the ChunkLoader.""" layer = _create_layer() shape = (64, 32) transpose_shape = (32, 64) # Just load one array. data = np.random.random(shape) chunks = {'image': data} # Give data2 different data. data2 = data * 2 # Create the ChunkRequest. location = ChunkLocation.from_layer(layer) request = ChunkRequest(location, chunks) # Load the ChunkRequest. request = chunk_loader.load_request(request) # Data should only match data not data2. assert np.all(data == request.image.data) assert not np.all(data2 == request.image.data) # request.image is just short-hand for request.chunks['image'] assert np.all(request.image.data == request.chunks['image'].data) # Since we didn't ask for a thumbnail_source it should just be the image data. assert np.all(request.thumbnail_source.data == request.image.data) # KeyError for chunks that do not exist. with pytest.raises(KeyError): request.chunks['missing_chunk_name'] # Test transpose_chunks() request.transpose_chunks((1, 0)) assert request.image.shape == transpose_shape napari-0.5.0a1/napari/components/experimental/chunk/_tests/test_loader.py000066400000000000000000000043211437041365600266450ustar00rootroot00000000000000"""Test _get_loader_configs() function.""" import pytest from napari.components.experimental.chunk._pool_group import ( _get_loader_configs, ) def test_get_loader_config_error(): """Test that defaults are required.""" with pytest.raises(KeyError): _get_loader_configs({}) @pytest.mark.async_only def test_get_loader_config_defaults(): """Test config that has defaults but no octree loaders.""" config = { "loader_defaults": { "force_synchronous": False, "num_workers": 10, "delay_queue_ms": 0, }, "octree": {}, } configs = _get_loader_configs(config) assert len(configs) == 1 assert configs[0]['num_workers'] == 10 assert configs[0]['delay_queue_ms'] == 0 TEST_CONFIG = { "loader_defaults": { "force_synchronous": False, "num_workers": 10, "delay_queue_ms": 0, }, "octree": { "loaders": { 0: {"num_workers": 10, "delay_queue_ms": 100}, 3: {"num_workers": 5, "delay_queue_ms": 0}, }, }, } @pytest.mark.async_only def test_get_loader_config_override(): """Test two loaders that override the defaults.""" configs = _get_loader_configs(TEST_CONFIG) # Check each config overrode the defaults. assert len(configs) == 2 assert configs[0]['num_workers'] == 10 assert configs[3]['num_workers'] == 5 assert configs[0]['delay_queue_ms'] == 100 assert configs[3]['delay_queue_ms'] == 0 # Check the defaults are still there. assert configs[0]['force_synchronous'] is False assert configs[3]['force_synchronous'] is False @pytest.mark.async_only def test_loader_pool_group(): from napari.components.experimental.chunk._pool_group import ( LoaderPoolGroup, ) group = LoaderPoolGroup(TEST_CONFIG) # Test _get_loader_priority() returns the priority of the pool we # should use. The one at or below the priority we give it. assert group._get_loader_priority(0) == 0 assert group._get_loader_priority(1) == 0 assert group._get_loader_priority(2) == 0 assert group._get_loader_priority(3) == 3 assert group._get_loader_priority(4) == 3 assert group._get_loader_priority(5) == 3 napari-0.5.0a1/napari/components/experimental/chunk/_utils.py000066400000000000000000000043111437041365600243350ustar00rootroot00000000000000"""ChunkLoader utilities. """ from typing import Optional import dask.array as da import numpy as np def _get_type_str(data) -> str: """Get human readable name for the data's type. Returns ------- str A string like "ndarray" or "dask". """ data_type = type(data) if data_type == list: if len(data) == 0: return "EMPTY" # Recursively get the type string of the zeroth level. return _get_type_str(data[0]) if data_type == da.Array: # Special case this because otherwise data_type.__name__ # below would just return "Array". return "dask" # For class numpy.ndarray this returns "ndarray" return data_type.__name__ class StatWindow: """Average value over a rolling window. Notes ----- Inserting values once the window is full is O(1). However calculating the average is O(N) although using numpy. Parameters ---------- size : int The size of the window. Attributes ---------- values : ndarray The values in our window. """ def __init__(self, size: int) -> None: self.size = size self.values = np.array([]) # float64 array # Once the window is full we insert values at this index, the # index loops through the slots circularly, forever. self.index = 0 def add(self, value: float) -> None: """Add one value to the window. Parameters ---------- value : float Add this value to the window. """ if len(self.values) < self.size: # Not super efficient but once window is full we are O(1). self.values = np.append(self.values, value) else: # Window is full, poke values in circularly. self.values[self.index] = value self.index = (self.index + 1) % self.size @property def average(self) -> Optional[float]: """Return the average of all the values in the window. Returns ------- float The average of all values in the window. """ if len(self.values) == 0: return None return float(np.average(self.values)) napari-0.5.0a1/napari/components/experimental/commands.py000066400000000000000000000024451437041365600235350ustar00rootroot00000000000000"""ExperimentalNamespace and CommandProcessor classes. """ from napari.components.experimental.chunk._commands._utils import highlight HELP_STR = f""" {highlight("Available Commands:")} experimental.cmds.loader """ class CommandProcessor: """Container for the LoaderCommand. Implements the console command "viewer.experimental.cmds.loader". Parameters ---------- layers The viewer's layers. """ def __init__(self, layers) -> None: self.layers = layers @property def loader(self): """The loader related commands.""" from napari.components.experimental.chunk._commands import ( LoaderCommands, ) return LoaderCommands(self.layers) def __repr__(self): return "Available Commands:\nexperimental.cmds.loader" class ExperimentalNamespace: """Container for the CommandProcessor. Implements the console command "viewer.experimental.cmds". Parameters ---------- layers The viewer's layers. """ def __init__(self, layers) -> None: self.layers = layers @property def cmds(self): """All experimental commands.""" return CommandProcessor(self.layers) def __repr__(self): return "Available Commands:\nexperimental.cmds.loader" napari-0.5.0a1/napari/components/experimental/monitor/000077500000000000000000000000001437041365600230445ustar00rootroot00000000000000napari-0.5.0a1/napari/components/experimental/monitor/__init__.py000066400000000000000000000003071437041365600251550ustar00rootroot00000000000000"""Monitor service.""" from napari.components.experimental.monitor._monitor import monitor from napari.components.experimental.monitor._utils import numpy_dumps __all__ = ["monitor", "numpy_dumps"] napari-0.5.0a1/napari/components/experimental/monitor/_api.py000066400000000000000000000161611437041365600243330ustar00rootroot00000000000000"""MonitorApi class. """ import logging from multiprocessing.managers import SharedMemoryManager from queue import Empty, Queue from threading import Event from typing import NamedTuple from napari.utils.events import EmitterGroup LOGGER = logging.getLogger("napari.monitor") # The client needs to know this. AUTH_KEY = "napari" # Port 0 means the OS chooses an available port. We send the server_port # port to the client in its NAPARI_MON_CLIENT variable. SERVER_PORT = 0 class NapariRemoteAPI(NamedTuple): """Napari exposes these shared resources.""" napari_data: dict napari_messages: Queue napari_shutdown: Event client_data: dict client_messages: Queue class MonitorApi: """The API that monitor clients can access. The MonitorApi creates and exposes a few "shared resources" that monitor clients can access. Client access the shared resources through their SharedMemoryManager which connects to napari. Exactly what resources we should expose is TBD. Here we are experimenting with having queue for sending message in each direction, and a shared dict for sharing data in both directions. The advantage of a Queue is presumably the other party will definitely get the message. While the advantage of dict is kind of the opposite, the other party can check the dict if they want, or they can ignore it. Again we're not sure what's best yet. But this illustrates some options. Shared Resources ---------------- napari_data : dict Napari shares data in this dict for clients to read. napari_messages : Queue Napari puts messages in here for clients to read. napari_shutdown : Event Napari signals this event when shutting down. Although today napari does not wait on anything, so typically the client just gets a connection error when napari goes away, rather than seeing this event. client_data : Queue Client shares data in here for napari to read. client_messages : Queue Client puts messages in here for napari to read, such as commands. Notes ----- The SharedMemoryManager provides the same proxy objects as SyncManager including list, dict, Barrier, BoundedSemaphore, Condition, Event, Lock, Namespace, Queue, RLock, Semaphore, Array, Value. SharedMemoryManager is derived from BaseManager, but it has similar functionality to SyncManager. See the official Python docs for multiprocessing.managers.SyncManager. Numpy can natively use shared memory buffers, something we want to try. """ # BaseManager.register() is a bit weird. Not sure now to best deal with # it. Most ways I tried led to pickling errors, because this class is being run # in the shared memory server process? Feel free to find a better approach. _napari_data_dict = dict() _napari_messages_queue = Queue() _napari_shutdown_event = Event() _client_data_dict = dict() _client_messages_queue = Queue() @staticmethod def _napari_data() -> Queue: return MonitorApi._napari_data_dict @staticmethod def _napari_messages() -> Queue: return MonitorApi._napari_messages_queue @staticmethod def _napari_shutdown() -> Event: return MonitorApi._napari_shutdown_event @staticmethod def _client_data() -> Queue: return MonitorApi._client_data_dict @staticmethod def _client_messages() -> Queue: return MonitorApi._client_messages_queue def __init__(self) -> None: # RemoteCommands listens to our run_command event. It executes # commands from the clients. self.events = EmitterGroup(source=self, run_command=None) # We must register all callbacks before we create our instance of # SharedMemoryManager. The client must do the same thing, but it # only needs to know the names. We allocate the shared memory. SharedMemoryManager.register('napari_data', callable=self._napari_data) SharedMemoryManager.register( 'napari_messages', callable=self._napari_messages ) SharedMemoryManager.register( 'napari_shutdown', callable=self._napari_shutdown ) SharedMemoryManager.register('client_data', callable=self._client_data) SharedMemoryManager.register( 'client_messages', callable=self._client_messages ) # Start our shared memory server. self._manager = SharedMemoryManager( address=('127.0.0.1', SERVER_PORT), authkey=str.encode(AUTH_KEY) ) self._manager.start() # Get the shared resources the server created. Clients will access # these same resources. self._remote = NapariRemoteAPI( self._manager.napari_data(), self._manager.napari_messages(), self._manager.napari_shutdown(), self._manager.client_data(), self._manager.client_messages(), ) @property def manager(self) -> SharedMemoryManager: """Our shared memory manager. The wrapper Monitor class accesses this and passes it to the MonitorService. Returns ------- SharedMemoryManager The manager we created and are using. """ return self._manager def stop(self) -> None: """Notify clients we are shutting down. If we wanted a graceful shutdown, we could wait on "connected" clients to exit. With a short timeout in case they are hung. Today we just signal this event and immediately exit. So most of the time clients just get a connection error. They never see that this event was set. """ self._remote.napari_shutdown.set() def poll(self): """Poll client_messages for new messages.""" assert self._manager is not None self._process_client_messages() def _process_client_messages(self) -> None: """Process every new message in the queue.""" client_messages = self._remote.client_messages while True: try: message = client_messages.get_nowait() if not isinstance(message, dict): LOGGER.warning( "Ignore message that was not a dict: %s", message ) continue # Assume every message is a command that napari should # execute. We might have other types of messages later. self.events.run_command(command=message) except Empty: return # No commands to process. def add_napari_data(self, data: dict) -> None: """Add data for shared memory clients to read. Parameters ---------- data : dict Add this data, replacing anything with the same key. """ self._remote.napari_data.update(data) def send_napari_message(self, message: dict) -> None: """Send a message to shared memory clients. Parameters ---------- message : dict Message to send to clients. """ self._remote.napari_messages.put(message) napari-0.5.0a1/napari/components/experimental/monitor/_monitor.py000066400000000000000000000151761437041365600252560ustar00rootroot00000000000000"""Monitor class. The Monitor class wraps the MonitorServer and MonitorApi. One reason for having a wrapper class is that so the rest of napari does not need to import any multiprocessing code unless actually using the monitor. """ import errno import json import logging import os import sys from pathlib import Path from typing import Optional from napari.utils.translations import trans LOGGER = logging.getLogger("napari.monitor") # If False monitor is disabled even if we meet all other requirements. ENABLE_MONITOR = True def _load_config(path: str) -> dict: """Load the JSON formatted config file. Parameters ---------- path : str The path of the JSON file we should load. Returns ------- dict The parsed data from the JSON file. """ path = Path(path).expanduser() if not path.exists(): raise FileNotFoundError( errno.ENOENT, trans._( "Monitor: Config file not found: {path}", deferred=True, path=path, ), ) with path.open() as infile: return json.load(infile) def _load_monitor_config() -> Optional[dict]: """Return the MonitorService config file data, or None. Returns ------- Optional[dict] The parsed config file data or None if no config. """ # We shouldn't even call into this file unless NAPARI_MON is defined # but check to be sure. value = os.getenv("NAPARI_MON") if value in [None, "0"]: return None return _load_config(value) def _setup_logging(config: dict) -> None: """Log "napari.monitor" messages to the configured file. Parameters ---------- config : dict Monitor configuration """ try: log_path = config['log_path'] except KeyError: return # No log file. # Nuke/reset log for now. # Path(log_path).unlink() fh = logging.FileHandler(log_path) LOGGER.addHandler(fh) LOGGER.setLevel(logging.DEBUG) LOGGER.info("Writing to log path %s", log_path) def _get_monitor_config() -> Optional[dict]: """Create and return the configuration for the MonitorService. The routine might return None for one serveral reasons: 1) We're not running under Python 3.9 or now. 2) The monitor is explicitly disable, ENABLED_MONITOR is False. 3) The NAPARI_MON environment variable is not defined. 4) The NAPARI_MON config file cannot be found and parsed. Returns ------- Optional[dict] The configuration for the MonitorService. """ if sys.version_info[:2] < (3, 9): # We require Python 3.9 for now. The shared memory features we need # were added in 3.8, but the 3.8 implemention was buggy. It's # possible we could backport to or otherwise fix 3.8 or even 3.7, # but for now we're making 3.9 a requirement. print("Monitor: not starting, requires Python 3.9 or newer") return None if not ENABLE_MONITOR: print("Monitor: not starting, disabled") return None # The NAPARI_MON environment variable points to our config file. config = _load_monitor_config() if config is None: print("Monitor: not starting, no usable config file") return None return config class Monitor: """Wraps the monitor service. We can't start the monitor service at import time. Under the hood the multiprocessing complains about a "partially started process". Instead someone must call our start() method explicitly once the process has fully started. """ def __init__(self) -> None: # Both are set when start() is called, and only if we have # a parseable config file, have Python 3.9, etc. self._service = None self._api = None self._running = False def __nonzero__(self) -> bool: """Return True if the service is running. So that callers can do: if monitor: monitor.add(...) """ return self._running @property def run_command_event(self): """The MonitorAPI fires this event for commands from clients.""" return self._api.events.run_command def start(self) -> bool: """Start the monitor service, if it hasn't been started already. Returns ------- bool True if we started the service or it was already started. """ if self._running: return True # It was already started. config = _get_monitor_config() if config is None: return False # Can't start without config. _setup_logging(config) # Late imports so no multiprocessing modules are even # imported unless we are going to start the service. from napari.components.experimental.monitor._api import MonitorApi from napari.components.experimental.monitor._service import ( MonitorService, ) # Create the API first. It will register our callbacks, then # we start the manager that will serve those callbacks. self._api = MonitorApi() # Now we can start our service. self._service = MonitorService(config, self._api.manager) self._running = True return True # We started the service. def stop(self) -> None: """Stop the monitor service.""" if not self._running: return self._api.stop() self._api = None self._service.stop() self._service = None self._running = False def on_poll(self, event) -> None: """The QtPoll object polls us. Probably we could get rid of polling by creating a thread that blocks waiting for client messages. Then it posts those messages as Qt Events. So the GUI doesn't block, but gracefully handles incoming messages as Qt events. """ if self._running: self._api.poll() # Handle the event to say "keep polling us". event.handled = True def add_data(self, data) -> None: """Add data to the monitor service. Caller should use this pattern: if monitor: monitor.add(...) So no time wasted assembling the dict unless the monitor is running. """ if self._running: self._api.add_napari_data(data) def send_message(self, message: dict) -> None: """Send a message to shared memory clients. Parameters ---------- message : dict Post this message to clients. """ if self._running: self._api.send_napari_message(message) monitor = Monitor() napari-0.5.0a1/napari/components/experimental/monitor/_service.py000066400000000000000000000123251437041365600252200ustar00rootroot00000000000000"""MonitorService class. Experimental shared memory service. Requires Python 3.9, for now at least. Monitor Config File ------------------- Only if NAPARI_MON is set and points to a config file will the monitor even start. The format of the .napari-mon config file is: { "clients": [ ["python", "/tmp/myclient.py"] ] "log_path": "/tmp/monitor.log" } All of the listed clients will be started. They can be the same program run with different arguments, or different programs. All clients will have access to the same shared memory. Client Config File ------------------- The client should decode the contents of the NAPARI_MON_CLIENT variable. It can be decoded like this: def _get_client_config() -> dict: env_str = os.getenv("NAPARI_MON_CLIENT") if env_str is None: return None env_bytes = env_str.encode('ascii') config_bytes = base64.b64decode(env_bytes) config_str = config_bytes.decode('ascii') return json.loads(config_str) Today the client configuration is only: { "server_port": "" } Client Startup -------------- See a working client: https://github.com/pwinston/webmon The client can access the MonitorApi by creating a SharedMemoryManager: napari_api = ['shutdown_event', 'command_queue', 'data'] for name in napari_api: SharedMemoryManager.register(name) SharedMemoryManager.register('command_queue') self.manager = SharedMemoryManager( address=('localhost', config['server_port']), authkey=str.encode('napari') ) # Get the shared resources. shutdown = self._manager.shutdown_event() commands = self._manager.command_queue() data = self._manager.data() It can send command like: commands.put( {"test_command": {"value": 42, "names": ["fred", "joe"]}} ) Passing Data From Napari To The Client -------------------------------------- In napari add data like: if monitor: monitor.add({ "tiled_image_layer": { "num_created": stats.created, "num_deleted": stats.deleted, "duration_ms": elapsed.duration_ms, } }) The client can access data['tiled_image_layer']. Clients should be resilient to missing data. Nn case the napari version is different than expected, or is just not producing that data for some reason. Future Work ----------- We plan to investigate the use of numpy shared memory buffers for bulk binary data. Possibly using recarray to organized things. """ import copy import logging import os import subprocess from multiprocessing.managers import SharedMemoryManager from napari.components.experimental.monitor._utils import base64_encoded_json LOGGER = logging.getLogger("napari.monitor") # If False we don't start any clients, for debugging. START_CLIENTS = True # We pass the data in this template to each client as an encoded # NAPARI_MON_CLIENT environment variable. client_config_template = { "server_port": "", } def _create_client_env(server_port: int) -> dict: """Create and return the environment for the client. Parameters ---------- server_port : int The port the client should connect to. """ # Every client gets the same config. Copy template and then stuff # in the correct values. client_config = copy.deepcopy(client_config_template) client_config['server_port'] = server_port # Start with our environment and just add in the one variable. env = os.environ.copy() env.update({"NAPARI_MON_CLIENT": base64_encoded_json(client_config)}) return env class MonitorService: """Make data available to a client via shared memory. Originally we used a ShareableList and serialized JSON into one of the slots in the list. However now we are using the MonitorApi._data dict proxy object instead. That serializes to JSON under the hood, but it's nicer that doing int ourselves. So this class is not doing much right now. However if we add true shared memory buffers for numpy, etc. then this class might manager those. """ def __init__(self, config: dict, manager: SharedMemoryManager) -> None: super().__init__() self._config = config self._manager = manager if START_CLIENTS: self._start_clients() def _start_clients(self) -> None: """Start every client in our config.""" # We asked for port 0 which means the OS will pick a port, we # save it off so we can send it the clients are starting up. server_port = self._manager.address[1] LOGGER.info("Listening on port %s", server_port) num_clients = len(self._config['clients']) LOGGER.info("Starting %d clients...", num_clients) env = _create_client_env(server_port) # Start every client. for args in self._config['clients']: LOGGER.info("Starting client %s", args) # Use Popen to run and not wait for the process to finish. subprocess.Popen(args, env=env) LOGGER.info("Started %d clients.", num_clients) def stop(self) -> None: """Stop the shared memory service.""" LOGGER.info("MonitorService.stop") self._manager.shutdown() napari-0.5.0a1/napari/components/experimental/monitor/_utils.py000066400000000000000000000017061437041365600247210ustar00rootroot00000000000000"""Monitor Utilities. """ import base64 import json import numpy as np class NumpyJSONEncoder(json.JSONEncoder): """A JSONEncoder that also converts ndarray's to lists.""" def default(self, o): if isinstance(o, np.ndarray): return o.tolist() return json.JSONEncoder.default(self, o) def numpy_dumps(data: dict) -> str: """Return data as a JSON string. Returns ------- str The JSON string. """ return json.dumps(data, cls=NumpyJSONEncoder) def base64_encoded_json(data: dict) -> str: """Return base64 encoded version of this data as JSON. Parameters ---------- data : dict The data to write as JSON then base64 encode. Returns ------- str The base64 encoded JSON string. """ json_str = numpy_dumps(data) json_bytes = json_str.encode('ascii') message_bytes = base64.b64encode(json_bytes) return message_bytes.decode('ascii') napari-0.5.0a1/napari/components/experimental/remote/000077500000000000000000000000001437041365600226505ustar00rootroot00000000000000napari-0.5.0a1/napari/components/experimental/remote/__init__.py000066400000000000000000000001461437041365600247620ustar00rootroot00000000000000from napari.components.experimental.remote._manager import RemoteManager __all__ = ["RemoteManager"] napari-0.5.0a1/napari/components/experimental/remote/_commands.py000066400000000000000000000050151437041365600251630ustar00rootroot00000000000000"""RemoteCommands class. """ import json import logging from napari.components.layerlist import LayerList from napari.layers.image.experimental.octree_image import _OctreeImageBase LOGGER = logging.getLogger("napari.monitor") class RemoteCommands: """Commands that a remote client can call. The MonitorApi commands a shared Queue calls "commands" that clients can put commands into. When MonitorApi.poll() is called, it checks the queue. It calls its run_command event for every command in the queue. This class listens to that event and processes those commands. The reason we use an event is so the monitor modules do not need to depend on Layer or LayerList. If they did it would create circular dependencies because people need to be able to import the monitor from anywhere. Parameters ---------- layers : LayerList The viewer's layers, so we can call into them. Notes ----- This is kind of a crude system for mapping remote commands to local methods, there probably is a better way with fancier use of events or something else. Also long term we don't what this to become a centralized repository of commands, command implementations should be spread out all over the system. """ def __init__(self, layers: LayerList) -> None: self.layers = layers def show_grid(self, show: bool) -> None: """Set whether the octree tile grid is visible. Parameters ---------- show : bool If True the grid is shown. """ for layer in self.layers.selected: if isinstance(layer, _OctreeImageBase): layer.display.show_grid = show def process_command(self, event) -> None: """Process this one command from the remote client. Parameters ---------- event : dict The remote command. """ command = event.command LOGGER.info("RemoveCommands._process_command: %s", json.dumps(command)) # Every top-level key in in the command should be a method # in this RemoveCommands class. # # { "set_grid": True } # # Then we would call self.set_grid(True) # for name, args in command.items(): try: method = getattr(self, name) LOGGER.info("Calling RemoteCommands.%s(%s)", name, args) method(args) except AttributeError: LOGGER.error("RemoteCommands.%s does not exist.", name) napari-0.5.0a1/napari/components/experimental/remote/_manager.py000066400000000000000000000026101437041365600247720ustar00rootroot00000000000000"""RemoteManager class. """ import logging from napari.components.experimental.remote._commands import RemoteCommands from napari.components.experimental.remote._messages import RemoteMessages from napari.components.layerlist import LayerList from napari.utils.events import Event LOGGER = logging.getLogger("napari.monitor") class RemoteManager: """Interacts with remote clients. The monitor system itself purposely does not depend on anything else in napari except for utils.events. However RemoteManager and its children RemoteCommands and RemoteMessages do very much depend on napari. RemoteCommands executes commands sent to napari by clients. RemoteMessages sends messages to remote clients, such as the current state of the layers. Parameters ---------- layers : LayerList The viewer's layers. """ def __init__(self, layers: LayerList) -> None: self._commands = RemoteCommands(layers) self._messages = RemoteMessages(layers) def process_command(self, event: Event) -> None: """Process this command from a remote client. Parameters ---------- event : Event Contains the command to process. """ return self._commands.process_command(event) def on_poll(self, _event: Event) -> None: """Send out messages when polled.""" self._messages.on_poll() napari-0.5.0a1/napari/components/experimental/remote/_messages.py000066400000000000000000000035311437041365600251720ustar00rootroot00000000000000"""RemoteMessages class. Sends messages to remote clients. """ import logging import time from typing import Dict from napari.components.experimental.monitor import monitor from napari.components.layerlist import LayerList from napari.layers.image.experimental.octree_image import _OctreeImageBase LOGGER = logging.getLogger("napari.monitor") class RemoteMessages: """Sends messages to remote clients. Parameters ---------- layers : LayerList The viewer's layers, so we can call into them. """ def __init__(self, layers: LayerList) -> None: self.layers = layers self._frame_number = 0 self._last_time = None def on_poll(self) -> None: """Send messages to clients. These message go out once per frame. So it might not make sense to include static information that rarely changes. Although if it's small, maybe it's okay. The message looks like: { "poll": { "layers": { 13482484: { "tile_state": ... "tile_config": ... } } } } """ self._frame_number += 1 layers: Dict[int, dict] = {} for layer in self.layers: if isinstance(layer, _OctreeImageBase): layers[id(layer)] = layer.remote_messages monitor.add_data({"poll": {"layers": layers}}) self._send_frame_time() def _send_frame_time(self) -> None: """Send the frame time since last poll.""" now = time.time() last = self._last_time delta = now - last if last is not None else 0 delta_ms = delta * 1000 monitor.send_message( {'frame_time': {'time': now, 'delta_ms': delta_ms}} ) self._last_time = now napari-0.5.0a1/napari/components/grid.py000066400000000000000000000071151437041365600201630ustar00rootroot00000000000000from typing import Tuple import numpy as np from napari.settings._application import GridHeight, GridStride, GridWidth from napari.utils.events import EventedModel class GridCanvas(EventedModel): """Grid for canvas. Right now the only grid mode that is still inside one canvas with one camera, but future grid modes could support multiple canvases. Attributes ---------- enabled : bool If grid is enabled or not. stride : int Number of layers to place in each grid square before moving on to the next square. The default ordering is to place the most visible layer in the top left corner of the grid. A negative stride will cause the order in which the layers are placed in the grid to be reversed. shape : 2-tuple of int Number of rows and columns in the grid. A value of -1 for either or both of will be used the row and column numbers will trigger an auto calculation of the necessary grid shape to appropriately fill all the layers at the appropriate stride. """ # fields stride: GridStride = 1 shape: Tuple[GridHeight, GridWidth] = (-1, -1) enabled: bool = False def actual_shape(self, nlayers: int = 1) -> Tuple[int, int]: """Return the actual shape of the grid. This will return the shape parameter, unless one of the row or column numbers is -1 in which case it will compute the optimal shape of the grid given the number of layers and current stride. If the grid is not enabled, this will return (1, 1). Parameters ---------- nlayers : int Number of layers that need to be placed in the grid. Returns ------- shape : 2-tuple of int Number of rows and columns in the grid. """ if self.enabled: if nlayers == 0: return (1, 1) n_row, n_column = self.shape n_grid_squares = np.ceil(nlayers / abs(self.stride)).astype(int) if n_row == -1 and n_column == -1: n_column = np.ceil(np.sqrt(n_grid_squares)).astype(int) n_row = np.ceil(n_grid_squares / n_column).astype(int) elif n_row == -1: n_row = np.ceil(n_grid_squares / n_column).astype(int) elif n_column == -1: n_column = np.ceil(n_grid_squares / n_row).astype(int) n_row = max(1, n_row) n_column = max(1, n_column) return (n_row, n_column) else: return (1, 1) def position(self, index: int, nlayers: int) -> Tuple[int, int]: """Return the position of a given linear index in grid. If the grid is not enabled, this will return (0, 0). Parameters ---------- index : int Position of current layer in layer list. nlayers : int Number of layers that need to be placed in the grid. Returns ------- position : 2-tuple of int Row and column position of current index in the grid. """ if self.enabled: n_row, n_column = self.actual_shape(nlayers) # Adjust for forward or reverse ordering if self.stride < 0: adj_i = nlayers - index - 1 else: adj_i = index adj_i = adj_i // abs(self.stride) adj_i = adj_i % (n_row * n_column) i_row = adj_i // n_column i_column = adj_i % n_column return (i_row, i_column) else: return (0, 0) napari-0.5.0a1/napari/components/layerlist.py000066400000000000000000000424651437041365600212550ustar00rootroot00000000000000import itertools import warnings from collections import namedtuple from functools import cached_property from typing import TYPE_CHECKING, Iterable, List, Optional, Tuple, Union import numpy as np from napari.layers import Layer from napari.layers.image.image import _ImageBase from napari.utils.events.containers import SelectableEventedList from napari.utils.naming import inc_name_count from napari.utils.translations import trans Extent = namedtuple('Extent', 'data world step') if TYPE_CHECKING: from npe2.manifest.io import WriterContribution class LayerList(SelectableEventedList[Layer]): """List-like layer collection with built-in reordering and callback hooks. Parameters ---------- data : iterable Iterable of napari.layer.Layer Events ------ inserting : (index: int) emitted before an item is inserted at ``index`` inserted : (index: int, value: T) emitted after ``value`` is inserted at ``index`` removing : (index: int) emitted before an item is removed at ``index`` removed : (index: int, value: T) emitted after ``value`` is removed at ``index`` moving : (index: int, new_index: int) emitted before an item is moved from ``index`` to ``new_index`` moved : (index: int, new_index: int, value: T) emitted after ``value`` is moved from ``index`` to ``new_index`` changed : (index: int, old_value: T, value: T) emitted when ``index`` is set from ``old_value`` to ``value`` changed : (index: slice, old_value: List[_T], value: List[_T]) emitted when ``index`` is set from ``old_value`` to ``value`` reordered : (value: self) emitted when the list is reordered (eg. moved/reversed). selection.events.changed : (added: Set[_T], removed: Set[_T]) emitted when the set changes, includes item(s) that have been added and/or removed from the set. selection.events.active : (value: _T) emitted when the current item has changed. selection.events._current : (value: _T) emitted when the current item has changed. (Private event) """ def __init__(self, data=()) -> None: super().__init__( data=data, basetype=Layer, lookup={str: lambda e: e.name}, ) # TODO: figure out how to move this context creation bit. # Ideally, the app should be aware of the layerlist, but not vice versa. # This could probably be done by having the layerlist emit events that the app # connects to, then the `_ctx` object would live on the app, (not here) from napari._app_model.context import create_context from napari._app_model.context._layerlist_context import ( LayerListContextKeys, ) self._ctx = create_context(self) if self._ctx is not None: # happens during Viewer type creation self._ctx_keys = LayerListContextKeys(self._ctx) self.selection.events.changed.connect(self._ctx_keys.update) # temporary: see note in _on_selection_event self.selection.events.changed.connect(self._on_selection_changed) def _on_selection_changed(self, event): # This method is a temporary workaround to the fact that the Points # layer needs to know when its selection state changes so that it can # update the highlight state. This (and the layer._on_selection # method) can be removed once highlighting logic has been removed from # the layer model. for layer in event.added: layer._on_selection(True) for layer in event.removed: layer._on_selection(False) def _process_delete_item(self, item: Layer): super()._process_delete_item(item) item.events.extent.disconnect(self._clean_cache) self._clean_cache() def _clean_cache(self): cached_properties = ( 'extent', '_extent_world', '_step_size', '_ranges', ) [self.__dict__.pop(p, None) for p in cached_properties] def __newlike__(self, data): return LayerList(data) def _coerce_name(self, name, layer=None): """Coerce a name into a unique equivalent. Parameters ---------- name : str Original name. layer : napari.layers.Layer, optional Layer for which name is generated. Returns ------- new_name : str Coerced, unique name. """ existing_layers = {x.name for x in self if x is not layer} for _ in range(len(self)): if name in existing_layers: name = inc_name_count(name) return name def _update_name(self, event): """Coerce name of the layer in `event.layer`.""" layer = event.source layer.name = self._coerce_name(layer.name, layer) def _ensure_unique(self, values, allow=()): bad = set(self._list) - set(allow) values = tuple(values) if isinstance(values, Iterable) else (values,) for v in values: if v in bad: raise ValueError( trans._( "Layer '{v}' is already present in layer list", deferred=True, v=v, ) ) return values def __setitem__(self, key, value): old = self._list[key] if isinstance(key, slice): value = self._ensure_unique(value, old) elif isinstance(key, int): (value,) = self._ensure_unique((value,), (old,)) super().__setitem__(key, value) def insert(self, index: int, value: Layer): """Insert ``value`` before index.""" (value,) = self._ensure_unique((value,)) new_layer = self._type_check(value) new_layer.name = self._coerce_name(new_layer.name) self._clean_cache() new_layer.events.extent.connect(self._clean_cache) super().insert(index, new_layer) def toggle_selected_visibility(self): """Toggle visibility of selected layers""" for layer in self.selection: layer.visible = not layer.visible @cached_property def _extent_world(self) -> np.ndarray: """Extent of layers in world coordinates. Default to 2D with (-0.5, 511.5) min/ max values if no data is present. Corresponds to pixels centered at [0, ..., 511]. Returns ------- extent_world : array, shape (2, D) """ return self._get_extent_world([layer.extent for layer in self]) def _get_min_and_max(self, mins_list, maxes_list): # Reverse dimensions since it is the last dimensions that are # displayed. mins_list = [mins[::-1] for mins in mins_list] maxes_list = [maxes[::-1] for maxes in maxes_list] with warnings.catch_warnings(): # Taking the nanmin and nanmax of an axis of all nan # raises a warning and returns nan for that axis # as we have do an explicit nan_to_num below this # behaviour is acceptable and we can filter the # warning warnings.filterwarnings( 'ignore', message=str( trans._('All-NaN axis encountered', deferred=True) ), ) min_v = np.nanmin( list(itertools.zip_longest(*mins_list, fillvalue=np.nan)), axis=1, ) max_v = np.nanmax( list(itertools.zip_longest(*maxes_list, fillvalue=np.nan)), axis=1, ) # 512 element default extent as documented in `_get_extent_world` min_v = np.nan_to_num(min_v, nan=-0.5) max_v = np.nan_to_num(max_v, nan=511.5) # switch back to original order return min_v[::-1], max_v[::-1] def _get_extent_world(self, layer_extent_list): """Extent of layers in world coordinates. Default to 2D with (-0.5, 511.5) min/ max values if no data is present. Corresponds to pixels centered at [0, ..., 511]. Returns ------- extent_world : array, shape (2, D) """ if len(self) == 0: min_v = np.asarray([-0.5] * self.ndim) max_v = np.asarray([511.5] * self.ndim) else: extrema = [extent.world for extent in layer_extent_list] mins = [e[0] for e in extrema] maxs = [e[1] for e in extrema] min_v, max_v = self._get_min_and_max(mins, maxs) return np.vstack([min_v, max_v]) @cached_property def _step_size(self) -> np.ndarray: """Ideal step size between planes in world coordinates. Computes the best step size that allows all data planes to be sampled if moving through the full range of world coordinates. The current implementation just takes the minimum scale. Returns ------- step_size : array, shape (D,) """ return self._get_step_size([layer.extent for layer in self]) def _step_size_from_scales(self, scales): # Reverse order so last axes of scale with different ndim are aligned scales = [scale[::-1] for scale in scales] full_scales = list( np.array(list(itertools.zip_longest(*scales, fillvalue=np.nan))) ) # restore original order return np.nanmin(full_scales, axis=1)[::-1] def _get_step_size(self, layer_extent_list): if len(self) == 0: return np.ones(self.ndim) scales = [extent.step for extent in layer_extent_list] return self._step_size_from_scales(scales) def get_extent(self, layers: Iterable[Layer]) -> Extent: """ Return extent for a given layer list. This function is useful for calculating the extent of a subset of layers when preparing and updating some supplementary layers. For example see the cross Vectors layer in the `multiple_viewer_widget` example. Parameters ---------- layers : list of Layer list of layers for which extent should be calculated Returns ------- extent : Extent extent for selected layers """ extent_list = [layer.extent for layer in layers] return Extent( data=None, world=self._get_extent_world(extent_list), step=self._get_step_size(extent_list), ) @cached_property def extent(self) -> Extent: """Extent of layers in data and world coordinates.""" return self.get_extent([x for x in self]) @cached_property def _ranges(self) -> List[Tuple[float, float, float]]: """Get ranges for Dims.range in world coordinates. This shares some code in common with the `extent` property, but determines Dims.range settings for each dimension such that each range is aligned to pixel centers at the finest scale. """ if len(self) == 0: return [(0, 1, 1)] * self.ndim else: # Determine minimum step size across all layers layer_extent_list = [layer.extent for layer in self] scales = [extent.step for extent in layer_extent_list] min_steps = self._step_size_from_scales(scales) # Pixel-based layers need to be offset by 0.5 * min_steps to align # Dims.range with pixel centers in world coordinates pixel_offsets = [ 0.5 * min_steps if isinstance(layer, _ImageBase) else [0] * len(min_steps) for layer in self ] # Non-pixel layers need an offset of the range stop by min_steps since the upper # limit of Dims.range is non-inclusive. point_offsets = [ [0] * len(min_steps) if isinstance(layer, _ImageBase) else min_steps for layer in self ] # Determine world coordinate extents similarly to # `_get_extent_world`, but including offsets calculated above. extrema = [extent.world for extent in layer_extent_list] mins = [ e[0] + o1[: len(e[0])] for e, o1 in zip(extrema, pixel_offsets) ] maxs = [ e[1] + o1[: len(e[0])] + o2[: len(e[0])] for e, o1, o2 in zip(extrema, pixel_offsets, point_offsets) ] min_v, max_v = self._get_min_and_max(mins, maxs) # form range tuples, switching back to original dimension order return [ (start, stop, step) for start, stop, step in zip(min_v, max_v, min_steps) ] @property def ndim(self) -> int: """Maximum dimensionality of layers. Defaults to 2 if no data is present. Returns ------- ndim : int """ return max((layer.ndim for layer in self), default=2) def _link_layers( self, method: str, layers: Optional[Iterable[Union[str, Layer]]] = None, attributes: Iterable[str] = (), ): # adding this method here allows us to emit an event when # layers in this group are linked/unlinked. Which is necessary # for updating context from napari.layers.utils import _link_layers if layers is not None: layers = [self[x] if isinstance(x, str) else x for x in layers] # type: ignore else: layers = self getattr(_link_layers, method)(layers, attributes) self.selection.events.changed(added={}, removed={}) def link_layers( self, layers: Optional[Iterable[Union[str, Layer]]] = None, attributes: Iterable[str] = (), ): return self._link_layers('link_layers', layers, attributes) def unlink_layers( self, layers: Optional[Iterable[Union[str, Layer]]] = None, attributes: Iterable[str] = (), ): return self._link_layers('unlink_layers', layers, attributes) def save( self, path: str, *, selected: bool = False, plugin: Optional[str] = None, _writer: Optional['WriterContribution'] = None, ) -> List[str]: """Save all or only selected layers to a path using writer plugins. If ``plugin`` is not provided and only one layer is targeted, then we directly call the corresponding``napari_write_`` hook (see :ref:`single layer writer hookspecs `) which will loop through implementations and stop when the first one returns a non-``None`` result. The order in which implementations are called can be changed with the Plugin sorter in the GUI or with the corresponding hook's :meth:`~napari.plugins._hook_callers._HookCaller.bring_to_front` method. If ``plugin`` is not provided and multiple layers are targeted, then we call :meth:`~napari.plugins.hook_specifications.napari_get_writer` which loops through plugins to find the first one that knows how to handle the combination of layers and is able to write the file. If no plugins offer :meth:`~napari.plugins.hook_specifications.napari_get_writer` for that combination of layers then the default :meth:`~napari.plugins.hook_specifications.napari_get_writer` will create a folder and call ``napari_write_`` for each layer using the ``Layer.name`` variable to modify the path such that the layers are written to unique files in the folder. If ``plugin`` is provided and a single layer is targeted, then we call the ``napari_write_`` for that plugin, and if it fails we error. If ``plugin`` is provided and multiple layers are targeted, then we call we call :meth:`~napari.plugins.hook_specifications.napari_get_writer` for that plugin, and if it doesn’t return a ``WriterFunction`` we error, otherwise we call it and if that fails if it we error. Parameters ---------- path : str A filepath, directory, or URL to open. Extensions may be used to specify output format (provided a plugin is available for the requested format). selected : bool Optional flag to only save selected layers. False by default. plugin : str, optional Name of the plugin to use for saving. If None then all plugins corresponding to appropriate hook specification will be looped through to find the first one that can save the data. _writer : WriterContribution, optional private: npe2 specific writer override. Returns ------- list of str File paths of any files that were written. """ from napari.plugins.io import save_layers layers = ( [x for x in self if x in self.selection] if selected else list(self) ) if selected: msg = trans._("No layers selected", deferred=True) else: msg = trans._("No layers to save", deferred=True) if not layers: warnings.warn(msg) return [] return save_layers(path, layers, plugin=plugin, _writer=_writer) napari-0.5.0a1/napari/components/overlays/000077500000000000000000000000001437041365600205245ustar00rootroot00000000000000napari-0.5.0a1/napari/components/overlays/__init__.py000066400000000000000000000012331437041365600226340ustar00rootroot00000000000000from napari.components.overlays.axes import AxesOverlay from napari.components.overlays.base import ( CanvasOverlay, Overlay, SceneOverlay, ) from napari.components.overlays.bounding_box import BoundingBoxOverlay from napari.components.overlays.interaction_box import ( SelectionBoxOverlay, TransformBoxOverlay, ) from napari.components.overlays.scale_bar import ScaleBarOverlay from napari.components.overlays.text import TextOverlay __all__ = [ "AxesOverlay", "Overlay", "CanvasOverlay", "BoundingBoxOverlay", "SelectionBoxOverlay", "TransformBoxOverlay", "ScaleBarOverlay", "SceneOverlay", "TextOverlay", ] napari-0.5.0a1/napari/components/overlays/axes.py000066400000000000000000000021121437041365600220320ustar00rootroot00000000000000from napari.components.overlays.base import SceneOverlay class AxesOverlay(SceneOverlay): """Axes indicating world coordinate origin and orientation. Attributes ---------- labels : bool If axes labels are visible or not. Not the actual axes labels are stored in `viewer.dims.axes_labels`. colored : bool If axes are colored or not. If colored then default coloring is x=cyan, y=yellow, z=magenta. If not colored than axes are the color opposite of the canvas background. dashed : bool If axes are dashed or not. If not dashed then all the axes are solid. If dashed then x=solid, y=dashed, z=dotted. arrows : bool If axes have arrowheads or not. visible : bool If the overlay is visible or not. opacity : float The opacity of the overlay. 0 is fully transparent. order : int The rendering order of the overlay: lower numbers get rendered first. """ labels: bool = True colored: bool = True dashed: bool = False arrows: bool = True napari-0.5.0a1/napari/components/overlays/base.py000066400000000000000000000035711437041365600220160ustar00rootroot00000000000000from napari.components._viewer_constants import CanvasPosition from napari.utils.events import EventedModel class Overlay(EventedModel): """ Overlay evented model. An overlay is a renderable entity meant to display additional information on top of the layer data, but is not data per se. For example: a scale bar, a color bar, axes, bounding boxes, etc. Attributes ---------- visible : bool If the overlay is visible or not. opacity : float The opacity of the overlay. 0 is fully transparent. order : int The rendering order of the overlay: lower numbers get rendered first. """ visible: bool = False opacity: float = 1 order: int = 1e6 def __hash__(self): return id(self) class CanvasOverlay(Overlay): """ Canvas overlay model. Canvas overlays live in canvas space; they do not live in the 2- or 3-dimensional scene being rendered, but in the 2D space of the screen. For example: scale bars, colormap bars, etc. Attributes ---------- position : CanvasPosition The position of the overlay in the canvas. visible : bool If the overlay is visible or not. opacity : float The opacity of the overlay. 0 is fully transparent. order : int The rendering order of the overlay: lower numbers get rendered first. """ position: CanvasPosition = CanvasPosition.BOTTOM_RIGHT class SceneOverlay(Overlay): """ Scene overlay model. Scene overlays live in the 2- or 3-dimensional space of the rendered data. For example: bounding boxes, data grids, etc. Attributes ---------- visible : bool If the overlay is visible or not. opacity : float The opacity of the overlay. 0 is fully transparent. order : int The rendering order of the overlay: lower numbers get rendered first. """ napari-0.5.0a1/napari/components/overlays/bounding_box.py000066400000000000000000000020561437041365600235560ustar00rootroot00000000000000from napari.components.overlays.base import SceneOverlay from napari.utils.color import ColorValue class BoundingBoxOverlay(SceneOverlay): """ Bounding box overlay to indicate layer boundaries. Attributes ---------- lines : bool Whether to show the lines of the bounding box. line_thickness : float Thickness of the lines in canvas pixels. line_color : ColorValue Color of the lines. points : bool Whether to show the vertices of the bounding box as points. point_size : float Size of the points in canvas pixels. point_color : ColorValue Color of the points. visible : bool If the overlay is visible or not. opacity : float The opacity of the overlay. 0 is fully transparent. order : int The rendering order of the overlay: lower numbers get rendered first. """ lines: bool = True line_thickness: float = 1 line_color: ColorValue = 'red' points: bool = True point_size: float = 5 point_color: ColorValue = 'blue' napari-0.5.0a1/napari/components/overlays/interaction_box.py000066400000000000000000000033051437041365600242660ustar00rootroot00000000000000from typing import Optional, Tuple from napari.components.overlays.base import SceneOverlay from napari.layers.utils.interaction_box import ( InteractionBoxHandle, calculate_bounds_from_contained_points, ) class SelectionBoxOverlay(SceneOverlay): """A box that can be used to select and transform objects. Attributes ---------- bounds : 2-tuple of 2-tuples Corners at top left and bottom right in layer coordinates. handles : bool Whether to show the handles for transfomation or just the box. selected_handle : Optional[InteractionBoxHandle] The currently selected handle. visible : bool If the overlay is visible or not. opacity : float The opacity of the overlay. 0 is fully transparent. order : int The rendering order of the overlay: lower numbers get rendered first. """ bounds: Tuple[Tuple[float, float], Tuple[float, float]] = ((0, 0), (0, 0)) handles: bool = False selected_handle: Optional[InteractionBoxHandle] = None def update_from_points(self, points): """Create as a bounding box of the given points""" self.bounds = calculate_bounds_from_contained_points(points) class TransformBoxOverlay(SceneOverlay): """A box that can be used to transform layers. Attributes ---------- selected_handle : Optional[InteractionBoxHandle] The currently selected handle. visible : bool If the overlay is visible or not. opacity : float The opacity of the overlay. 0 is fully transparent. order : int The rendering order of the overlay: lower numbers get rendered first. """ selected_handle: Optional[InteractionBoxHandle] = None napari-0.5.0a1/napari/components/overlays/scale_bar.py000066400000000000000000000033321437041365600230120ustar00rootroot00000000000000"""Scale bar model.""" from typing import Optional from napari.components.overlays.base import CanvasOverlay from napari.utils.color import ColorValue class ScaleBarOverlay(CanvasOverlay): """Scale bar indicating size in world coordinates. Attributes ---------- colored : bool If scale bar are colored or not. If colored then default color is magenta. If not colored than scale bar color is the opposite of the canvas background or the background box. color : ColorValue Scalebar and text color. See ``ColorValue.validate`` for supported values. ticks : bool If scale bar has ticks at ends or not. background_color : np.ndarray Background color of canvas. If scale bar is not colored then it has the color opposite of this color. font_size : float The font size (in points) of the text. box : bool If background box is visible or not. box_color : Optional[str | array-like] Background box color. See ``ColorValue.validate`` for supported values. unit : Optional[str] Unit to be used by the scale bar. The value can be set to `None` to display no units. position : CanvasPosition The position of the overlay in the canvas. visible : bool If the overlay is visible or not. opacity : float The opacity of the overlay. 0 is fully transparent. order : int The rendering order of the overlay: lower numbers get rendered first. """ colored: bool = False color: ColorValue = [1, 0, 1, 1] ticks: bool = True font_size: float = 10 box: bool = False box_color: ColorValue = [0, 0, 0, 0.6] unit: Optional[str] = None napari-0.5.0a1/napari/components/overlays/text.py000066400000000000000000000015401437041365600220620ustar00rootroot00000000000000"""Text label model.""" from napari.components.overlays.base import CanvasOverlay from napari.utils.color import ColorValue class TextOverlay(CanvasOverlay): """Label model to display arbitrary text in the canvas Attributes ---------- color : np.ndarray A (4,) color array of the text overlay. font_size : float The font size (in points) of the text. text : str Text to be displayed in the canvas. position : CanvasPosition The position of the overlay in the canvas. visible : bool If the overlay is visible or not. opacity : float The opacity of the overlay. 0 is fully transparent. order : int The rendering order of the overlay: lower numbers get rendered first. """ color: ColorValue = (0.5, 0.5, 0.5, 1.0) font_size: float = 10 text: str = "" napari-0.5.0a1/napari/components/tooltip.py000066400000000000000000000005131437041365600207230ustar00rootroot00000000000000from napari.utils.events import EventedModel class Tooltip(EventedModel): """Tooltip showing additional information on the cursor. Attributes ---------- visible : bool If tooltip is visible or not. text : str text of tooltip """ # fields visible: bool = False text: str = "" napari-0.5.0a1/napari/components/viewer_model.py000066400000000000000000001637521437041365600217310ustar00rootroot00000000000000from __future__ import annotations import inspect import itertools import os import warnings from functools import lru_cache from pathlib import Path from typing import ( TYPE_CHECKING, Any, Dict, List, Mapping, Optional, Sequence, Set, Tuple, Union, ) import numpy as np from pydantic import Extra, Field, PrivateAttr, validator from napari import layers from napari.components._viewer_mouse_bindings import dims_scroll from napari.components.camera import Camera from napari.components.cursor import Cursor from napari.components.dims import Dims from napari.components.grid import GridCanvas from napari.components.layerlist import LayerList from napari.components.overlays import ( AxesOverlay, Overlay, ScaleBarOverlay, TextOverlay, ) from napari.components.tooltip import Tooltip from napari.errors import ( MultipleReaderError, NoAvailableReaderError, ReaderPluginError, ) from napari.layers import ( Image, Labels, Layer, Points, Shapes, Surface, Tracks, Vectors, ) from napari.layers._source import layer_source from napari.layers.image._image_key_bindings import image_fun_to_mode from napari.layers.image._image_utils import guess_labels from napari.layers.labels._labels_key_bindings import labels_fun_to_mode from napari.layers.points._points_key_bindings import points_fun_to_mode from napari.layers.shapes._shapes_key_bindings import shapes_fun_to_mode from napari.layers.surface._surface_key_bindings import surface_fun_to_mode from napari.layers.tracks._tracks_key_bindings import tracks_fun_to_mode from napari.layers.utils.stack_utils import split_channels from napari.layers.vectors._vectors_key_bindings import vectors_fun_to_mode from napari.plugins.utils import get_potential_readers, get_preferred_reader from napari.settings import get_settings from napari.utils._register import create_func as create_add_method from napari.utils.action_manager import action_manager from napari.utils.colormaps import ensure_colormap from napari.utils.events import ( Event, EventedDict, EventedModel, disconnect_events, ) from napari.utils.events.event import WarningEmitter from napari.utils.key_bindings import KeymapProvider from napari.utils.migrations import rename_argument from napari.utils.misc import is_sequence from napari.utils.mouse_bindings import MousemapProvider from napari.utils.progress import progress from napari.utils.theme import available_themes, is_theme_available from napari.utils.translations import trans DEFAULT_THEME = 'dark' EXCLUDE_DICT = { 'keymap', '_mouse_wheel_gen', '_mouse_drag_gen', '_persisted_mouse_event', 'mouse_move_callbacks', 'mouse_drag_callbacks', 'mouse_wheel_callbacks', } EXCLUDE_JSON = EXCLUDE_DICT.union({'layers', 'active_layer'}) if TYPE_CHECKING: from napari.types import FullLayerData, LayerData PathLike = Union[str, Path] PathOrPaths = Union[PathLike, Sequence[PathLike]] __all__ = ['ViewerModel', 'valid_add_kwargs'] def _current_theme() -> str: return get_settings().appearance.theme DEFAULT_OVERLAYS = { 'scale_bar': ScaleBarOverlay, 'text': TextOverlay, 'axes': AxesOverlay, } # KeymapProvider & MousemapProvider should eventually be moved off the ViewerModel class ViewerModel(KeymapProvider, MousemapProvider, EventedModel): """Viewer containing the rendered scene, layers, and controlling elements including dimension sliders, and control bars for color limits. Parameters ---------- title : string The title of the viewer window. ndisplay : {2, 3} Number of displayed dimensions. order : tuple of int Order in which dimensions are displayed where the last two or last three dimensions correspond to row x column or plane x row x column if ndisplay is 2 or 3. axis_labels : list of str Dimension names. Attributes ---------- window : Window Parent window. layers : LayerList List of contained layers. dims : Dimensions Contains axes, indices, dimensions and sliders. """ # Using allow_mutation=False means these attributes aren't settable and don't # have an event emitter associated with them camera: Camera = Field(default_factory=Camera, allow_mutation=False) cursor: Cursor = Field(default_factory=Cursor, allow_mutation=False) dims: Dims = Field(default_factory=Dims, allow_mutation=False) grid: GridCanvas = Field(default_factory=GridCanvas, allow_mutation=False) layers: LayerList = Field( default_factory=LayerList, allow_mutation=False ) # Need to create custom JSON encoder for layer! help: str = '' status: Union[str, Dict] = 'Ready' tooltip: Tooltip = Field(default_factory=Tooltip, allow_mutation=False) theme: str = Field(default_factory=_current_theme) title: str = 'napari' # private track of overlays, only expose the old ones for backward compatibility _overlays: EventedDict[str, Overlay] = PrivateAttr( default_factory=EventedDict ) # 2-tuple indicating height and width _canvas_size: Tuple[int, int] = (600, 800) _ctx: Mapping # To check if mouse is over canvas to avoid race conditions between # different events systems mouse_over_canvas: bool = False def __init__( self, title='napari', ndisplay=2, order=(), axis_labels=() ) -> None: # max_depth=0 means don't look for parent contexts. from napari._app_model.context import create_context # FIXME: just like the LayerList, this object should ideally be created # elsewhere. The app should know about the ViewerModel, but not vice versa. self._ctx = create_context(self, max_depth=0) # allow extra attributes during model initialization, useful for mixins self.__config__.extra = Extra.allow super().__init__( title=title, dims={ 'axis_labels': axis_labels, 'ndisplay': ndisplay, 'order': order, }, ) self.__config__.extra = Extra.ignore settings = get_settings() self.tooltip.visible = settings.appearance.layer_tooltip_visibility settings.appearance.events.layer_tooltip_visibility.connect( self._tooltip_visible_update ) self._update_viewer_grid() settings.application.events.grid_stride.connect( self._update_viewer_grid ) settings.application.events.grid_width.connect( self._update_viewer_grid ) settings.application.events.grid_height.connect( self._update_viewer_grid ) # Add extra events - ideally these will be removed too! self.events.add( layers_change=WarningEmitter( trans._( "This event will be removed in 0.5.0. Please use viewer.layers.events instead", deferred=True, ), type="layers_change", ), reset_view=Event, ) # Connect events self.grid.events.connect(self.reset_view) self.grid.events.connect(self._on_grid_change) self.dims.events.ndisplay.connect(self._update_layers) self.dims.events.ndisplay.connect(self.reset_view) self.dims.events.order.connect(self._update_layers) self.dims.events.order.connect(self.reset_view) self.dims.events.current_step.connect(self._update_layers) self.cursor.events.position.connect( self._update_status_bar_from_cursor ) self.layers.events.inserted.connect(self._on_add_layer) self.layers.events.removed.connect(self._on_remove_layer) self.layers.events.reordered.connect(self._on_grid_change) self.layers.events.reordered.connect(self._on_layers_change) self.layers.selection.events.active.connect(self._on_active_layer) # Add mouse callback self.mouse_wheel_callbacks.append(dims_scroll) self._overlays.update({k: v() for k, v in DEFAULT_OVERLAYS.items()}) # simple properties exposing overlays for backward compatibility @property def axes(self): return self._overlays['axes'] @property def scale_bar(self): return self._overlays['scale_bar'] @property def text_overlay(self): return self._overlays['text'] def _tooltip_visible_update(self, event): self.tooltip.visible = event.value def _update_viewer_grid(self): """Keep viewer grid settings up to date with settings values.""" settings = get_settings() self.grid.stride = settings.application.grid_stride self.grid.shape = ( settings.application.grid_height, settings.application.grid_width, ) @validator('theme') def _valid_theme(cls, v): if not is_theme_available(v): raise ValueError( trans._( "Theme '{theme_name}' not found; options are {themes}.", deferred=True, theme_name=v, themes=", ".join(available_themes()), ) ) return v def json(self, **kwargs): """Serialize to json.""" # Manually exclude the layer list and active layer which cannot be serialized at this point # and mouse and keybindings don't belong on model # https://github.com/samuelcolvin/pydantic/pull/2231 # https://github.com/samuelcolvin/pydantic/issues/660#issuecomment-642211017 exclude = kwargs.pop('exclude', set()) exclude = exclude.union(EXCLUDE_JSON) return super().json(exclude=exclude, **kwargs) def dict(self, **kwargs): """Convert to a dictionary.""" # Manually exclude the layer list and active layer which cannot be serialized at this point # and mouse and keybindings don't belong on model # https://github.com/samuelcolvin/pydantic/pull/2231 # https://github.com/samuelcolvin/pydantic/issues/660#issuecomment-642211017 exclude = kwargs.pop('exclude', set()) exclude = exclude.union(EXCLUDE_DICT) return super().dict(exclude=exclude, **kwargs) def __hash__(self): return id(self) def __str__(self): """Simple string representation""" return f'napari.Viewer: {self.title}' @property def _sliced_extent_world(self) -> np.ndarray: """Extent of layers in world coordinates after slicing. D is either 2 or 3 depending on if the displayed data is 2D or 3D. Returns ------- sliced_extent_world : array, shape (2, D) """ if len(self.layers) == 0 and self.dims.ndim != 2: # If no data is present and dims model has not been reset to 0 # than someone has passed more than two axis labels which are # being saved and so default values are used. return np.vstack( [np.zeros(self.dims.ndim), np.repeat(512, self.dims.ndim)] ) else: return self.layers.extent.world[:, self.dims.displayed] def reset_view(self): """Reset the camera view.""" extent = self._sliced_extent_world scene_size = extent[1] - extent[0] corner = extent[0] grid_size = list(self.grid.actual_shape(len(self.layers))) if len(scene_size) > len(grid_size): grid_size = [1] * (len(scene_size) - len(grid_size)) + grid_size size = np.multiply(scene_size, grid_size) center = np.add(corner, np.divide(size, 2))[-self.dims.ndisplay :] center = [0] * (self.dims.ndisplay - len(center)) + list(center) self.camera.center = center # zoom is definied as the number of canvas pixels per world pixel # The default value used below will zoom such that the whole field # of view will occupy 95% of the canvas on the most filled axis if np.max(size) == 0: self.camera.zoom = 0.95 * np.min(self._canvas_size) else: scale = np.array(size[-2:]) scale[np.isclose(scale, 0)] = 1 self.camera.zoom = 0.95 * np.min( np.array(self._canvas_size) / scale ) self.camera.angles = (0, 0, 90) # Emit a reset view event, which is no longer used internally, but # which maybe useful for building on napari. self.events.reset_view( center=self.camera.center, zoom=self.camera.zoom, angles=self.camera.angles, ) def _new_labels(self): """Create new labels layer filling full world coordinates space.""" layers_extent = self.layers.extent extent = layers_extent.world scale = layers_extent.step scene_size = extent[1] - extent[0] corner = extent[0] + 0.5 * layers_extent.step shape = [ np.round(s / sc).astype('int') if s > 0 else 1 for s, sc in zip(scene_size, scale) ] empty_labels = np.zeros(shape, dtype=int) self.add_labels(empty_labels, translate=np.array(corner), scale=scale) def _update_layers(self, *, layers=None): """Updates the contained layers. Parameters ---------- layers : list of napari.layers.Layer, optional List of layers to update. If none provided updates all. """ layers = layers or self.layers for layer in layers: layer._slice_dims( self.dims.point, self.dims.ndisplay, self.dims.order ) position = list(self.cursor.position) for ind in self.dims.order[: -self.dims.ndisplay]: position[ind] = self.dims.point[ind] self.cursor.position = position def _on_active_layer(self, event): """Update viewer state for a new active layer.""" active_layer = event.value if active_layer is None: self.help = '' self.cursor.style = 'standard' self.camera.interactive = True else: self.help = active_layer.help self.cursor.style = active_layer.cursor self.cursor.size = active_layer.cursor_size self.camera.interactive = active_layer.interactive @staticmethod def rounded_division(min_val, max_val, precision): return int(((min_val + max_val) / 2) / precision) * precision def _on_layers_change(self): if len(self.layers) == 0: self.dims.ndim = 2 self.dims.reset() else: ranges = self.layers._ranges ndim = len(ranges) self.dims.ndim = ndim self.dims.set_range(range(ndim), ranges) new_dim = self.dims.ndim dim_diff = new_dim - len(self.cursor.position) if dim_diff < 0: self.cursor.position = self.cursor.position[:new_dim] elif dim_diff > 0: self.cursor.position = tuple( list(self.cursor.position) + [0] * dim_diff ) self.events.layers_change() def _update_interactive(self, event): """Set the viewer interactivity with the `event.interactive` bool.""" if event.source is self.layers.selection.active: self.camera.interactive = event.interactive def _update_cursor(self, event): """Set the viewer cursor with the `event.cursor` string.""" self.cursor.style = event.cursor def _update_cursor_size(self, event): """Set the viewer cursor_size with the `event.cursor_size` int.""" self.cursor.size = event.cursor_size def _update_status_bar_from_cursor(self, event=None): """Update the status bar based on the current cursor position. This is generally used as a callback when cursor.position is updated. """ # Update status and help bar based on active layer if not self.mouse_over_canvas: return active = self.layers.selection.active if active is not None: self.status = active.get_status( self.cursor.position, view_direction=self.cursor._view_direction, dims_displayed=list(self.dims.displayed), world=True, ) self.help = active.help if self.tooltip.visible: self.tooltip.text = active._get_tooltip_text( self.cursor.position, view_direction=self.cursor._view_direction, dims_displayed=list(self.dims.displayed), world=True, ) else: self.status = 'Ready' def _on_grid_change(self): """Arrange the current layers is a 2D grid.""" extent = self._sliced_extent_world n_layers = len(self.layers) for i, layer in enumerate(self.layers): i_row, i_column = self.grid.position(n_layers - 1 - i, n_layers) self._subplot(layer, (i_row, i_column), extent) def _subplot(self, layer, position, extent): """Shift a layer to a specified position in a 2D grid. Parameters ---------- layer : napari.layers.Layer Layer that is to be moved. position : 2-tuple of int New position of layer in grid. extent : array, shape (2, D) Extent of the world. """ scene_shift = extent[1] - extent[0] translate_2d = np.multiply(scene_shift[-2:], position) translate = [0] * layer.ndim translate[-2:] = translate_2d layer._translate_grid = translate @property def experimental(self): """Experimental commands for IPython console. For example run "viewer.experimental.cmds.loader.help". """ from napari.components.experimental.commands import ( ExperimentalNamespace, ) return ExperimentalNamespace(self.layers) def _on_add_layer(self, event): """Connect new layer events. Parameters ---------- event : :class:`napari.layers.Layer` Layer to add. """ layer = event.value # Connect individual layer events to viewer events # TODO: in a future PR, we should now be able to connect viewer *only* # to viewer.layers.events... and avoid direct viewer->layer connections layer.events.interactive.connect(self._update_interactive) layer.events.cursor.connect(self._update_cursor) layer.events.cursor_size.connect(self._update_cursor_size) layer.events.data.connect(self._on_layers_change) layer.events.scale.connect(self._on_layers_change) layer.events.translate.connect(self._on_layers_change) layer.events.rotate.connect(self._on_layers_change) layer.events.shear.connect(self._on_layers_change) layer.events.affine.connect(self._on_layers_change) layer.events.name.connect(self.layers._update_name) if hasattr(layer.events, "mode"): layer.events.mode.connect(self._on_layer_mode_change) self._layer_help_from_mode(layer) # Update dims and grid model self._on_layers_change() self._on_grid_change() # Slice current layer based on dims self._update_layers(layers=[layer]) if len(self.layers) == 1: self.reset_view() ranges = self.layers._ranges midpoint = [self.rounded_division(*_range) for _range in ranges] self.dims.set_point(range(len(ranges)), midpoint) @staticmethod def _layer_help_from_mode(layer: Layer): """ Update layer help text base on layer mode. """ layer_to_func_and_mode = { Points: points_fun_to_mode, Labels: labels_fun_to_mode, Shapes: shapes_fun_to_mode, Vectors: vectors_fun_to_mode, Image: image_fun_to_mode, Surface: surface_fun_to_mode, Tracks: tracks_fun_to_mode, } help_li = [] shortcuts = get_settings().shortcuts.shortcuts for fun, mode_ in layer_to_func_and_mode.get(layer.__class__, []): if mode_ == layer.mode: continue action_name = f"napari:{fun.__name__}" desc = action_manager._actions[action_name].description.lower() if not shortcuts.get(action_name, []): continue help_li.append( trans._( "use <{shortcut}> for {desc}", shortcut=shortcuts[action_name][0], desc=desc, ) ) layer.help = ", ".join(help_li) def _on_layer_mode_change(self, event): self._layer_help_from_mode(event.source) if (active := self.layers.selection.active) is not None: self.help = active.help def _on_remove_layer(self, event): """Disconnect old layer events. Parameters ---------- event : napari.utils.event.Event Event which will remove a layer. Returns ------- layer : :class:`napari.layers.Layer` or list The layer that was added (same as input). """ layer = event.value # Disconnect all connections from layer disconnect_events(layer.events, self) disconnect_events(layer.events, self.layers) self._on_layers_change() self._on_grid_change() def add_layer(self, layer: Layer) -> Layer: """Add a layer to the viewer. Parameters ---------- layer : :class:`napari.layers.Layer` Layer to add. Returns ------- layer : :class:`napari.layers.Layer` or list The layer that was added (same as input). """ # Adding additional functionality inside `add_layer` # should be avoided to keep full functionality # from adding a layer through the `layers.append` # method self.layers.append(layer) return layer @rename_argument("interpolation", "interpolation2d", "0.6.0") def add_image( self, data=None, *, channel_axis=None, rgb=None, colormap=None, contrast_limits=None, gamma=1, interpolation2d='nearest', interpolation3d='linear', rendering='mip', depiction='volume', iso_threshold=None, attenuation=0.05, name=None, metadata=None, scale=None, translate=None, rotate=None, shear=None, affine=None, opacity=1, blending=None, visible=True, multiscale=None, cache=True, plane=None, experimental_clipping_planes=None, ) -> Union[Image, List[Image]]: """Add an image layer to the layer list. Parameters ---------- data : array or list of array Image data. Can be N >= 2 dimensional. If the last dimension has length 3 or 4 can be interpreted as RGB or RGBA if rgb is `True`. If a list and arrays are decreasing in shape then the data is treated as a multiscale image. Please note multiscale rendering is only supported in 2D. In 3D, only the lowest resolution scale is displayed. channel_axis : int, optional Axis to expand image along. If provided, each channel in the data will be added as an individual image layer. In channel_axis mode, all other parameters MAY be provided as lists, and the Nth value will be applied to the Nth channel in the data. If a single value is provided, it will be broadcast to all Layers. rgb : bool or list Whether the image is rgb RGB or RGBA. If not specified by user and the last dimension of the data has length 3 or 4 it will be set as `True`. If `False` the image is interpreted as a luminance image. If a list then must be same length as the axis that is being expanded as channels. colormap : str, napari.utils.Colormap, tuple, dict, list Colormaps to use for luminance images. If a string must be the name of a supported colormap from vispy or matplotlib. If a tuple the first value must be a string to assign as a name to a colormap and the second item must be a Colormap. If a dict the key must be a string to assign as a name to a colormap and the value must be a Colormap. If a list then must be same length as the axis that is being expanded as channels, and each colormap is applied to each new image layer. contrast_limits : list (2,) Color limits to be used for determining the colormap bounds for luminance images. If not passed is calculated as the min and max of the image. If list of lists then must be same length as the axis that is being expanded and then each colormap is applied to each image. gamma : list, float Gamma correction for determining colormap linearity. Defaults to 1. If a list then must be same length as the axis that is being expanded as channels. interpolation : str or list Deprecated, to be removed in 0.6.0 interpolation2d : str or list Interpolation mode used by vispy in 2D. Must be one of our supported modes. If a list then must be same length as the axis that is being expanded as channels. interpolation3d : str or list Interpolation mode used by vispy in 3D. Must be one of our supported modes. If a list then must be same length as the axis that is being expanded as channels. rendering : str or list Rendering mode used by vispy. Must be one of our supported modes. If a list then must be same length as the axis that is being expanded as channels. depiction : str Selects a preset volume depiction mode in vispy * volume: images are rendered as 3D volumes. * plane: images are rendered as 2D planes embedded in 3D. iso_threshold : float or list Threshold for isosurface. If a list then must be same length as the axis that is being expanded as channels. attenuation : float or list Attenuation rate for attenuated maximum intensity projection. If a list then must be same length as the axis that is being expanded as channels. name : str or list of str Name of the layer. If a list then must be same length as the axis that is being expanded as channels. metadata : dict or list of dict Layer metadata. If a list then must be a list of dicts with the same length as the axis that is being expanded as channels. scale : tuple of float or list Scale factors for the layer. If a list then must be a list of tuples of float with the same length as the axis that is being expanded as channels. translate : tuple of float or list Translation values for the layer. If a list then must be a list of tuples of float with the same length as the axis that is being expanded as channels. rotate : float, 3-tuple of float, n-D array or list. If a float convert into a 2D rotation matrix using that value as an angle. If 3-tuple convert into a 3D rotation matrix, using a yaw, pitch, roll convention. Otherwise assume an nD rotation. Angles are assumed to be in degrees. They can be converted from radians with np.degrees if needed. If a list then must have same length as the axis that is being expanded as channels. shear : 1-D array or list. A vector of shear values for an upper triangular n-D shear matrix. If a list then must have same length as the axis that is being expanded as channels. affine : n-D array or napari.utils.transforms.Affine (N+1, N+1) affine transformation matrix in homogeneous coordinates. The first (N, N) entries correspond to a linear transform and the final column is a length N translation vector and a 1 or a napari `Affine` transform object. Applied as an extra transform on top of the provided scale, rotate, and shear values. opacity : float or list Opacity of the layer visual, between 0.0 and 1.0. If a list then must be same length as the axis that is being expanded as channels. blending : str or list One of a list of preset blending modes that determines how RGB and alpha values of the layer visual get mixed. Allowed values are {'opaque', 'translucent', and 'additive'}. If a list then must be same length as the axis that is being expanded as channels. visible : bool or list of bool Whether the layer visual is currently being displayed. If a list then must be same length as the axis that is being expanded as channels. multiscale : bool Whether the data is a multiscale image or not. Multiscale data is represented by a list of array like image data. If not specified by the user and if the data is a list of arrays that decrease in shape then it will be taken to be multiscale. The first image in the list should be the largest. Please note multiscale rendering is only supported in 2D. In 3D, only the lowest resolution scale is displayed. cache : bool Whether slices of out-of-core datasets should be cached upon retrieval. Currently, this only applies to dask arrays. plane : dict or SlicingPlane Properties defining plane rendering in 3D. Properties are defined in data coordinates. Valid dictionary keys are {'position', 'normal', 'thickness', and 'enabled'}. experimental_clipping_planes : list of dicts, list of ClippingPlane, or ClippingPlaneList Each dict defines a clipping plane in 3D in data coordinates. Valid dictionary keys are {'position', 'normal', and 'enabled'}. Values on the negative side of the normal are discarded if the plane is enabled. Returns ------- layer : :class:`napari.layers.Image` or list The newly-created image layer or list of image layers. """ if colormap is not None: # standardize colormap argument(s) to Colormaps, and make sure they # are in AVAILABLE_COLORMAPS. This will raise one of many various # errors if the colormap argument is invalid. See # ensure_colormap for details if isinstance(colormap, list): colormap = [ensure_colormap(c) for c in colormap] else: colormap = ensure_colormap(colormap) # doing this here for IDE/console autocompletion in add_image function. kwargs = { 'rgb': rgb, 'colormap': colormap, 'contrast_limits': contrast_limits, 'gamma': gamma, 'interpolation2d': interpolation2d, 'interpolation3d': interpolation3d, 'rendering': rendering, 'depiction': depiction, 'iso_threshold': iso_threshold, 'attenuation': attenuation, 'name': name, 'metadata': metadata, 'scale': scale, 'translate': translate, 'rotate': rotate, 'shear': shear, 'affine': affine, 'opacity': opacity, 'blending': blending, 'visible': visible, 'multiscale': multiscale, 'cache': cache, 'plane': plane, 'experimental_clipping_planes': experimental_clipping_planes, } # these arguments are *already* iterables in the single-channel case. iterable_kwargs = { 'scale', 'translate', 'rotate', 'shear', 'affine', 'contrast_limits', 'metadata', 'experimental_clipping_planes', } if channel_axis is None: kwargs['colormap'] = kwargs['colormap'] or 'gray' kwargs['blending'] = kwargs['blending'] or 'translucent_no_depth' # Helpful message if someone tries to add multi-channel kwargs, # but forget the channel_axis arg for k, v in kwargs.items(): if k not in iterable_kwargs and is_sequence(v): raise TypeError( trans._( "Received sequence for argument '{argument}', did you mean to specify a 'channel_axis'? ", deferred=True, argument=k, ) ) layer = Image(data, **kwargs) self.layers.append(layer) return layer else: layerdata_list = split_channels(data, channel_axis, **kwargs) layer_list = list() for image, i_kwargs, _ in layerdata_list: layer = Image(image, **i_kwargs) self.layers.append(layer) layer_list.append(layer) return layer_list def open_sample( self, plugin: str, sample: str, reader_plugin: Optional[str] = None, **kwargs, ) -> List[Layer]: """Open `sample` from `plugin` and add it to the viewer. To see all available samples registered by plugins, use :func:`napari.plugins.available_samples` Parameters ---------- plugin : str name of a plugin providing a sample sample : str name of the sample reader_plugin : str, optional reader plugin to pass to viewer.open (only used if the sample data is a string). by default None. **kwargs additional kwargs will be passed to the sample data loader provided by `plugin`. Use of ``**kwargs`` may raise an error if the kwargs do not match the sample data loader. Returns ------- layers : list A list of any layers that were added to the viewer. Raises ------ KeyError If `plugin` does not provide a sample named `sample`. """ from napari.plugins import _npe2, plugin_manager # try with npe2 data, available = _npe2.get_sample_data(plugin, sample) # then try with npe1 if data is None: try: data = plugin_manager._sample_data[plugin][sample]['data'] except KeyError: available += list(plugin_manager.available_samples()) # npe2 uri sample data, extract the path so we can use viewer.open elif hasattr(data.__self__, 'uri'): data = data.__self__.uri if data is None: msg = trans._( "Plugin {plugin!r} does not provide sample data named {sample!r}. ", plugin=plugin, sample=sample, deferred=True, ) if available: msg = trans._( "Plugin {plugin!r} does not provide sample data named {sample!r}. Available samples include: {samples}.", deferred=True, plugin=plugin, sample=sample, samples=available, ) else: msg = trans._( "Plugin {plugin!r} does not provide sample data named {sample!r}. No plugin samples have been registered.", deferred=True, plugin=plugin, sample=sample, ) raise KeyError(msg) with layer_source(sample=(plugin, sample)): if callable(data): added = [] for datum in data(**kwargs): added.extend(self._add_layer_from_data(*datum)) return added elif isinstance(data, (str, Path)): return self.open(data, plugin=reader_plugin) else: raise TypeError( trans._( 'Got unexpected type for sample ({plugin!r}, {sample!r}): {data_type}', deferred=True, plugin=plugin, sample=sample, data_type=type(data), ) ) def open( self, path: PathOrPaths, *, stack: Union[bool, List[List[str]]] = False, plugin: Optional[str] = 'napari', layer_type: Optional[str] = None, **kwargs, ) -> List[Layer]: """Open a path or list of paths with plugins, and add layers to viewer. A list of paths will be handed one-by-one to the napari_get_reader hook if stack is False, otherwise the full list is passed to each plugin hook. Parameters ---------- path : str or list of str A filepath, directory, or URL (or a list of any) to open. stack : bool or list[list[str]], optional If a list of strings is passed as ``path`` and ``stack`` is ``True``, then the entire list will be passed to plugins. It is then up to individual plugins to know how to handle a list of paths. If ``stack`` is ``False``, then the ``path`` list is broken up and passed to plugin readers one by one. by default False. If the stack option is a list of lists containing individual paths, the inner lists are passedto the reader and will be stacked. plugin : str, optional Name of a plugin to use, by default builtins. If provided, will force ``path`` to be read with the specified ``plugin``. If None, ``plugin`` will be read from preferences or inferred if just one reader is compatible. If the requested plugin cannot read ``path``, an exception will be raised. layer_type : str, optional If provided, will force data read from ``path`` to be passed to the corresponding ``add_`` method (along with any additional) ``kwargs`` provided to this function. This *may* result in exceptions if the data returned from the path is not compatible with the layer_type. **kwargs All other keyword arguments will be passed on to the respective ``add_layer`` method. Returns ------- layers : list A list of any layers that were added to the viewer. """ if plugin == 'builtins': warnings.warn( trans._( 'The "builtins" plugin name is deprecated and will not work in a future version. Please use "napari" instead.', deferred=True, ), ) plugin = 'napari' paths: List[str | Path | List[str | Path]] = ( [os.fspath(path)] if isinstance(path, (Path, str)) else [os.fspath(p) for p in path] ) # If stack is a bool and True, add an additional layer of nesting. if isinstance(stack, bool) and stack: paths = [paths] # If stack is a list and True, extend the paths with the inner lists. elif isinstance(stack, list) and stack: paths.extend(stack) added: List[Layer] = [] # for layers that get added with progress( paths, desc=trans._('Opening Files'), total=0 if len(paths) == 1 else None, # indeterminate bar for 1 file ) as pbr: for _path in pbr: # If _path is a list, set stack to True _stack = True if isinstance(_path, list) else False # If _path is not a list already, make it a list. _path = [_path] if not isinstance(_path, list) else _path if plugin: added.extend( self._add_layers_with_plugins( _path, kwargs=kwargs, plugin=plugin, layer_type=layer_type, stack=_stack, ) ) # no plugin choice was made else: layers = self._open_or_raise_error( _path, kwargs, layer_type, _stack ) added.extend(layers) return added def _open_or_raise_error( self, paths: List[Union[Path, str]], kwargs: Dict[str, Any] = None, layer_type: Optional[str] = None, stack: Union[bool, List[List[str]]] = False, ): """Open paths if plugin choice is unambiguous, raising any errors. This function will open paths if there is no plugin choice to be made i.e. there is a preferred reader associated with this file extension, or there is only one plugin available. Any errors that occur during the opening process are raised. If multiple plugins are available to read these paths, an error is raised specifying this. Errors are also raised by this function when the given paths are not a list or tuple, or if no plugins are available to read the files. This assumes all files have the same extension, as other cases are not yet supported. This function is called from ViewerModel.open, which raises any errors returned. The QtViewer also calls this method but catches exceptions and opens a dialog for users to make a plugin choice. Parameters ---------- paths : List[Path | str] list of file paths to open kwargs : Dict[str, Any], optional keyword arguments to pass to layer adding method, by default {} layer_type : Optional[str], optional layer type for paths, by default None stack : bool or list[list[str]], optional True if files should be opened as a stack, by default False. Can also be a list containing lists of files to stack. Returns ------- added list of layers added plugin plugin used to try opening paths, if any Raises ------ TypeError when paths is *not* a list or tuple NoAvailableReaderError when no plugins are available to read path ReaderPluginError when reading with only available or prefered plugin fails MultipleReaderError when multiple readers are available to read the path """ paths = [os.fspath(path) for path in paths] # PathObjects -> str _path = paths[0] # we want to display the paths nicely so make a help string here path_message = f"[{_path}], ...]" if len(paths) > 1 else _path readers = get_potential_readers(_path) if not readers: raise NoAvailableReaderError( trans._( 'No plugin found capable of reading {path_message}.', path_message=path_message, deferred=True, ), paths, ) plugin = get_preferred_reader(_path) if plugin and plugin not in readers: warnings.warn( RuntimeWarning( trans._( "Can't find {plugin} plugin associated with {path_message} files. ", plugin=plugin, path_message=path_message, ) + trans._( "This may be because you've switched environments, or have uninstalled the plugin without updating the reader preference. " ) + trans._( "You can remove this preference in the preference dialog, or by editing `settings.plugins.extension2reader`." ) ) ) plugin = None # preferred plugin exists, or we just have one plugin available if plugin or len(readers) == 1: plugin = plugin or next(iter(readers.keys())) try: added = self._add_layers_with_plugins( paths, kwargs=kwargs, stack=stack, plugin=plugin, layer_type=layer_type, ) # plugin failed except Exception as e: # noqa: BLE001 raise ReaderPluginError( trans._( 'Tried opening with {plugin}, but failed.', deferred=True, plugin=plugin, ), plugin, paths, ) from e # multiple plugins else: raise MultipleReaderError( trans._( "Multiple plugins found capable of reading {path_message}. Select plugin from {plugins} and pass to reading function e.g. `viewer.open(..., plugin=...)`.", path_message=path_message, plugins=readers, deferred=True, ), list(readers.keys()), paths, ) return added def _add_layers_with_plugins( self, paths: List[str], *, stack: bool, kwargs: Optional[dict] = None, plugin: Optional[str] = None, layer_type: Optional[str] = None, ) -> List[Layer]: """Load a path or a list of paths into the viewer using plugins. This function is mostly called from self.open_path, where the ``stack`` argument determines whether a list of strings is handed to plugins one at a time, or en-masse. Parameters ---------- paths : list of str A filepath, directory, or URL (or a list of any) to open. If a list, the assumption is that the list is to be treated as a stack. kwargs : dict, optional keyword arguments that will be used to overwrite any of those that are returned in the meta dict from plugins. plugin : str, optional Name of a plugin to use. If provided, will force ``path`` to be read with the specified ``plugin``. If the requested plugin cannot read ``path``, an exception will be raised. layer_type : str, optional If provided, will force data read from ``path`` to be passed to the corresponding ``add_`` method (along with any additional) ``kwargs`` provided to this function. This *may* result in exceptions if the data returned from the path is not compatible with the layer_type. stack : bool See `open` method Stack=False => path is unique string, and list of len(1) Stack=True => path is list of path Returns ------- List[Layer] A list of any layers that were added to the viewer. """ from napari.plugins.io import read_data_with_plugins assert stack is not None assert isinstance(paths, list) assert not isinstance(paths, str) for p in paths: assert isinstance(p, str) if stack: layer_data, hookimpl = read_data_with_plugins( paths, plugin=plugin, stack=stack ) else: assert len(paths) == 1 layer_data, hookimpl = read_data_with_plugins( paths, plugin=plugin, stack=stack ) # glean layer names from filename. These will be used as *fallback* # names, if the plugin does not return a name kwarg in their meta dict. filenames = [] if len(paths) == len(layer_data): filenames = iter(paths) else: # if a list of paths has been returned as a list of layer data # without a 1:1 relationship between the two lists we iterate # over the first name filenames = itertools.repeat(paths[0]) # add each layer to the viewer added: List[Layer] = [] # for layers that get added plugin = hookimpl.plugin_name if hookimpl else None for data, filename in zip(layer_data, filenames): basename, _ext = os.path.splitext(os.path.basename(filename)) _data = _unify_data_and_user_kwargs( data, kwargs, layer_type, fallback_name=basename ) # actually add the layer with layer_source(path=filename, reader_plugin=plugin): added.extend(self._add_layer_from_data(*_data)) return added def _add_layer_from_data( self, data, meta: Dict[str, Any] = None, layer_type: Optional[str] = None, ) -> List[Layer]: """Add arbitrary layer data to the viewer. Primarily intended for usage by reader plugin hooks. Parameters ---------- data : Any Data in a format that is valid for the corresponding `add_*` method of the specified ``layer_type``. meta : dict, optional Dict of keyword arguments that will be passed to the corresponding `add_*` method. MUST NOT contain any keyword arguments that are not valid for the corresponding method. layer_type : str Type of layer to add. MUST have a corresponding add_* method on on the viewer instance. If not provided, the layer is assumed to be "image", unless data.dtype is one of (np.int32, np.uint32, np.int64, np.uint64), in which case it is assumed to be "labels". Returns ------- layers : list of layers A list of layers added to the viewer. Raises ------ ValueError If ``layer_type`` is not one of the recognized layer types. TypeError If any keyword arguments in ``meta`` are unexpected for the corresponding `add_*` method for this layer_type. Examples -------- A typical use case might be to upack a tuple of layer data with a specified layer_type. >>> viewer = napari.Viewer() >>> data = ( ... np.random.random((10, 2)) * 20, ... {'face_color': 'blue'}, ... 'points', ... ) >>> viewer._add_layer_from_data(*data) """ layer_type = (layer_type or '').lower() # assumes that big integer type arrays are likely labels. if not layer_type: layer_type = guess_labels(data) if layer_type not in layers.NAMES: raise ValueError( trans._( "Unrecognized layer_type: '{layer_type}'. Must be one of: {layer_names}.", deferred=True, layer_type=layer_type, layer_names=layers.NAMES, ) ) try: add_method = getattr(self, 'add_' + layer_type) layer = add_method(data, **(meta or {})) except TypeError as exc: if 'unexpected keyword argument' not in str(exc): raise exc bad_key = str(exc).split('keyword argument ')[-1] raise TypeError( trans._( "_add_layer_from_data received an unexpected keyword argument ({bad_key}) for layer type {layer_type}", deferred=True, bad_key=bad_key, layer_type=layer_type, ) ) from exc return layer if isinstance(layer, list) else [layer] def _normalize_layer_data(data: LayerData) -> FullLayerData: """Accepts any layerdata tuple, and returns a fully qualified tuple. Parameters ---------- data : LayerData 1-, 2-, or 3-tuple with (data, meta, layer_type). Returns ------- FullLayerData 3-tuple with (data, meta, layer_type) Raises ------ ValueError If data has len < 1 or len > 3, or if the second item in ``data`` is not a ``dict``, or the third item is not a valid layer_type ``str`` """ if not isinstance(data, tuple) and 0 < len(data) < 4: raise ValueError( trans._( "LayerData must be a 1-, 2-, or 3-tuple", deferred=True, ) ) _data = list(data) if len(_data) > 1: if not isinstance(_data[1], dict): raise ValueError( trans._( "The second item in a LayerData tuple must be a dict", deferred=True, ) ) else: _data.append(dict()) if len(_data) > 2: if _data[2] not in layers.NAMES: raise ValueError( trans._( "The third item in a LayerData tuple must be one of: {layers!r}.", deferred=True, layers=layers.NAMES, ) ) else: _data.append(guess_labels(_data[0])) return tuple(_data) # type: ignore def _unify_data_and_user_kwargs( data: LayerData, kwargs: Optional[dict] = None, layer_type: Optional[str] = None, fallback_name: str = None, ) -> FullLayerData: """Merge data returned from plugins with options specified by user. If ``data == (_data, _meta, _type)``. Then: - ``kwargs`` will be used to update ``_meta`` - ``layer_type`` will replace ``_type`` and, if provided, ``_meta`` keys will be pruned to layer_type-appropriate kwargs - ``fallback_name`` is used if ``not _meta.get('name')`` .. note: If a user specified both layer_type and additional keyword arguments to viewer.open(), it is their responsibility to make sure the kwargs match the layer_type. Parameters ---------- data : LayerData 1-, 2-, or 3-tuple with (data, meta, layer_type) returned from plugin. kwargs : dict, optional User-supplied keyword arguments, to override those in ``meta`` supplied by plugins. layer_type : str, optional A user-supplied layer_type string, to override the ``layer_type`` declared by the plugin. fallback_name : str, optional A name for the layer, to override any name in ``meta`` supplied by the plugin. Returns ------- FullLayerData Fully qualified LayerData tuple with user-provided overrides. """ _data, _meta, _type = _normalize_layer_data(data) if layer_type: # the user has explicitly requested this be a certain layer type # strip any kwargs from the plugin that are no longer relevant _meta = prune_kwargs(_meta, layer_type) _type = layer_type if kwargs: # if user provided kwargs, use to override any meta dict values that # were returned by the plugin. We only prune kwargs if the user did # *not* specify the layer_type. This means that if a user specified # both layer_type and additional keyword arguments to viewer.open(), # it is their responsibility to make sure the kwargs match the # layer_type. _meta.update(prune_kwargs(kwargs, _type) if not layer_type else kwargs) if not _meta.get('name') and fallback_name: _meta['name'] = fallback_name return (_data, _meta, _type) def prune_kwargs(kwargs: Dict[str, Any], layer_type: str) -> Dict[str, Any]: """Return copy of ``kwargs`` with only keys valid for ``add_`` Parameters ---------- kwargs : dict A key: value mapping where some or all of the keys are parameter names for the corresponding ``Viewer.add_`` method. layer_type : str The type of layer that is going to be added with these ``kwargs``. Returns ------- pruned_kwargs : dict A key: value mapping where all of the keys are valid parameter names for the corresponding ``Viewer.add_`` method. Raises ------ ValueError If ``ViewerModel`` does not provide an ``add_`` method for the provided ``layer_type``. Examples -------- >>> test_kwargs = { ... 'scale': (0.75, 1), ... 'blending': 'additive', ... 'num_colors': 10, ... } >>> prune_kwargs(test_kwargs, 'image') {'scale': (0.75, 1), 'blending': 'additive'} >>> # only labels has the ``num_colors`` argument >>> prune_kwargs(test_kwargs, 'labels') {'scale': (0.75, 1), 'blending': 'additive', 'num_colors': 10} """ add_method = getattr(ViewerModel, 'add_' + layer_type, None) if not add_method or layer_type == 'layer': raise ValueError( trans._( "Invalid layer_type: {layer_type}", deferred=True, layer_type=layer_type, ) ) # get valid params for the corresponding add_ method valid = valid_add_kwargs()[layer_type] return {k: v for k, v in kwargs.items() if k in valid} @lru_cache(maxsize=1) def valid_add_kwargs() -> Dict[str, Set[str]]: """Return a dict where keys are layer types & values are valid kwargs.""" valid = dict() for meth in dir(ViewerModel): if not meth.startswith('add_') or meth[4:] == 'layer': continue params = inspect.signature(getattr(ViewerModel, meth)).parameters valid[meth[4:]] = set(params) - {'self', 'kwargs'} return valid for _layer in ( layers.Labels, layers.Points, layers.Shapes, layers.Surface, layers.Tracks, layers.Vectors, ): func = create_add_method(_layer, filename=__file__) setattr(ViewerModel, func.__name__, func) napari-0.5.0a1/napari/conftest.py000066400000000000000000000573441437041365600167070ustar00rootroot00000000000000""" Notes for using the plugin-related fixtures here: 1. The `_mock_npe2_pm` fixture is always used, and it mocks the global npe2 plugin manager instance with a discovery-deficient plugin manager. No plugins should be discovered in tests without explicit registration. 2. wherever the builtins need to be tested, the `builtins` fixture should be explicitly added to the test. (it's a DynamicPlugin that registers our builtins.yaml with the global mock npe2 plugin manager) 3. wherever *additional* plugins or contributions need to be added, use the `tmp_plugin` fixture, and add additional contributions _within_ the test (not in the fixture): ```python def test_something(tmp_plugin): @tmp_plugin.contribute.reader(filname_patterns=["*.ext"]) def f(path): ... # the plugin name can be accessed at: tmp_plugin.name ``` 4. If you need a _second_ mock plugin, use `tmp_plugin.spawn(register=True)` to create another one. ```python new_plugin = tmp_plugin.spawn(register=True) @new_plugin.contribute.reader(filename_patterns=["*.tiff"]) def get_reader(path): ... ``` """ from __future__ import annotations import os import sys from concurrent.futures import ThreadPoolExecutor from contextlib import suppress from itertools import chain from multiprocessing.pool import ThreadPool from typing import TYPE_CHECKING from unittest.mock import patch from weakref import WeakKeyDictionary try: __import__('dotenv').load_dotenv() except ModuleNotFoundError: pass import dask.threaded import numpy as np import pytest from IPython.core.history import HistoryManager from napari.components import LayerList from napari.layers import Image, Labels, Points, Shapes, Vectors from napari.utils.config import async_loading from napari.utils.misc import ROOT_DIR if TYPE_CHECKING: from npe2._pytest_plugin import TestPluginManager def pytest_addoption(parser): """Add napari specific command line options. --aysnc_only Run only asynchronous tests, not sync ones. Notes ----- Due to the placement of this conftest.py file, you must specifically name the napari folder such as "pytest napari --aysnc_only" """ parser.addoption( "--async_only", action="store_true", default=False, help="run only asynchronous tests", ) @pytest.fixture def layer_data_and_types(): """Fixture that provides some layers and filenames Returns ------- tuple ``layers, layer_data, layer_types, filenames`` - layers: some image and points layers - layer_data: same as above but in LayerData form - layer_types: list of strings with type of layer - filenames: the expected filenames with extensions for the layers. """ layers = [ Image(np.random.rand(20, 20), name='ex_img'), Image(np.random.rand(20, 20)), Points(np.random.rand(20, 2), name='ex_pts'), Points( np.random.rand(20, 2), properties={'values': np.random.rand(20)} ), ] extensions = ['.tif', '.tif', '.csv', '.csv'] layer_data = [layer.as_layer_data_tuple() for layer in layers] layer_types = [layer._type_string for layer in layers] filenames = [layer.name + e for layer, e in zip(layers, extensions)] return layers, layer_data, layer_types, filenames @pytest.fixture( params=[ 'image', 'labels', 'points', 'shapes', 'shapes-rectangles', 'vectors', ] ) def layer(request): """Parameterized fixture that supplies a layer for testing. Parameters ---------- request : _pytest.fixtures.SubRequest The pytest request object Returns ------- napari.layers.Layer The desired napari Layer. """ np.random.seed(0) if request.param == 'image': data = np.random.rand(20, 20) return Image(data) elif request.param == 'labels': data = np.random.randint(10, size=(20, 20)) return Labels(data) elif request.param == 'points': data = np.random.rand(20, 2) return Points(data) elif request.param == 'shapes': data = [ np.random.rand(2, 2), np.random.rand(2, 2), np.random.rand(6, 2), np.random.rand(6, 2), np.random.rand(2, 2), ] shape_type = ['ellipse', 'line', 'path', 'polygon', 'rectangle'] return Shapes(data, shape_type=shape_type) elif request.param == 'shapes-rectangles': data = np.random.rand(7, 4, 2) return Shapes(data) elif request.param == 'vectors': data = np.random.rand(20, 2, 2) return Vectors(data) else: return None @pytest.fixture() def layers(): """Fixture that supplies a layers list for testing. Returns ------- napari.components.LayerList The desired napari LayerList. """ np.random.seed(0) list_of_layers = [ Image(np.random.rand(20, 20)), Labels(np.random.randint(10, size=(20, 2))), Points(np.random.rand(20, 2)), Shapes(np.random.rand(10, 2, 2)), Vectors(np.random.rand(10, 2, 2)), ] return LayerList(list_of_layers) # Currently we cannot run async and async in the invocation of pytest # because we get a segfault for unknown reasons. So for now: # "pytest" runs sync_only # "pytest napari --async_only" runs async only @pytest.fixture(scope="session", autouse=True) def configure_loading(request): """Configure async/async loading.""" if request.config.getoption("--async_only"): # Late import so we don't import experimental code unless using it. from napari.components.experimental.chunk import synchronous_loading with synchronous_loading(False): yield else: yield # Sync so do nothing. def _is_async_mode() -> bool: """Return True if we are currently loading chunks asynchronously Returns ------- bool True if we are currently loading chunks asynchronously. """ if not async_loading: return False # Not enabled at all. else: # Late import so we don't import experimental code unless using it. from napari.components.experimental.chunk import chunk_loader return not chunk_loader.force_synchronous @pytest.fixture(autouse=True) def skip_sync_only(request): """Skip async_only tests if running async.""" sync_only = request.node.get_closest_marker('sync_only') if _is_async_mode() and sync_only: pytest.skip("running with --async_only") @pytest.fixture(autouse=True) def skip_async_only(request): """Skip async_only tests if running sync.""" async_only = request.node.get_closest_marker('async_only') if not _is_async_mode() and async_only: pytest.skip("not running with --async_only") @pytest.fixture(autouse=True) def skip_examples(request): """Skip examples test if .""" if request.node.get_closest_marker( 'examples' ) and request.config.getoption("--skip_examples"): pytest.skip("running with --skip_examples") # _PYTEST_RAISE=1 will prevent pytest from handling exceptions. # Use with a debugger that's set to break on "unhandled exceptions". # https://github.com/pytest-dev/pytest/issues/7409 if os.getenv('_PYTEST_RAISE', "0") != "0": @pytest.hookimpl(tryfirst=True) def pytest_exception_interact(call): raise call.excinfo.value @pytest.hookimpl(tryfirst=True) def pytest_internalerror(excinfo): raise excinfo.value @pytest.fixture(autouse=True) def fresh_settings(monkeypatch): """This fixture ensures that default settings are used for every test. and ensures that changes to settings in a test are reverted, and never saved to disk. """ from napari import settings from napari.settings import NapariSettings # prevent the developer's config file from being used if it exists cp = NapariSettings.__private_attributes__['_config_path'] monkeypatch.setattr(cp, 'default', None) # calling save() with no config path is normally an error # here we just have save() return if called without a valid path NapariSettings.__original_save__ = NapariSettings.save def _mock_save(self, path=None, **dict_kwargs): if not (path or self.config_path): return NapariSettings.__original_save__(self, path, **dict_kwargs) monkeypatch.setattr(NapariSettings, 'save', _mock_save) # this makes sure that we start with fresh settings for every test. settings._SETTINGS = None yield @pytest.fixture(autouse=True) def auto_shutdown_dask_threadworkers(): """ This automatically shutdown dask thread workers. We don't assert the number of threads in unchanged as other things modify the number of threads. """ assert dask.threaded.default_pool is None try: yield finally: if isinstance(dask.threaded.default_pool, ThreadPool): dask.threaded.default_pool.close() dask.threaded.default_pool.join() elif dask.threaded.default_pool: dask.threaded.default_pool.shutdown() dask.threaded.default_pool = None # this is not the proper way to configure IPython, but it's an easy one. # This will prevent IPython to try to write history on its sql file and do # everything in memory. # 1) it saves a thread and # 2) it can prevent issues with slow or read-only file systems in CI. HistoryManager.enabled = False @pytest.fixture def napari_svg_name(): """the plugin name changes with npe2 to `napari-svg` from `svg`.""" from importlib.metadata import metadata if tuple(metadata('napari-svg')['Version'].split('.')) < ('0', '1', '6'): return 'svg' else: return 'napari-svg' @pytest.fixture(autouse=True, scope='session') def _no_error_reports(): """Turn off napari_error_reporter if it's installed.""" try: p1 = patch('napari_error_reporter.capture_exception') p2 = patch('napari_error_reporter.install_error_reporter') with p1, p2: yield except (ModuleNotFoundError, AttributeError): yield @pytest.fixture(autouse=True) def _npe2pm(npe2pm, monkeypatch): """Autouse the npe2 mock plugin manager with no registered plugins.""" from napari.plugins import NapariPluginManager monkeypatch.setattr(NapariPluginManager, 'discover', lambda *_, **__: None) return npe2pm @pytest.fixture def builtins(_npe2pm: TestPluginManager): with _npe2pm.tmp_plugin(package='napari') as plugin: yield plugin @pytest.fixture def tmp_plugin(_npe2pm: TestPluginManager): with _npe2pm.tmp_plugin() as plugin: plugin.manifest.package_metadata = {'version': '0.1.0', 'name': 'test'} plugin.manifest.display_name = 'Temp Plugin' yield plugin def _event_check(instance): def _prepare_check(name, no_event_): def check(instance, no_event=no_event_): if name in no_event: assert not hasattr( instance.events, name ), f"event {name} defined" else: assert hasattr( instance.events, name ), f"event {name} not defined" return check no_event_set = set() if isinstance(instance, tuple): no_event_set = instance[1] instance = instance[0] for name, value in instance.__class__.__dict__.items(): if isinstance(value, property) and name[0] != '_': yield _prepare_check(name, no_event_set), instance, name def pytest_generate_tests(metafunc): """Generate separate test for each test toc check if all events are defined.""" if 'event_define_check' in metafunc.fixturenames: res = [] ids = [] for obj in metafunc.cls.get_objects(): for check, instance, name in _event_check(obj): res.append((check, instance)) ids.append(f"{name}-{instance}") metafunc.parametrize('event_define_check,obj', res, ids=ids) def pytest_collection_modifyitems(session, config, items): test_order_prefix = [ os.path.join("napari", "utils"), os.path.join("napari", "layers"), os.path.join("napari", "components"), os.path.join("napari", "settings"), os.path.join("napari", "plugins"), os.path.join("napari", "_vispy"), os.path.join("napari", "_qt"), os.path.join("napari", "qt"), os.path.join("napari", "_tests"), os.path.join("napari", "_tests", "test_examples.py"), ] test_order = [[] for _ in test_order_prefix] test_order.append([]) # for not matching tests for item in items: index = -1 for i, prefix in enumerate(test_order_prefix): if prefix in str(item.fspath): index = i test_order[index].append(item) items[:] = list(chain(*test_order)) @pytest.fixture(autouse=True) def disable_notification_dismiss_timer(monkeypatch): """ This fixture disables starting timer for closing notification by setting the value of `NapariQtNotification.DISMISS_AFTER` to 0. As Qt timer is realised by thread and keep reference to the object, without increase of reference counter object could be garbage collected and cause segmentation fault error when Qt (C++) code try to access it without checking if Python object exists. This fixture is used in all tests because it is possible to call Qt code from non Qt test by connection of `NapariQtNotification.show_notification` to `NotificationManager` global instance. """ with suppress(ImportError): from napari._qt.dialogs.qt_notification import NapariQtNotification monkeypatch.setattr(NapariQtNotification, "DISMISS_AFTER", 0) monkeypatch.setattr(NapariQtNotification, "FADE_IN_RATE", 0) monkeypatch.setattr(NapariQtNotification, "FADE_OUT_RATE", 0) @pytest.fixture() def single_threaded_executor(): executor = ThreadPoolExecutor(max_workers=1) yield executor executor.shutdown() @pytest.fixture(autouse=True) def _mock_app(): """Mock clean 'test_app' `NapariApplication` instance. This is used whenever `napari._app_model.get_app()` is called to return a 'test_app' `NapariApplication` instead of the 'napari' `NapariApplication`. Note that `NapariApplication` registers app-model actions, providers and processors. If this is not desired, please create a clean `app_model.Application` in the test. It does not however, register Qt related actions or providers. If this is required for a unit test, `napari._qt._qapp_model.qactions.init_qactions()` can be used within the test. """ from app_model import Application from napari._app_model._app import NapariApplication, _napari_names app = NapariApplication('test_app') app.injection_store.namespace = _napari_names with patch.object(NapariApplication, 'get_app', return_value=app): try: yield app finally: Application.destroy('test_app') def _get_calling_place(depth=1): if not hasattr(sys, "_getframe"): return "" frame = sys._getframe(1 + depth) result = f"{frame.f_code.co_filename}:{frame.f_lineno}" if not frame.f_code.co_filename.startswith(ROOT_DIR): with suppress(ValueError): while not frame.f_code.co_filename.startswith(ROOT_DIR): frame = frame.f_back if frame is None: break else: result += f" called from\n{frame.f_code.co_filename}:{frame.f_lineno}" return result @pytest.fixture def dangling_qthreads(monkeypatch, qtbot, request): from qtpy.QtCore import QThread base_start = QThread.start thread_dict = WeakKeyDictionary() # dict of threads that have been started but not yet terminated if "disable_qthread_start" in request.keywords: def my_start(*_, **__): """dummy function to prevent thread start""" else: def my_start(self, priority=QThread.InheritPriority): thread_dict[self] = _get_calling_place() base_start(self, priority) monkeypatch.setattr(QThread, 'start', my_start) yield dangling_threads_li = [] for thread, calling in thread_dict.items(): try: if thread.isRunning(): dangling_threads_li.append((thread, calling)) except RuntimeError as e: if ( "wrapped C/C++ object of type" not in e.args[0] and "Internal C++ object" not in e.args[0] ): raise for thread, _ in dangling_threads_li: with suppress(RuntimeError): thread.quit() qtbot.waitUntil(thread.isFinished, timeout=2000) long_desc = ( "If you see this error, it means that a QThread was started in a test " "but not terminated. This can cause segfaults in the test suite. " "Please use the `qtbot` fixture to wait for the thread to finish. " "If you think that the thread is obsolete for this test, you can " "use the `@pytest.mark.disable_qthread_start` mark or `monkeypatch` " "fixture to patch the `start` method of the " "QThread class to do nothing.\n" ) if len(dangling_threads_li) > 1: long_desc += " The QThreads were started in:\n" else: long_desc += " The QThread was started in:\n" assert not dangling_threads_li, long_desc + "\n".join( x[1] for x in dangling_threads_li ) @pytest.fixture def dangling_qthread_pool(monkeypatch, request): from qtpy.QtCore import QThreadPool base_start = QThreadPool.start threadpool_dict = WeakKeyDictionary() # dict of threadpools that have been used to run QRunnables if "disable_qthread_pool_start" in request.keywords: def my_start(*_, **__): """dummy function to prevent thread start""" else: def my_start(self, runnable, priority=0): if self not in threadpool_dict: threadpool_dict[self] = [] threadpool_dict[self].append(_get_calling_place()) base_start(self, runnable, priority) monkeypatch.setattr(QThreadPool, 'start', my_start) yield dangling_threads_pools = [] for thread_pool, calling in threadpool_dict.items(): if thread_pool.activeThreadCount(): dangling_threads_pools.append((thread_pool, calling)) for thread_pool, _ in dangling_threads_pools: with suppress(RuntimeError): thread_pool.clear() thread_pool.waitForDone(2000) long_desc = ( "If you see this error, it means that a QThreadPool was used to run " "a QRunnable in a test but not terminated. This can cause segfaults " "in the test suite. Please use the `qtbot` fixture to wait for the " "thread to finish. If you think that the thread is obsolete for this " "use the `@pytest.mark.disable_qthread_pool_start` mark or `monkeypatch` " "fixture to patch the `start` " "method of the QThreadPool class to do nothing.\n" ) if len(dangling_threads_pools) > 1: long_desc += " The QThreadPools were used in:\n" else: long_desc += " The QThreadPool was used in:\n" assert not dangling_threads_pools, long_desc + "\n".join( "; ".join(x[1]) for x in dangling_threads_pools ) @pytest.fixture def dangling_qtimers(monkeypatch, request): from qtpy.QtCore import QTimer base_start = QTimer.start timer_dkt = WeakKeyDictionary() single_shot_list = [] if "disable_qtimer_start" in request.keywords: from pytestqt.qt_compat import qt_api def my_start(*_, **__): """dummy function to prevent timer start""" _single_shot = my_start class OldTimer(QTimer): def start(self, time=None): if time is not None: base_start(self, time) else: base_start(self) monkeypatch.setattr(qt_api.QtCore, "QTimer", OldTimer) # This monkeypatch is require to keep `qtbot.waitUntil` working else: def my_start(self, msec=None): timer_dkt[self] = _get_calling_place() if msec is not None: base_start(self, msec) else: base_start(self) def single_shot(msec, reciver, method=None): t = QTimer() t.setSingleShot(True) if method is None: t.timeout.connect(reciver) else: t.timeout.connect(getattr(reciver, method)) single_shot_list.append((t, _get_calling_place(2))) base_start(t, msec) def _single_shot(self, *args): if isinstance(self, QTimer): single_shot(*args) else: single_shot(self, *args) monkeypatch.setattr(QTimer, 'start', my_start) monkeypatch.setattr(QTimer, 'singleShot', _single_shot) yield dangling_timers = [] for timer, calling in chain(timer_dkt.items(), single_shot_list): if timer.isActive(): dangling_timers.append((timer, calling)) for timer, _ in dangling_timers: with suppress(RuntimeError): timer.stop() long_desc = ( "If you see this error, it means that a QTimer was started but not stopped. " "This can cause tests to fail, and can also cause segfaults. " "If this test does not require a QTimer to pass you could monkeypatch it out. " "If it does require a QTimer, you should stop or wait for it to finish before test ends. " ) if len(dangling_timers) > 1: long_desc += "The QTimers were started in:\n" else: long_desc += "The QTimer was started in:\n" assert not dangling_timers, long_desc + "\n".join( x[1] for x in dangling_timers ) @pytest.fixture def dangling_qanimations(monkeypatch, request): from qtpy.QtCore import QPropertyAnimation base_start = QPropertyAnimation.start animation_dkt = WeakKeyDictionary() if "disable_qanimation_start" in request.keywords: def my_start(*_, **__): """dummy function to prevent thread start""" else: def my_start(self): animation_dkt[self] = _get_calling_place() base_start(self) monkeypatch.setattr(QPropertyAnimation, 'start', my_start) yield dangling_animations = [] for animation, calling in animation_dkt.items(): if animation.state() == QPropertyAnimation.Running: dangling_animations.append((animation, calling)) for animation, _ in dangling_animations: with suppress(RuntimeError): animation.stop() long_desc = ( "If you see this error, it means that a QPropertyAnimation was started but not stopped. " "This can cause tests to fail, and can also cause segfaults. " "If this test does not require a QPropertyAnimation to pass you could monkeypatch it out. " "If it does require a QPropertyAnimation, you should stop or wait for it to finish before test ends. " ) if len(dangling_animations) > 1: long_desc += " The QPropertyAnimations were started in:\n" else: long_desc += " The QPropertyAnimation was started in:\n" assert not dangling_animations, long_desc + "\n".join( x[1] for x in dangling_animations ) def pytest_runtest_setup(item): if "qapp" in item.fixturenames: # here we do autouse for dangling fixtures only if qapp is used if "qtbot" not in item.fixturenames: # for proper waiting for threads to finish item.fixturenames.append("qtbot") item.fixturenames.extend( [ "dangling_qthread_pool", "dangling_qanimations", "dangling_qthreads", "dangling_qtimers", ] ) napari-0.5.0a1/napari/errors/000077500000000000000000000000001437041365600160075ustar00rootroot00000000000000napari-0.5.0a1/napari/errors/__init__.py000066400000000000000000000003311437041365600201150ustar00rootroot00000000000000from napari.errors.reader_errors import ( MultipleReaderError, NoAvailableReaderError, ReaderPluginError, ) __all__ = [ "MultipleReaderError", "NoAvailableReaderError", "ReaderPluginError", ] napari-0.5.0a1/napari/errors/reader_errors.py000066400000000000000000000046031437041365600212220ustar00rootroot00000000000000from typing import List class MultipleReaderError(RuntimeError): """Multiple readers are available for paths and none explicitly chosen. Thrown when the viewer model tries to open files but multiple reader plugins are available that could claim them. User must make an explicit choice out of the available readers before opening files. Parameters ---------- message: str error description available_readers : List[str] list of available reader plugins for path paths: List[str] file paths for reading Attributes ---------- message: str error description available_readers : List[str] list of available reader plugins for path paths: List[str] file paths for reading """ def __init__( self, message: str, available_readers: List[str], paths: List[str], *args: object, ) -> None: super().__init__(message, *args) self.available_plugins = available_readers self.paths = paths class ReaderPluginError(ValueError): """A reader plugin failed while trying to open paths. This error is thrown either when the only available plugin failed to read the paths, or when the plugin associated with the paths' file extension failed. Parameters ---------- message: str error description reader_plugin : str plugin that was tried paths: List[str] file paths for reading Attributes ---------- message: str error description reader_plugin : str plugin that was tried paths: List[str] file paths for reading """ def __init__( self, message: str, reader_plugin: str, paths: List[str], *args: object ) -> None: super().__init__(message, *args) self.reader_plugin = reader_plugin self.paths = paths class NoAvailableReaderError(ValueError): """No reader plugins are available to open the chosen file Parameters ---------- message: str error description paths: List[str] file paths for reading Attributes ---------- message: str error description paths: List[str] file paths for reading """ def __init__(self, message: str, paths: List[str], *args: object) -> None: super().__init__(message, *args) self.paths = paths napari-0.5.0a1/napari/experimental/000077500000000000000000000000001437041365600171705ustar00rootroot00000000000000napari-0.5.0a1/napari/experimental/__init__.py000066400000000000000000000005031437041365600212770ustar00rootroot00000000000000from napari.components.experimental.chunk import ( chunk_loader, synchronous_loading, ) from napari.layers.utils._link_layers import ( layers_linked, link_layers, unlink_layers, ) __all__ = [ 'chunk_loader', 'link_layers', 'layers_linked', 'synchronous_loading', 'unlink_layers', ] napari-0.5.0a1/napari/layers/000077500000000000000000000000001437041365600157725ustar00rootroot00000000000000napari-0.5.0a1/napari/layers/__init__.py000066400000000000000000000016511437041365600201060ustar00rootroot00000000000000"""Layers are the viewable objects that can be added to a viewer. Custom layers must inherit from Layer and pass along the `visual node `_ to the super constructor. """ import inspect as _inspect from napari.layers.base import Layer from napari.layers.image import Image from napari.layers.labels import Labels from napari.layers.points import Points from napari.layers.shapes import Shapes from napari.layers.surface import Surface from napari.layers.tracks import Tracks from napari.layers.vectors import Vectors from napari.utils.misc import all_subclasses as _all_subcls # isabstact check is to exclude _ImageBase class NAMES = { subclass.__name__.lower() for subclass in _all_subcls(Layer) if not _inspect.isabstract(subclass) } __all__ = [ 'Image', 'Labels', 'Layer', 'Points', 'Shapes', 'Surface', 'Tracks', 'Vectors', 'NAMES', ] napari-0.5.0a1/napari/layers/_data_protocols.py000066400000000000000000000050501437041365600215200ustar00rootroot00000000000000"""This module holds Protocols that layer.data objects are expected to provide. """ from __future__ import annotations from typing import ( TYPE_CHECKING, Any, Protocol, Tuple, Union, runtime_checkable, ) from napari.utils.translations import trans _OBJ_NAMES = set(dir(Protocol)) _OBJ_NAMES.update({'__annotations__', '__dict__', '__weakref__'}) if TYPE_CHECKING: from enum import Enum from napari.types import DTypeLike # https://github.com/python/typing/issues/684#issuecomment-548203158 class ellipsis(Enum): Ellipsis = "..." Ellipsis = ellipsis.Ellipsis else: ellipsis = type(Ellipsis) def _raise_protocol_error(obj: Any, protocol: type): """Raise a more helpful error when required protocol members are missing.""" annotations = getattr(protocol, '__annotations__', {}) needed = set(dir(protocol)).union(annotations) - _OBJ_NAMES missing = needed - set(dir(obj)) message = trans._( "Object of type {type_name} does not implement {protocol_name} Protocol.\nMissing methods: {missing_methods}", deferred=True, type_name=repr(type(obj).__name__), protocol_name=repr(protocol.__name__), missing_methods=repr(missing), ) raise TypeError(message) Index = Union[int, slice, ellipsis] @runtime_checkable class LayerDataProtocol(Protocol): """Protocol that all layer.data must support. We don't explicitly declare the array types we support (i.e. dask, xarray, etc...). Instead, we support protocols. This Protocol is a place to document the attributes and methods that must be present for an object to be used as `layer.data`. We should aim to ensure that napari never accesses a method on `layer.data` that is not in this protocol. This protocol should remain a subset of the Array API proposed by the Python array API standard: https://data-apis.org/array-api/latest/API_specification/array_object.html WIP: Shapes.data may be an execption. """ @property def dtype(self) -> DTypeLike: """Data type of the array elements.""" @property def shape(self) -> Tuple[int, ...]: """Array dimensions.""" def __getitem__( self, key: Union[Index, Tuple[Index, ...], LayerDataProtocol] ) -> LayerDataProtocol: """Returns self[key].""" def assert_protocol(obj: Any, protocol: type = LayerDataProtocol): """Assert `obj` is an instance of `protocol` or raise helpful error.""" if not isinstance(obj, protocol): _raise_protocol_error(obj, protocol) napari-0.5.0a1/napari/layers/_layer_actions.py000066400000000000000000000120711437041365600213400ustar00rootroot00000000000000"""This module contains actions (functions) that operate on layers. Among other potential uses, these will populate the menu when you right-click on a layer in the LayerList. """ from __future__ import annotations from typing import TYPE_CHECKING, List, cast import numpy as np from napari.layers import Image, Labels, Layer from napari.layers._source import layer_source from napari.layers.utils import stack_utils from napari.layers.utils._link_layers import get_linked_layers from napari.utils.translations import trans if TYPE_CHECKING: from napari.components import LayerList def _duplicate_layer(ll: LayerList, *, name: str = ''): from copy import deepcopy for lay in list(ll.selection): data, state, type_str = lay.as_layer_data_tuple() state["name"] = trans._('{name} copy', name=lay.name) with layer_source(parent=lay): new = Layer.create(deepcopy(data), state, type_str) ll.insert(ll.index(lay) + 1, new) def _split_stack(ll: LayerList, axis: int = 0): layer = ll.selection.active if not isinstance(layer, Image): return if layer.rgb: images = stack_utils.split_rgb(layer) else: images = stack_utils.stack_to_images(layer, axis) ll.remove(layer) ll.extend(images) ll.selection = set(images) # type: ignore def _split_rgb(ll: LayerList): return _split_stack(ll) def _convert(ll: LayerList, type_: str): from napari.layers import Shapes for lay in list(ll.selection): idx = ll.index(lay) ll.pop(idx) if isinstance(lay, Shapes) and type_ == 'labels': data = lay.to_labels() else: data = lay.data.astype(int) if type_ == 'labels' else lay.data new_layer = Layer.create(data, lay._get_base_state(), type_) ll.insert(idx, new_layer) # TODO: currently, we have to create a thin _convert_to_x wrapper around _convert # here for the purpose of type hinting (which partial doesn't do) ... # so that inject_dependencies works correctly. # however, we could conceivably add an `args` option to register_action # that would allow us to pass additional arguments, like a partial. def _convert_to_labels(ll: LayerList): return _convert(ll, 'labels') def _convert_to_image(ll: LayerList): return _convert(ll, 'image') def _merge_stack(ll: LayerList, rgb=False): # force selection to follow LayerList ordering imgs = cast(List[Image], [layer for layer in ll if layer in ll.selection]) assert all(isinstance(layer, Image) for layer in imgs) merged = ( stack_utils.merge_rgb(imgs) if rgb else stack_utils.images_to_stack(imgs) ) for layer in imgs: ll.remove(layer) ll.append(merged) def _toggle_visibility(ll: LayerList): for lay in ll.selection: lay.visible = not lay.visible def _link_selected_layers(ll: LayerList): ll.link_layers(ll.selection) def _unlink_selected_layers(ll: LayerList): ll.unlink_layers(ll.selection) def _select_linked_layers(ll: LayerList): ll.selection.update(get_linked_layers(*ll.selection)) def _convert_dtype(ll: LayerList, mode='int64'): if not (layer := ll.selection.active): return if not isinstance(layer, Labels): raise NotImplementedError( trans._( "Data type conversion only implemented for labels", deferred=True, ) ) target_dtype = np.dtype(mode) if ( np.min(layer.data) < np.iinfo(target_dtype).min or np.max(layer.data) > np.iinfo(target_dtype).max ): raise AssertionError( trans._( "Labeling contains values outside of the target data type range.", deferred=True, ) ) else: layer.data = layer.data.astype(np.dtype(mode)) def _project(ll: LayerList, axis: int = 0, mode='max'): layer = ll.selection.active if not layer: return if not isinstance(layer, Image): raise NotImplementedError( trans._( "Projections are only implemented for images", deferred=True ) ) # this is not the desired behavior for coordinate-based layers # but the action is currently only enabled for 'image_active and ndim > 2' # before opening up to other layer types, this line should be updated. data = (getattr(np, mode)(layer.data, axis=axis, keepdims=False),) # get the meta data of the layer, but without transforms meta = { key: layer._get_base_state()[key] for key in layer._get_base_state() if key not in ('scale', 'translate', 'rotate', 'shear', 'affine') } meta.update( # sourcery skip { 'name': f'{layer} {mode}-proj', 'colormap': layer.colormap.name, 'rendering': layer.rendering, } ) new = Layer.create(data, meta, layer._type_string) # add transforms from original layer, but drop the axis of the projection new._transforms = layer._transforms.set_slice( [ax for ax in range(layer.ndim) if ax != axis] ) ll.append(new) napari-0.5.0a1/napari/layers/_multiscale_data.py000066400000000000000000000052111437041365600216350ustar00rootroot00000000000000from __future__ import annotations from typing import List, Sequence, Tuple, Union import numpy as np from napari.layers._data_protocols import LayerDataProtocol, assert_protocol from napari.utils.translations import trans # note: this also implements `LayerDataProtocol`, but we don't need to inherit. class MultiScaleData(Sequence[LayerDataProtocol]): """Wrapper for multiscale data, to provide consistent API. :class:`LayerDataProtocol` is the subset of the python Array API that we expect array-likes to provide. Multiscale data is just a sequence of array-likes (providing, e.g. `shape`, `dtype`, `__getitem__`). Parameters ---------- data : Sequence[LayerDataProtocol] Levels of multiscale data, from larger to smaller. max_size : Sequence[int], optional Maximum size of a displayed tile in pixels, by default`data[-1].shape` Raises ------ ValueError If `data` is empty or is not a list, tuple, or ndarray. TypeError If any of the items in `data` don't provide `LayerDataProtocol`. """ def __init__( self, data: Sequence[LayerDataProtocol], ) -> None: self._data: List[LayerDataProtocol] = list(data) if not self._data: raise ValueError( trans._("Multiscale data must be a (non-empty) sequence") ) for d in self._data: assert_protocol(d) @property def dtype(self) -> np.dtype: """Return dtype of the first scale..""" return self._data[0].dtype @property def shape(self) -> Tuple[int, ...]: """Shape of multiscale is just the biggest shape.""" return self._data[0].shape @property def shapes(self) -> Tuple[Tuple[int, ...], ...]: """Tuple shapes for all scales.""" return tuple(im.shape for im in self._data) def __getitem__( # type: ignore [override] self, key: Union[int, Tuple[slice, ...]] ) -> LayerDataProtocol: """Multiscale indexing.""" return self._data[key] def __len__(self) -> int: return len(self._data) def __eq__(self, other) -> bool: return self._data == other def __add__(self, other) -> bool: return self._data + other def __mul__(self, other) -> bool: return self._data * other def __rmul__(self, other) -> bool: return other * self._data def __array__(self) -> np.ndarray: return np.asarray(self._data[-1]) def __repr__(self) -> str: return ( f"" ) napari-0.5.0a1/napari/layers/_source.py000066400000000000000000000070361437041365600200110ustar00rootroot00000000000000from __future__ import annotations import weakref from contextlib import contextmanager from contextvars import ContextVar from typing import Optional, Tuple from magicgui.widgets import FunctionGui from pydantic import BaseModel, validator from napari.layers.base.base import Layer class Source(BaseModel): """An object to store the provenance of a layer. Parameters ---------- path: str, optional filpath/url associated with layer reader_plugin: str, optional name of reader plugin that loaded the file (if applicable) sample: Tuple[str, str], optional Tuple of (sample_plugin, sample_name), if layer was loaded via `viewer.open_sample`. widget: FunctionGui, optional magicgui widget, if the layer was added via a magicgui widget. parent: Layer, optional parent layer if the layer is a duplicate. """ path: Optional[str] = None reader_plugin: Optional[str] = None sample: Optional[Tuple[str, str]] = None widget: Optional[FunctionGui] = None parent: Optional[Layer] = None class Config: arbitrary_types_allowed = True frozen = True @validator('parent') def make_weakref(cls, layer: Layer): return weakref.ref(layer) def __deepcopy__(self, memo): """Custom deepcopy implementation. this prevents deep copy. `Source` doesn't really need to be copied (i.e. if we deepcopy a layer, it essentially has the same `Source`). Moreover, deepcopying a widget is challenging, and maybe odd anyway. """ return self # layer source context management _LAYER_SOURCE: ContextVar[dict] = ContextVar('_LAYER_SOURCE', default={}) @contextmanager def layer_source(**source_kwargs): """Creates context in which all layers will be given `source_kwargs`. The module-level variable `_LAYER_SOURCE` holds a set of key-value pairs that can be used to create a new `Source` object. Any routine in napari that may result in the creation of a new layer (such as opening a file, using a particular plugin, or calling a magicgui widget) can use this context manager to declare that any layers created within the context result from a specific source. (This applies even if the layer isn't "directly" created in the context, but perhaps in some sub-function within the context). `Layer.__init__` will call :func:`current_source`, to query the current state of the `_LAYER_SOURCE` variable. Contexts may be stacked, meaning a given layer.source can reflect the actions of multiple events (for instance, an `open_sample` call that in turn resulted in a `reader_plugin` opening a file). However, the "deepest" context will "win" in the case where multiple calls to `layer_source` provide conflicting values. Parameters ---------- **source_kwargs keys/values should be valid parameters for :class:`Source`. Examples -------- >>> with layer_source(path='file.ext', reader_plugin='plugin'): # doctest: +SKIP ... points = some_function_that_creates_points() ... >>> assert points.source == Source(path='file.ext', reader_plugin='plugin') # doctest: +SKIP """ token = _LAYER_SOURCE.set({**_LAYER_SOURCE.get(), **source_kwargs}) try: yield finally: _LAYER_SOURCE.reset(token) def current_source(): """Get the current layer :class:`Source` (inferred from context). The main place this function is used is in :meth:`Layer.__init__`. """ return Source(**_LAYER_SOURCE.get()) napari-0.5.0a1/napari/layers/_tests/000077500000000000000000000000001437041365600172735ustar00rootroot00000000000000napari-0.5.0a1/napari/layers/_tests/__init__.py000066400000000000000000000000001437041365600213720ustar00rootroot00000000000000napari-0.5.0a1/napari/layers/_tests/test_dask_layers.py000066400000000000000000000256641437041365600232220ustar00rootroot00000000000000from contextlib import nullcontext import dask import dask.array as da import numpy as np import pytest from napari import layers from napari.components import ViewerModel from napari.utils import _dask_utils, resize_dask_cache @pytest.mark.sync_only @pytest.mark.parametrize('dtype', ['float64', 'uint8']) def test_dask_not_greedy(dtype): """Make sure that we don't immediately calculate dask arrays.""" FETCH_COUNT = 0 def get_plane(block_id): if isinstance(block_id, tuple): nonlocal FETCH_COUNT FETCH_COUNT += 1 return np.random.rand(1, 1, 1, 10, 10) arr = da.map_blocks( get_plane, chunks=((1,) * 4, (1,) * 2, (1,) * 8, (10,), (10,)), dtype=dtype, ) layer = layers.Image(arr) # the <= is because before dask-2021.12.0, the above code resulted in NO # fetches for uint8 data, and afterwards, results in a single fetch. # the single fetch is actually the more "expected" behavior. And all we # are really trying to assert here is that we didn't fetch all the planes # in the first index... so we allow 0-1 fetches. assert FETCH_COUNT <= 1 if dtype == 'uint8': assert tuple(layer.contrast_limits) == (0, 255) def test_dask_array_creates_cache(): """Test that dask arrays create cache but turns off fusion.""" resize_dask_cache(1) assert _dask_utils._DASK_CACHE.cache.available_bytes == 1 # by default we have no dask_cache and task fusion is active original = dask.config.get("optimization.fuse.active", None) def mock_set_view_slice(): assert dask.config.get("optimization.fuse.active") is False layer = layers.Image(da.ones((100, 100))) layer._set_view_slice = mock_set_view_slice layer.set_view_slice() # adding a dask array will reate cache and turn off task fusion, # *but only* during slicing (see "mock_set_view_slice" above) assert _dask_utils._DASK_CACHE.cache.available_bytes > 100 assert not _dask_utils._DASK_CACHE.active assert dask.config.get("optimization.fuse.active", None) == original # make sure we can resize the cache resize_dask_cache(10000) assert _dask_utils._DASK_CACHE.cache.available_bytes == 10000 # This should only affect dask arrays, and not numpy data def mock_set_view_slice2(): assert dask.config.get("optimization.fuse.active", None) == original layer2 = layers.Image(np.ones((100, 100))) layer2._set_view_slice = mock_set_view_slice2 layer2.set_view_slice() def test_list_of_dask_arrays_doesnt_create_cache(): """Test that adding a list of dask array also creates a dask cache.""" resize_dask_cache(1) # in case other tests created it assert _dask_utils._DASK_CACHE.cache.available_bytes == 1 original = dask.config.get("optimization.fuse.active", None) _ = layers.Image([da.ones((100, 100)), da.ones((20, 20))]) assert _dask_utils._DASK_CACHE.cache.available_bytes > 100 assert not _dask_utils._DASK_CACHE.active assert dask.config.get("optimization.fuse.active", None) == original @pytest.fixture def delayed_dask_stack(): """A 4D (20, 10, 10, 10) delayed dask array, simulates disk io.""" # we will return a dict with a 'calls' variable that tracks call count output = {'calls': 0} # create a delayed version of function that simply generates np.arrays # but also counts when it has been called @dask.delayed def get_array(): nonlocal output output['calls'] += 1 return np.random.rand(10, 10, 10) # then make a mock "timelapse" of 3D stacks # see https://napari.org/tutorials/applications/dask.html for details _list = [get_array() for fn in range(20)] output['stack'] = da.stack( [da.from_delayed(i, shape=(10, 10, 10), dtype=float) for i in _list] ) assert output['stack'].shape == (20, 10, 10, 10) return output @pytest.mark.sync_only def test_dask_global_optimized_slicing(delayed_dask_stack, monkeypatch): """Test that dask_configure reduces compute with dask stacks.""" # add dask stack to the viewer, making sure to pass multiscale and clims v = ViewerModel() dask_stack = delayed_dask_stack['stack'] layer = v.add_image(dask_stack) # the first and the middle stack will be loaded assert delayed_dask_stack['calls'] == 2 with layer.dask_optimized_slicing() as (_, cache): assert cache.cache.available_bytes > 0 assert cache.active # make sure the cache actually has been populated assert len(cache.cache.heap.heap) > 0 assert not cache.active # only active inside of the context # changing the Z plane should never incur calls # since the stack has already been loaded (& it is chunked as a 3D array) current_z = v.dims.point[1] for i in range(3): v.dims.set_point(1, current_z + i) assert delayed_dask_stack['calls'] == 2 # still just the first call # changing the timepoint will, of course, incur some compute calls initial_t = v.dims.point[0] v.dims.set_point(0, initial_t + 1) assert delayed_dask_stack['calls'] == 3 v.dims.set_point(0, initial_t + 2) assert delayed_dask_stack['calls'] == 4 # but going back to previous timepoints should not, since they are cached v.dims.set_point(0, initial_t + 1) v.dims.set_point(0, initial_t + 0) assert delayed_dask_stack['calls'] == 4 # again, visiting a new point will increment the counter v.dims.set_point(0, initial_t + 3) assert delayed_dask_stack['calls'] == 5 @pytest.mark.sync_only def test_dask_unoptimized_slicing(delayed_dask_stack, monkeypatch): """Prove that the dask_configure function works with a counterexample.""" # we start with a cache...but then intentionally turn it off per-layer. resize_dask_cache(10000) assert _dask_utils._DASK_CACHE.cache.available_bytes == 10000 # add dask stack to viewer. v = ViewerModel() dask_stack = delayed_dask_stack['stack'] layer = v.add_image(dask_stack, cache=False) # the first and the middle stack will be loaded assert delayed_dask_stack['calls'] == 2 with layer.dask_optimized_slicing() as (_, cache): assert cache is None # without optimized dask slicing, we get a new call to the get_array func # (which "re-reads" the full z stack) EVERY time we change the Z plane # even though we've already read this full timepoint. current_z = v.dims.point[1] for i in range(3): v.dims.set_point(1, current_z + i) assert delayed_dask_stack['calls'] == 2 + i # 😞 # of course we still incur calls when moving to a new timepoint... initial_t = v.dims.point[0] v.dims.set_point(0, initial_t + 1) v.dims.set_point(0, initial_t + 2) assert delayed_dask_stack['calls'] == 6 # without the cache we ALSO incur calls when returning to previously loaded # timepoints 😭 v.dims.set_point(0, initial_t + 1) v.dims.set_point(0, initial_t + 0) v.dims.set_point(0, initial_t + 3) # all told, we have ~2x as many calls as the optimized version above. # (should be exactly 9 calls, but for some reason, sometimes more on CI) assert delayed_dask_stack['calls'] >= 9 @pytest.mark.sync_only def test_dask_local_unoptimized_slicing(delayed_dask_stack, monkeypatch): """Prove that the dask_configure function works with a counterexample.""" # make sure we are not caching for this test, which also tests that we # can turn off caching resize_dask_cache(0) assert _dask_utils._DASK_CACHE.cache.available_bytes == 0 monkeypatch.setattr( layers.base.base, 'configure_dask', lambda *_: nullcontext ) # add dask stack to viewer. v = ViewerModel() dask_stack = delayed_dask_stack['stack'] v.add_image(dask_stack, cache=False) # the first and the middle stack will be loaded assert delayed_dask_stack['calls'] == 2 # without optimized dask slicing, we get a new call to the get_array func # (which "re-reads" the full z stack) EVERY time we change the Z plane # even though we've already read this full timepoint. for i in range(3): v.dims.set_point(1, i) assert delayed_dask_stack['calls'] == 2 + 1 + i # 😞 # of course we still incur calls when moving to a new timepoint... v.dims.set_point(0, 1) v.dims.set_point(0, 2) assert delayed_dask_stack['calls'] == 7 # without the cache we ALSO incur calls when returning to previously loaded # timepoints 😭 v.dims.set_point(0, 1) v.dims.set_point(0, 0) v.dims.set_point(0, 3) # all told, we have ~2x as many calls as the optimized version above. # (should be exactly 8 calls, but for some reason, sometimes less on CI) assert delayed_dask_stack['calls'] >= 10 @pytest.mark.sync_only def test_dask_cache_resizing(delayed_dask_stack): """Test that we can spin up, resize, and spin down the cache.""" # make sure we have a cache # big enough for 10+ (10, 10, 10) "timepoints" resize_dask_cache(100000) # add dask stack to the viewer, making sure to pass multiscale and clims v = ViewerModel() dask_stack = delayed_dask_stack['stack'] v.add_image(dask_stack) assert _dask_utils._DASK_CACHE.cache.available_bytes > 0 # make sure the cache actually has been populated assert len(_dask_utils._DASK_CACHE.cache.heap.heap) > 0 # we can resize that cache back to 0 bytes resize_dask_cache(0) assert _dask_utils._DASK_CACHE.cache.available_bytes == 0 # adding a 2nd stack should not adjust the cache size once created v.add_image(dask_stack) assert _dask_utils._DASK_CACHE.cache.available_bytes == 0 # and the cache will remain empty regardless of what we do for i in range(3): v.dims.set_point(1, i) assert len(_dask_utils._DASK_CACHE.cache.heap.heap) == 0 # but we can always spin it up again resize_dask_cache(1e4) assert _dask_utils._DASK_CACHE.cache.available_bytes == 1e4 # and adding a new image doesn't change the size v.add_image(dask_stack) assert _dask_utils._DASK_CACHE.cache.available_bytes == 1e4 # but the cache heap is getting populated again for i in range(3): v.dims.set_point(0, i) assert len(_dask_utils._DASK_CACHE.cache.heap.heap) > 0 def test_prevent_dask_cache(delayed_dask_stack): """Test that pre-emptively setting cache to zero keeps it off""" resize_dask_cache(0) v = ViewerModel() dask_stack = delayed_dask_stack['stack'] # adding a new stack will not increase the cache size v.add_image(dask_stack) assert _dask_utils._DASK_CACHE.cache.available_bytes == 0 # and the cache will not be populated for i in range(3): v.dims.set_point(0, i) assert len(_dask_utils._DASK_CACHE.cache.heap.heap) == 0 @pytest.mark.sync_only def test_dask_contrast_limits_range_init(): np_arr = np.array([[0.000001, -0.0002], [0, 0.0000004]]) da_arr = da.array(np_arr) i1 = layers.Image(np_arr) i2 = layers.Image(da_arr) assert i1.contrast_limits_range == i2.contrast_limits_range napari-0.5.0a1/napari/layers/_tests/test_data_protocol.py000066400000000000000000000015431437041365600235410ustar00rootroot00000000000000import pytest from napari._tests.utils import layer_test_data from napari.layers import Shapes, Surface from napari.layers._data_protocols import assert_protocol EASY_TYPES = [i for i in layer_test_data if i[0] not in (Shapes, Surface)] def _layer_test_data_id(test_data): LayerCls, data, ndim = test_data objtype = type(data).__name__ dtype = getattr(data, 'dtype', '?') return f'{LayerCls.__name__}_{objtype}_{dtype}_{ndim}d' @pytest.mark.parametrize('test_data', EASY_TYPES, ids=_layer_test_data_id) def test_layer_protocol(test_data): LayerCls, data, _ = test_data layer = LayerCls(data) assert_protocol(layer.data) def test_layer_protocol_raises(): with pytest.raises(TypeError) as e: assert_protocol([]) # list doesn't provide the protocol assert "Missing methods: " in str(e) assert "'shape'" in str(e) napari-0.5.0a1/napari/layers/_tests/test_layer_actions.py000066400000000000000000000051011437041365600235350ustar00rootroot00000000000000import numpy as np import pytest from napari.components.layerlist import LayerList from napari.layers import Image, Labels, Points, Shapes from napari.layers._layer_actions import ( _convert, _convert_dtype, _duplicate_layer, _project, ) def test_duplicate_layers(): def _dummy(): pass layer_list = LayerList() layer_list.append(Points([[0, 0]], name="test")) layer_list.selection.active = layer_list[0] layer_list[0].events.data.connect(_dummy) assert len(layer_list[0].events.data.callbacks) == 2 assert len(layer_list) == 1 _duplicate_layer(layer_list) assert len(layer_list) == 2 assert layer_list[0].name == "test" assert layer_list[1].name == "test copy" assert layer_list[1].events.source is layer_list[1] assert ( len(layer_list[1].events.data.callbacks) == 1 ) # `events` Event Emitter assert layer_list[1].source.parent() is layer_list[0] @pytest.mark.parametrize( 'mode', ['max', 'min', 'std', 'sum', 'mean', 'median'] ) def test_projections(mode): ll = LayerList() ll.append(Image(np.random.rand(8, 8, 8))) assert len(ll) == 1 assert ll[-1].data.ndim == 3 _project(ll, mode=mode) assert len(ll) == 2 # because keepdims = False assert ll[-1].data.shape == (8, 8) @pytest.mark.parametrize( 'mode', ['int8', 'int16', 'int32', 'int64', 'uint8', 'uint16', 'uint32', 'uint64'], ) def test_convert_dtype(mode): ll = LayerList() data = np.zeros((10, 10), dtype=np.int16) ll.append(Labels(data)) assert ll[-1].data.dtype == np.int16 data[5, 5] = 1000 assert data[5, 5] == 1000 if mode == 'int8' or mode == 'uint8': # label value 1000 is outside of the target data type range. with pytest.raises(AssertionError): _convert_dtype(ll, mode=mode) assert ll[-1].data.dtype == np.int16 else: _convert_dtype(ll, mode=mode) assert ll[-1].data.dtype == np.dtype(mode) assert ll[-1].data[5, 5] == 1000 assert ll[-1].data.flatten().sum() == 1000 @pytest.mark.parametrize( 'input, type_', [ (Image(np.random.rand(10, 10)), 'labels'), (Labels(np.ones((10, 10), dtype=int)), 'image'), (Shapes([np.array([[0, 0], [0, 10], [10, 0], [10, 10]])]), 'labels'), ], ) def test_convert_layer(input, type_): ll = LayerList() input.scale *= 1.5 original_scale = input.scale.copy() ll.append(input) assert ll[0]._type_string != type_ _convert(ll, type_) assert ll[0]._type_string == type_ assert np.array_equal(ll[0].scale, original_scale) napari-0.5.0a1/napari/layers/_tests/test_layer_attributes.py000066400000000000000000000114061437041365600242700ustar00rootroot00000000000000import numpy as np import pytest from napari._tests.utils import layer_test_data from napari.layers import Image, Labels @pytest.mark.parametrize( 'image_shape, dims_displayed, expected', [ ((10, 20, 30), (0, 1, 2), [[0, 10], [0, 20], [0, 30]]), ((10, 20, 30), (0, 2, 1), [[0, 10], [0, 30], [0, 20]]), ((10, 20, 30), (2, 1, 0), [[0, 30], [0, 20], [0, 10]]), ], ) def test_layer_bounding_box_order(image_shape, dims_displayed, expected): layer = Image(data=np.random.random(image_shape)) # assert np.allclose( layer._display_bounding_box(dims_displayed=dims_displayed), expected ) @pytest.mark.parametrize('Layer, data, ndim', layer_test_data) def test_update_scale_updates_layer_extent_cache(Layer, data, ndim): np.random.seed(0) layer = Layer(data) # Check layer has been correctly created assert layer.ndim == ndim np.testing.assert_almost_equal(layer.extent.step, (1,) * layer.ndim) # Check layer extent change when scale changes old_extent = layer.extent layer.scale = (2,) * layer.ndim new_extent = layer.extent assert old_extent is not layer.extent assert new_extent is layer.extent np.testing.assert_almost_equal(layer.extent.step, (2,) * layer.ndim) @pytest.mark.parametrize('Layer, data, ndim', layer_test_data) def test_update_data_updates_layer_extent_cache(Layer, data, ndim): np.random.seed(0) layer = Layer(data) # Check layer has been correctly created assert layer.ndim == ndim # Check layer extent change when data changes old_extent = layer.extent try: layer.data = data + 1 except TypeError: return new_extent = layer.extent assert old_extent is not layer.extent assert new_extent is layer.extent def test_contrast_limits_must_be_increasing(): np.random.seed(0) Image(np.random.rand(8, 8), contrast_limits=[0, 1]) with pytest.raises(ValueError): Image(np.random.rand(8, 8), contrast_limits=[1, 1]) with pytest.raises(ValueError): Image(np.random.rand(8, 8), contrast_limits=[1, 0]) def _check_subpixel_values(layer, val_dict): ndisplay = layer._slice_input.ndisplay for center, expected_value in val_dict.items(): # ensure all positions within the pixel extent report the same value # note: values are checked in data coordinates in this function for offset_0 in [-0.4999, 0, 0.4999]: for offset_1 in [-0.4999, 0, 0.4999]: position = [center[0] + offset_0, center[1] + offset_1] view_direction = None dims_displayed = None if ndisplay == 3: position = [0] + position if isinstance(layer, Labels): # Labels implements _get_value_3d, Image does not view_direction = np.asarray([1.0, 0, 0]) dims_displayed = [0, 1, 2] val = layer.get_value( position=position, view_direction=view_direction, dims_displayed=dims_displayed, world=False, ) assert val == expected_value @pytest.mark.parametrize('ImageClass', [Image, Labels]) @pytest.mark.parametrize('ndim', [2, 3]) def test_get_value_at_subpixel_offsets(ImageClass, ndim): """check value at various shifts within a pixel/voxel's extent""" if ndim == 3: data = np.arange(1, 9).reshape(2, 2, 2) elif ndim == 2: data = np.arange(1, 5).reshape(2, 2) # test using non-uniform scale per-axis layer = ImageClass(data, scale=(0.5, 1, 2)[:ndim]) layer._slice_dims([0] * ndim, ndisplay=ndim) # dictionary of expected values at each voxel center coordinate val_dict = { (0, 0): data[(0,) * (ndim - 2) + (0, 0)], (0, 1): data[(0,) * (ndim - 2) + (0, 1)], (1, 0): data[(0,) * (ndim - 2) + (1, 0)], (1, 1): data[(0,) * (ndim - 2) + (1, 1)], } _check_subpixel_values(layer, val_dict) @pytest.mark.parametrize('ImageClass', [Image, Labels]) def test_get_value_3d_view_of_2d_image(ImageClass): """check value at various shifts within a pixel/voxel's extent""" data = np.arange(1, 5).reshape(2, 2) ndisplay = 3 # test using non-uniform scale per-axis layer = ImageClass(data, scale=(0.5, 1)) layer._slice_dims([0] * ndisplay, ndisplay=ndisplay) # dictionary of expected values at each voxel center coordinate val_dict = { (0, 0): data[(0, 0)], (0, 1): data[(0, 1)], (1, 0): data[(1, 0)], (1, 1): data[(1, 1)], } _check_subpixel_values(layer, val_dict) def test_zero_scale_layer(): with pytest.raises(ValueError, match='scale values of 0'): Image(np.zeros((64, 64)), scale=(0, 1)) napari-0.5.0a1/napari/layers/_tests/test_serialize.py000066400000000000000000000030151437041365600226720ustar00rootroot00000000000000import inspect import numpy as np import pytest from napari._tests.utils import are_objects_equal, layer_test_data @pytest.mark.parametrize('Layer, data, ndim', layer_test_data) def test_attrs_arrays(Layer, data, ndim): """Test layer attributes and arrays.""" np.random.seed(0) layer = Layer(data) # Check layer has been correctly created assert layer.ndim == ndim properties = layer._get_state() # Check every property is in call signature signature = inspect.signature(Layer) # Check every property is also a parameter. for prop in properties.keys(): assert prop in signature.parameters # Check number of properties is same as number in signature # excluding `cache` which is not yet in `_get_state` assert len(properties) == len(signature.parameters) - 1 # Check new layer can be created new_layer = Layer(**properties) # Check that new layer matches old on all properties: for prop in properties.keys(): assert are_objects_equal( getattr(layer, prop), getattr(new_layer, prop) ) @pytest.mark.parametrize('Layer, data, ndim', layer_test_data) def test_no_callbacks(Layer, data, ndim): """Test no internal callbacks for layer emitters.""" layer = Layer(data) # Check layer has been correctly created assert layer.ndim == ndim # Check that no internal callbacks have been registered assert len(layer.events.callbacks) == 0 for em in layer.events.emitters.values(): assert len(em.callbacks) == 0 napari-0.5.0a1/napari/layers/_tests/test_source.py000066400000000000000000000035711437041365600222120ustar00rootroot00000000000000import pydantic import pytest from napari.layers import Points from napari.layers._source import Source, current_source, layer_source def test_layer_source(): """Test basic layer source assignment mechanism""" with layer_source(path='some_path', reader_plugin='napari'): points = Points() assert points.source == Source(path='some_path', reader_plugin='napari') def test_source_context(): """Test nested contexts, overrides, and resets.""" assert current_source() == Source() # everything created within this context will have this sample source with layer_source(sample=('samp', 'name')): assert current_source() == Source(sample=('samp', 'name')) # nested contexts override previous ones with layer_source(path='a', reader_plugin='plug'): assert current_source() == Source( path='a', reader_plugin='plug', sample=('samp', 'name') ) # note the new path now... with layer_source(path='b'): assert current_source() == Source( path='b', reader_plugin='plug', sample=('samp', 'name') ) # as we exit the contexts, they should undo their assignments assert current_source() == Source( path='a', reader_plugin='plug', sample=('samp', 'name') ) assert current_source() == Source(sample=('samp', 'name')) point = Points() with layer_source(parent=point): assert current_source() == Source( sample=('samp', 'name'), parent=point ) assert current_source() == Source() def test_source_assert_parent(): assert current_source() == Source() with pytest.raises(pydantic.error_wrappers.ValidationError): with layer_source(parent=''): current_source() assert current_source() == Source() napari-0.5.0a1/napari/layers/_tests/test_utils.py000066400000000000000000000042461437041365600220520ustar00rootroot00000000000000import numpy as np import pytest from skimage.util import img_as_ubyte from napari.layers.utils.layer_utils import convert_to_uint8 @pytest.mark.filterwarnings("ignore:Downcasting uint:UserWarning:skimage") @pytest.mark.parametrize("dtype", [np.uint8, np.uint16, np.uint32, np.uint64]) def test_uint(dtype): data = np.arange(50, dtype=dtype) data_scaled = data * 256 ** (data.dtype.itemsize - 1) assert convert_to_uint8(data_scaled).dtype == np.uint8 assert np.all(data == convert_to_uint8(data_scaled)) assert np.all(img_as_ubyte(data) == convert_to_uint8(data)) assert np.all(img_as_ubyte(data_scaled) == convert_to_uint8(data_scaled)) @pytest.mark.filterwarnings("ignore:Downcasting int:UserWarning:skimage") @pytest.mark.parametrize("dtype", [np.int8, np.int16, np.int32, np.int64]) def test_int(dtype): data = np.arange(50, dtype=dtype) data_scaled = data * 256 ** (data.dtype.itemsize - 1) assert convert_to_uint8(data).dtype == np.uint8 assert convert_to_uint8(data_scaled).dtype == np.uint8 assert np.all(img_as_ubyte(data) == convert_to_uint8(data)) assert np.all(2 * data == convert_to_uint8(data_scaled)) assert np.all(img_as_ubyte(data_scaled) == convert_to_uint8(data_scaled)) assert np.all(img_as_ubyte(data - 10) == convert_to_uint8(data - 10)) assert np.all( img_as_ubyte(data_scaled - 10) == convert_to_uint8(data_scaled - 10) ) @pytest.mark.parametrize("dtype", [np.float64, np.float32, float]) def test_float(dtype): data = np.linspace(0, 0.5, 128, dtype=dtype, endpoint=False) res = np.arange(128, dtype=np.uint8) assert convert_to_uint8(data).dtype == np.uint8 assert np.all(convert_to_uint8(data) == res) data = np.linspace(0, 1, 256, dtype=dtype) res = np.arange(256, dtype=np.uint8) assert np.all(convert_to_uint8(data) == res) assert np.all(img_as_ubyte(data) == convert_to_uint8(data)) assert np.all(img_as_ubyte(data - 0.5) == convert_to_uint8(data - 0.5)) def test_bool(): data = np.zeros((10, 10), dtype=bool) data[2:-2, 2:-2] = 1 converted = convert_to_uint8(data) assert converted.dtype == np.uint8 assert np.all(img_as_ubyte(data) == converted) napari-0.5.0a1/napari/layers/base/000077500000000000000000000000001437041365600167045ustar00rootroot00000000000000napari-0.5.0a1/napari/layers/base/__init__.py000066400000000000000000000001171437041365600210140ustar00rootroot00000000000000from napari.layers.base.base import Layer, no_op __all__ = ['Layer', 'no_op'] napari-0.5.0a1/napari/layers/base/_base_constants.py000066400000000000000000000075131437041365600224310ustar00rootroot00000000000000from collections import OrderedDict from enum import IntEnum, auto from napari.utils.misc import StringEnum from napari.utils.translations import trans class Blending(StringEnum): """BLENDING: Blending mode for the layer. Selects a preset blending mode in vispy that determines how RGB and alpha values get mixed. Blending.OPAQUE Allows for only the top layer to be visible and corresponds to depth_test=True, cull_face=False, blend=False. Blending.TRANSLUCENT Allows for multiple layers to be blended with different opacity and corresponds to depth_test=True, cull_face=False, blend=True, blend_func=('src_alpha', 'one_minus_src_alpha'), and blend_equation=('func_add'). Blending.TRANSLUCENT_NO_DEPTH Allows for multiple layers to be blended with different opacity, but no depth testing is performed. and corresponds to depth_test=False, cull_face=False, blend=True, blend_func=('src_alpha', 'one_minus_src_alpha'), and blend_equation=('func_add'). Blending.ADDITIVE Allows for multiple layers to be blended together with different colors and opacity. Useful for creating overlays. It corresponds to depth_test=False, cull_face=False, blend=True, blend_func=('src_alpha', 'one'). Blending.MINIMUM Allows for multiple layers to be blended together such that the minimum of each color and alpha are selected. Useful for creating overlays with inverted colormaps. It corresponds to depth_test=False, cull_face=False, blend=True, blend_equation='min'. """ TRANSLUCENT = auto() TRANSLUCENT_NO_DEPTH = auto() ADDITIVE = auto() MINIMUM = auto() OPAQUE = auto() BLENDING_TRANSLATIONS = OrderedDict( [ (Blending.TRANSLUCENT, trans._("translucent")), (Blending.TRANSLUCENT_NO_DEPTH, trans._("translucent_no_depth")), (Blending.ADDITIVE, trans._("additive")), (Blending.MINIMUM, trans._("minimum")), (Blending.OPAQUE, trans._("opaque")), ] ) class Mode(StringEnum): """ Mode: Interactive mode. The normal, default mode is PAN_ZOOM, which allows for normal interactivity with the canvas. TRANSFORM allows for manipulation of the layer transform. """ PAN_ZOOM = auto() TRANSFORM = auto() class InteractionBoxHandle(IntEnum): """ Handle indices for the InteractionBox overlay. Vertices are generated according to the following scheme: 8 | 0---4---2 | | 5 9 6 | | 1---7---3 Note that y is actually upside down in the canvas in vispy coordinates. """ TOP_LEFT = 0 TOP_CENTER = 4 TOP_RIGHT = 2 CENTER_LEFT = 5 CENTER_RIGHT = 6 BOTTOM_LEFT = 1 BOTTOM_CENTER = 7 BOTTOM_RIGHT = 3 ROTATION = 8 INSIDE = 9 @classmethod def opposite_handle(cls, handle): opposites = { InteractionBoxHandle.TOP_LEFT: InteractionBoxHandle.BOTTOM_RIGHT, InteractionBoxHandle.TOP_CENTER: InteractionBoxHandle.BOTTOM_CENTER, InteractionBoxHandle.TOP_RIGHT: InteractionBoxHandle.BOTTOM_LEFT, InteractionBoxHandle.CENTER_LEFT: InteractionBoxHandle.CENTER_RIGHT, } opposites.update({v: k for k, v in opposites.items()}) if (opposite := opposites.get(handle, None)) is None: raise ValueError(f'{handle} has no opposite handle.') return opposite @classmethod def corners(cls): return ( cls.TOP_LEFT, cls.TOP_RIGHT, cls.BOTTOM_LEFT, cls.BOTTOM_RIGHT, ) napari-0.5.0a1/napari/layers/base/_base_key_bindings.py000066400000000000000000000006311437041365600230540ustar00rootroot00000000000000from app_model.types import KeyCode from napari.layers.base.base import Layer @Layer.bind_key(KeyCode.Space) def hold_to_pan_zoom(layer): """Hold to pan and zoom in the viewer.""" if layer._mode != layer._modeclass.PAN_ZOOM: # on key press prev_mode = layer.mode layer.mode = layer._modeclass.PAN_ZOOM yield # on key release layer.mode = prev_mode napari-0.5.0a1/napari/layers/base/_base_mouse_bindings.py000066400000000000000000000166111437041365600234210ustar00rootroot00000000000000import warnings import numpy as np from napari.layers.utils.interaction_box import ( InteractionBoxHandle, generate_transform_box_from_layer, get_nearby_handle, ) from napari.utils.transforms import Affine from napari.utils.translations import trans def highlight_box_handles(layer, event): """ Highlight the hovered handle of a TransformBox. """ if not len(event.dims_displayed) == 2: return # we work in data space so we're axis aligned which simplifies calculation # same as Layer.world_to_data world_to_data = ( layer._transforms[1:].set_slice(event.dims_displayed).inverse ) pos = np.array(world_to_data(event.position))[event.dims_displayed] handle_coords = generate_transform_box_from_layer( layer, event.dims_displayed ) # TODO: dynamically set tolerance based on canvas size so it's not hard to pick small layer nearby_handle = get_nearby_handle(pos, handle_coords) # set the selected vertex of the box to the nearby_handle (can also be INSIDE or None) layer._overlays['transform_box'].selected_vertex = nearby_handle def _translate_with_box( layer, initial_affine, initial_mouse_pos, mouse_pos, event ): offset = mouse_pos - initial_mouse_pos new_affine = Affine(translate=offset).compose(initial_affine) layer.affine = layer.affine.replace_slice(event.dims_displayed, new_affine) def _rotate_with_box( layer, initial_affine, initial_mouse_pos, initial_handle_coords, initial_center, mouse_pos, event, ): # calculate the angle between the center-handle vector and the center-mouse vector center_to_handle = ( initial_handle_coords[InteractionBoxHandle.ROTATION] - initial_center ) center_to_handle /= np.linalg.norm(center_to_handle) center_to_mouse = mouse_pos - initial_center center_to_mouse /= np.linalg.norm(center_to_mouse) angle = np.arctan2(center_to_mouse[1], center_to_mouse[0]) - np.arctan2( center_to_handle[1], center_to_handle[0] ) new_affine = ( Affine(translate=initial_center) .compose(Affine(rotate=np.rad2deg(angle))) .compose(Affine(translate=-initial_center)) .compose(initial_affine) ) layer.affine = layer.affine.replace_slice(event.dims_displayed, new_affine) def _scale_with_box( layer, initial_affine, initial_world_to_data, initial_data2physical, nearby_handle, initial_center, initial_handle_coords_data, mouse_pos, event, ): locked_aspect_ratio = False if 'Shift' in event.modifiers: if nearby_handle in InteractionBoxHandle.corners(): locked_aspect_ratio = True else: warnings.warn( trans._( 'Aspect ratio can only be blocked when resizing from a corner', deferred=True, ), RuntimeWarning, stacklevel=2, ) # note: we work in data space from here on! # if Control is held, instead of locking into place the opposite handle, # lock into place the center of the layer and resize around it. if 'Control' in event.modifiers: scaling_center = initial_world_to_data(initial_center) else: # opposite handle scaling_center = initial_handle_coords_data[ InteractionBoxHandle.opposite_handle(nearby_handle) ] # calculate the distance to the scaling center (which is fixed) before and after drag center_to_handle = ( initial_handle_coords_data[nearby_handle] - scaling_center ) center_to_mouse = initial_world_to_data(mouse_pos) - scaling_center # get per-dimension scale values with warnings.catch_warnings(): # a "divide by zero" warning is raised here when resizing along only one axis # (i.e: dragging the central handle of the TransformBox). # That's intended, because we get inf or nan, which we can then replace with 1s # and thus maintain the size along that axis. warnings.simplefilter("ignore", RuntimeWarning) scale = center_to_mouse / center_to_handle scale = np.nan_to_num(scale, posinf=1, neginf=1) if locked_aspect_ratio: scale_factor = np.linalg.norm(scale) scale = [scale_factor, scale_factor] new_affine = ( # bring layer to axis aligned space initial_affine.compose(initial_data2physical) # center opposite handle .compose(Affine(translate=scaling_center)) # apply scale .compose(Affine(scale=scale)) # undo all the above, backwards .compose(Affine(translate=-scaling_center)) .compose(initial_data2physical.inverse) .compose(initial_affine.inverse) # compose with the original affine .compose(initial_affine) ) layer.affine = layer.affine.replace_slice(event.dims_displayed, new_affine) def transform_with_box(layer, event): """ Translate, rescale or rotate a layer by dragging a TransformBox handle. """ if not len(event.dims_displayed) == 2: return # we work in data space so we're axis aligned which simplifies calculation # same as Layer.data_to_world initial_data_to_world = layer._transforms[1:].simplified.set_slice( event.dims_displayed ) initial_world_to_data = initial_data_to_world.inverse initial_mouse_pos = np.array(event.position)[event.dims_displayed] initial_mouse_pos_data = initial_world_to_data(initial_mouse_pos) initial_handle_coords_data = generate_transform_box_from_layer( layer, event.dims_displayed ) nearby_handle = get_nearby_handle( initial_mouse_pos_data, initial_handle_coords_data ) if nearby_handle is None: return # now that we have the nearby handles, other calculations need # the world space handle positions initial_handle_coords = initial_data_to_world(initial_handle_coords_data) # initial layer transform so we can calculate changes later initial_affine = layer.affine.set_slice(event.dims_displayed) # needed for rescaling initial_data2physical = layer._transforms['data2physical'].set_slice( event.dims_displayed ) # needed for resize and rotate initial_center = np.mean( initial_handle_coords[ [ InteractionBoxHandle.TOP_LEFT, InteractionBoxHandle.BOTTOM_RIGHT, ] ], axis=0, ) yield while event.type == 'mouse_move': mouse_pos = np.array(event.position)[event.dims_displayed] if nearby_handle == InteractionBoxHandle.INSIDE: _translate_with_box( layer, initial_affine, initial_mouse_pos, mouse_pos, event ) yield elif nearby_handle == InteractionBoxHandle.ROTATION: _rotate_with_box( layer, initial_affine, initial_mouse_pos, initial_handle_coords, initial_center, mouse_pos, event, ) yield else: _scale_with_box( layer, initial_affine, initial_world_to_data, initial_data2physical, nearby_handle, initial_center, initial_handle_coords_data, mouse_pos, event, ) yield napari-0.5.0a1/napari/layers/base/_tests/000077500000000000000000000000001437041365600202055ustar00rootroot00000000000000napari-0.5.0a1/napari/layers/base/_tests/test_base_key_bindings.py000066400000000000000000000010261437041365600252540ustar00rootroot00000000000000import pytest from napari.layers.base import _base_key_bindings as key_bindings from napari.layers.points import Points def test_hold_to_pan_zoom(layer): data = [[1, 3], [8, 4], [10, 10], [15, 4]] layer = Points(data, size=1) layer.mode = 'transform' # need to go through the generator gen = key_bindings.hold_to_pan_zoom(layer) assert layer.mode == 'transform' next(gen) assert layer.mode == 'pan_zoom' with pytest.raises(StopIteration): next(gen) assert layer.mode == 'transform' napari-0.5.0a1/napari/layers/base/base.py000066400000000000000000001776441437041365600202130ustar00rootroot00000000000000from __future__ import annotations import itertools import os.path import warnings from abc import ABC, abstractmethod from collections import defaultdict, namedtuple from contextlib import contextmanager from functools import cached_property from typing import List, Optional, Tuple, Union import magicgui as mgui import numpy as np from npe2 import plugin_manager as pm from napari.layers.base._base_constants import Blending, Mode from napari.layers.base._base_mouse_bindings import ( highlight_box_handles, transform_with_box, ) from napari.layers.utils._slice_input import _SliceInput from napari.layers.utils.interactivity_utils import ( drag_data_to_projected_distance, ) from napari.layers.utils.layer_utils import ( coerce_affine, compute_multiscale_level_and_corners, convert_to_uint8, dims_displayed_world_to_layer, get_extent_world, ) from napari.layers.utils.plane import ClippingPlane, ClippingPlaneList from napari.utils._dask_utils import configure_dask from napari.utils._magicgui import ( add_layer_to_viewer, add_layers_to_viewer, get_layers, ) from napari.utils.events import EmitterGroup, Event, EventedDict from napari.utils.events.event import WarningEmitter from napari.utils.geometry import ( find_front_back_face, intersect_line_with_axis_aligned_bounding_box_3d, ) from napari.utils.key_bindings import KeymapProvider from napari.utils.mouse_bindings import MousemapProvider from napari.utils.naming import magic_name from napari.utils.status_messages import generate_layer_coords_status from napari.utils.transforms import Affine, CompositeAffine, TransformChain from napari.utils.translations import trans Extent = namedtuple('Extent', 'data world step') def no_op(layer: Layer, event: Event) -> None: """ A convenient no-op event for the layer mouse binding. This makes it easier to handle many cases by inserting this as as place holder Parameters ---------- layer : Layer Current layer on which this will be bound as a callback event : Event event that triggered this mouse callback. Returns ------- None """ return None @mgui.register_type(choices=get_layers, return_callback=add_layer_to_viewer) class Layer(KeymapProvider, MousemapProvider, ABC): """Base layer class. Parameters ---------- name : str Name of the layer. metadata : dict Layer metadata. scale : tuple of float Scale factors for the layer. translate : tuple of float Translation values for the layer. rotate : float, 3-tuple of float, or n-D array. If a float convert into a 2D rotation matrix using that value as an angle. If 3-tuple convert into a 3D rotation matrix, using a yaw, pitch, roll convention. Otherwise assume an nD rotation. Angles are assumed to be in degrees. They can be converted from radians with np.degrees if needed. shear : 1-D array or n-D array Either a vector of upper triangular values, or an nD shear matrix with ones along the main diagonal. affine : n-D array or napari.utils.transforms.Affine (N+1, N+1) affine transformation matrix in homogeneous coordinates. The first (N, N) entries correspond to a linear transform and the final column is a length N translation vector and a 1 or a napari `Affine` transform object. Applied as an extra transform on top of the provided scale, rotate, and shear values. opacity : float Opacity of the layer visual, between 0.0 and 1.0. blending : str One of a list of preset blending modes that determines how RGB and alpha values of the layer visual get mixed. Allowed values are {'opaque', 'translucent', 'translucent_no_depth', 'additive', and 'minimum'}. visible : bool Whether the layer visual is currently being displayed. multiscale : bool Whether the data is multiscale or not. Multiscale data is represented by a list of data objects and should go from largest to smallest. Attributes ---------- name : str Unique name of the layer. opacity : float Opacity of the layer visual, between 0.0 and 1.0. visible : bool Whether the layer visual is currently being displayed. blending : Blending Determines how RGB and alpha values get mixed. * ``Blending.OPAQUE`` Allows for only the top layer to be visible and corresponds to ``depth_test=True``, ``cull_face=False``, ``blend=False``. * ``Blending.TRANSLUCENT`` Allows for multiple layers to be blended with different opacity and corresponds to ``depth_test=True``, ``cull_face=False``, ``blend=True``, ``blend_func=('src_alpha', 'one_minus_src_alpha')``, and ``blend_equation=('func_add')``. * ``Blending.TRANSLUCENT_NO_DEPTH`` Allows for multiple layers to be blended with different opacity, but no depth testing is performed. Corresponds to ``depth_test=False``, ``cull_face=False``, ``blend=True``, ``blend_func=('src_alpha', 'one_minus_src_alpha')``, and ``blend_equation=('func_add')``. * ``Blending.ADDITIVE`` Allows for multiple layers to be blended together with different colors and opacity. Useful for creating overlays. It corresponds to ``depth_test=False``, ``cull_face=False``, ``blend=True``, ``blend_func=('src_alpha', 'one')``, and ``blend_equation=('func_add')``. * ``Blending.MINIMUM`` Allows for multiple layers to be blended together such that the minimum of each RGB component and alpha are selected. Useful for creating overlays with inverted colormaps. It corresponds to ``depth_test=False``, ``cull_face=False``, ``blend=True``, ``blend_equation=('min')``. scale : tuple of float Scale factors for the layer. translate : tuple of float Translation values for the layer. rotate : float, 3-tuple of float, or n-D array. If a float convert into a 2D rotation matrix using that value as an angle. If 3-tuple convert into a 3D rotation matrix, using a yaw, pitch, roll convention. Otherwise assume an nD rotation. Angles are assumed to be in degrees. They can be converted from radians with np.degrees if needed. shear : 1-D array or n-D array Either a vector of upper triangular values, or an nD shear matrix with ones along the main diagonal. affine : n-D array or napari.utils.transforms.Affine (N+1, N+1) affine transformation matrix in homogeneous coordinates. The first (N, N) entries correspond to a linear transform and the final column is a length N translation vector and a 1 or a napari `Affine` transform object. Applied as an extra transform on top of the provided scale, rotate, and shear values. multiscale : bool Whether the data is multiscale or not. Multiscale data is represented by a list of data objects and should go from largest to smallest. cache : bool Whether slices of out-of-core datasets should be cached upon retrieval. Currently, this only applies to dask arrays. z_index : int Depth of the layer visual relative to other visuals in the scenecanvas. corner_pixels : array Coordinates of the top-left and bottom-right canvas pixels in the data coordinates of each layer. For multiscale data the coordinates are in the space of the currently viewed data level, not the highest resolution level. ndim : int Dimensionality of the layer. thumbnail : (N, M, 4) array Array of thumbnail data for the layer. status : str Displayed in status bar bottom left. help : str Displayed in status bar bottom right. interactive : bool Determine if canvas pan/zoom interactivity is enabled. cursor : str String identifying which cursor displayed over canvas. cursor_size : int | None Size of cursor if custom. None yields default size scale_factor : float Conversion factor from canvas coordinates to image coordinates, which depends on the current zoom level. source : Source source of the layer (such as a plugin or widget) Notes ----- Must define the following: * `_extent_data`: property * `data` property (setter & getter) May define the following: * `_set_view_slice()`: called to set currently viewed slice * `_basename()`: base/default name of the layer """ _modeclass = Mode _drag_modes = { Mode.PAN_ZOOM: no_op, Mode.TRANSFORM: transform_with_box, } _move_modes = { Mode.PAN_ZOOM: no_op, Mode.TRANSFORM: highlight_box_handles, } _cursor_modes = { Mode.PAN_ZOOM: 'standard', Mode.TRANSFORM: 'standard', } def __init__( self, data, ndim, *, name=None, metadata=None, scale=None, translate=None, rotate=None, shear=None, affine=None, opacity=1, blending='translucent', visible=True, multiscale=False, cache=True, # this should move to future "data source" object. experimental_clipping_planes=None, mode='pan_zoom', ) -> None: super().__init__() if name is None and data is not None: name = magic_name(data) if scale is not None and not np.all(scale): raise ValueError( trans._( "Layer {name} is invalid because it has scale values of 0. The layer's scale is currently {scale}", deferred=True, name=repr(name), scale=repr(scale), ) ) # Needs to be imported here to avoid circular import in _source from napari.layers._source import current_source self._source = current_source() self.dask_optimized_slicing = configure_dask(data, cache) self._metadata = dict(metadata or {}) self._opacity = opacity self._blending = Blending(blending) self._visible = visible self._freeze = False self._status = 'Ready' self._help = '' self._cursor = 'standard' self._cursor_size = 1 self._interactive = True self._value = None self.scale_factor = 1 self.multiscale = multiscale self._experimental_clipping_planes = ClippingPlaneList() self._mode = self._modeclass('pan_zoom') self._ndim = ndim self._slice_input = _SliceInput( ndisplay=2, point=(0,) * ndim, order=tuple(range(ndim)), ) # Create a transform chain consisting of four transforms: # 1. `tile2data`: An initial transform only needed to display tiles # of an image. It maps pixels of the tile into the coordinate space # of the full resolution data and can usually be represented by a # scale factor and a translation. A common use case is viewing part # of lower resolution level of a multiscale image, another is using a # downsampled version of an image when the full image size is larger # than the maximum allowed texture size of your graphics card. # 2. `data2physical`: The main transform mapping data to a world-like # physical coordinate that may also encode acquisition parameters or # sample spacing. # 3. `physical2world`: An extra transform applied in world-coordinates that # typically aligns this layer with another. # 4. `world2grid`: An additional transform mapping world-coordinates # into a grid for looking at layers side-by-side. if scale is None: scale = [1] * ndim if translate is None: translate = [0] * ndim self._transforms = TransformChain( [ Affine(np.ones(ndim), np.zeros(ndim), name='tile2data'), CompositeAffine( scale, translate, rotate=rotate, shear=shear, ndim=ndim, name='data2physical', ), coerce_affine(affine, ndim=ndim, name='physical2world'), Affine(np.ones(ndim), np.zeros(ndim), name='world2grid'), ] ) self.corner_pixels = np.zeros((2, ndim), dtype=int) self._editable = True self._array_like = False self._thumbnail_shape = (32, 32, 4) self._thumbnail = np.zeros(self._thumbnail_shape, dtype=np.uint8) self._update_properties = True self._name = '' self.experimental_clipping_planes = experimental_clipping_planes # circular import from napari.components.overlays.bounding_box import BoundingBoxOverlay from napari.components.overlays.interaction_box import ( SelectionBoxOverlay, TransformBoxOverlay, ) self._overlays = EventedDict() self.events = EmitterGroup( source=self, refresh=Event, set_data=Event, blending=Event, opacity=Event, visible=Event, scale=Event, translate=Event, rotate=Event, shear=Event, affine=Event, data=Event, name=Event, thumbnail=Event, status=Event, help=Event, interactive=Event, cursor=Event, cursor_size=Event, editable=Event, loaded=Event, extent=Event, _overlays=Event, select=WarningEmitter( trans._( "'layer.events.select' is deprecated and will be removed in napari v0.4.9, use 'viewer.layers.selection.events.changed' instead, and inspect the 'added' attribute on the event.", deferred=True, ), type='select', ), deselect=WarningEmitter( trans._( "'layer.events.deselect' is deprecated and will be removed in napari v0.4.9, use 'viewer.layers.selection.events.changed' instead, and inspect the 'removed' attribute on the event.", deferred=True, ), type='deselect', ), mode=Event, ) self.name = name self.mode = mode self._overlays.update( { 'transform_box': TransformBoxOverlay(), 'selection_box': SelectionBoxOverlay(), 'bounding_box': BoundingBoxOverlay(), } ) # TODO: we try to avoid inner event connection, but this might be the only way # until we figure out nested evented objects self._overlays.events.connect(self.events._overlays) def __str__(self): """Return self.name.""" return self.name def __repr__(self): cls = type(self) return f"<{cls.__name__} layer {repr(self.name)} at {hex(id(self))}>" def _mode_setter_helper(self, mode): """ Helper to manage callbacks in multiple layers Parameters ---------- mode : type(self._modeclass) | str New mode for the current layer. Returns ------- bool : whether mode changed """ mode = self._modeclass(mode) assert mode is not None if not self.editable: mode = self._modeclass.PAN_ZOOM if mode == self._mode: return mode if mode.value not in self._modeclass.keys(): raise ValueError( trans._( "Mode not recognized: {mode}", deferred=True, mode=mode ) ) for callback_list, mode_dict in [ (self.mouse_drag_callbacks, self._drag_modes), (self.mouse_move_callbacks, self._move_modes), ( self.mouse_double_click_callbacks, getattr( self, '_double_click_modes', defaultdict(lambda: no_op) ), ), ]: if mode_dict[self._mode] in callback_list: callback_list.remove(mode_dict[self._mode]) callback_list.append(mode_dict[mode]) self.cursor = self._cursor_modes[mode] self.interactive = mode == self._modeclass.PAN_ZOOM self._overlays['transform_box'].visible = ( mode == self._modeclass.TRANSFORM ) if mode == self._modeclass.TRANSFORM: self.help = trans._( 'hold to pan/zoom, hold to preserve aspect ratio and rotate in 45° increments' ) elif mode == self._modeclass.PAN_ZOOM: self.help = '' return mode @property def mode(self) -> str: """str: Interactive mode Interactive mode. The normal, default mode is PAN_ZOOM, which allows for normal interactivity with the canvas. TRANSFORM allows for manipulation of the layer transform. """ return str(self._mode) @mode.setter def mode(self, mode): mode = self._mode_setter_helper(mode) if mode == self._mode: return self._mode = mode self.events.mode(mode=str(mode)) @classmethod def _basename(cls): return f'{cls.__name__}' @property def name(self): """str: Unique name of the layer.""" return self._name @name.setter def name(self, name): if name == self.name: return if not name: name = self._basename() self._name = str(name) self.events.name() @property def metadata(self) -> dict: """Key/value map for user-stored data.""" return self._metadata @metadata.setter def metadata(self, value: dict) -> None: self._metadata.clear() self._metadata.update(value) @property def source(self): return self._source @property def loaded(self) -> bool: """Return True if this layer is fully loaded in memory. This base class says that layers are permanently in the loaded state. Derived classes that do asynchronous loading can override this. """ return True @property def opacity(self): """float: Opacity value between 0.0 and 1.0.""" return self._opacity @opacity.setter def opacity(self, opacity): if not 0.0 <= opacity <= 1.0: raise ValueError( trans._( 'opacity must be between 0.0 and 1.0; got {opacity}', deferred=True, opacity=opacity, ) ) self._opacity = opacity self._update_thumbnail() self.events.opacity() @property def blending(self): """Blending mode: Determines how RGB and alpha values get mixed. Blending.OPAQUE Allows for only the top layer to be visible and corresponds to depth_test=True, cull_face=False, blend=False. Blending.TRANSLUCENT Allows for multiple layers to be blended with different opacity and corresponds to depth_test=True, cull_face=False, blend=True, blend_func=('src_alpha', 'one_minus_src_alpha'), and blend_equation=('func_add'). Blending.TRANSLUCENT_NO_DEPTH Allows for multiple layers to be blended with different opacity, but no depth testing is performed. Corresponds to ``depth_test=False``, cull_face=False, blend=True, blend_func=('src_alpha', 'one_minus_src_alpha'), and blend_equation=('func_add'). Blending.ADDITIVE Allows for multiple layers to be blended together with different colors and opacity. Useful for creating overlays. It corresponds to depth_test=False, cull_face=False, blend=True, blend_func=('src_alpha', 'one'), and blend_equation=('func_add'). Blending.MINIMUM Allows for multiple layers to be blended together such that the minimum of each RGB component and alpha are selected. Useful for creating overlays with inverted colormaps. It corresponds to depth_test=False, cull_face=False, blend=True, blend_equation=('min'). """ return str(self._blending) @blending.setter def blending(self, blending): self._blending = Blending(blending) self.events.blending() @property def visible(self) -> bool: """bool: Whether the visual is currently being displayed.""" return self._visible @visible.setter def visible(self, visible: bool): self._visible = visible self.refresh() self.events.visible() @property def editable(self) -> bool: """bool: Whether the current layer data is editable from the viewer.""" return self._editable @editable.setter def editable(self, editable: bool): if self._editable == editable: return self._editable = editable self._on_editable_changed() self.events.editable() def _reset_editable(self) -> None: """Reset this layer's editable state based on layer properties.""" self.editable = True def _on_editable_changed(self) -> None: """Executes side-effects on this layer related to changes of the editable state.""" pass @property def scale(self): """list: Anisotropy factors to scale data into world coordinates.""" return self._transforms['data2physical'].scale @scale.setter def scale(self, scale): if scale is None: scale = [1] * self.ndim self._transforms['data2physical'].scale = np.array(scale) self._clear_extent() self.events.scale() @property def translate(self): """list: Factors to shift the layer by in units of world coordinates.""" return self._transforms['data2physical'].translate @translate.setter def translate(self, translate): self._transforms['data2physical'].translate = np.array(translate) self._clear_extent() self.events.translate() @property def rotate(self): """array: Rotation matrix in world coordinates.""" return self._transforms['data2physical'].rotate @rotate.setter def rotate(self, rotate): self._transforms['data2physical'].rotate = rotate self._clear_extent() self.events.rotate() @property def shear(self): """array: Shear matrix in world coordinates.""" return self._transforms['data2physical'].shear @shear.setter def shear(self, shear): self._transforms['data2physical'].shear = shear self._clear_extent() self.events.shear() @property def affine(self): """napari.utils.transforms.Affine: Extra affine transform to go from physical to world coordinates.""" return self._transforms['physical2world'] @affine.setter def affine(self, affine): # Assignment by transform name is not supported by TransformChain and # EventedList, so use the integer index instead. For more details, see: # https://github.com/napari/napari/issues/3058 self._transforms[2] = coerce_affine( affine, ndim=self.ndim, name='physical2world' ) self._clear_extent() self.events.affine() @property def translate_grid(self): warnings.warn( trans._( "translate_grid will become private in v0.4.14. See Layer.translate or Layer.data_to_world() instead.", ), DeprecationWarning, stacklevel=2, ) return self._translate_grid @translate_grid.setter def translate_grid(self, translate_grid): warnings.warn( trans._( "translate_grid will become private in v0.4.14. See Layer.translate or Layer.data_to_world() instead.", ), DeprecationWarning, stacklevel=2, ) self._translate_grid = translate_grid @property def _translate_grid(self): """list: Factors to shift the layer by.""" return self._transforms['world2grid'].translate @_translate_grid.setter def _translate_grid(self, translate_grid): if np.all(self._translate_grid == translate_grid): return self._transforms['world2grid'].translate = np.array(translate_grid) self.events.translate() @property def _is_moving(self): return self._private_is_moving @_is_moving.setter def _is_moving(self, value): assert value in (True, False) if value: assert self._moving_coordinates is not None self._private_is_moving = value def _update_dims(self): """Update the dimensionality of transforms and slices when data changes.""" ndim = self._get_ndim() old_ndim = self._ndim if old_ndim > ndim: keep_axes = range(old_ndim - ndim, old_ndim) self._transforms = self._transforms.set_slice(keep_axes) elif old_ndim < ndim: new_axes = range(ndim - old_ndim) self._transforms = self._transforms.expand_dims(new_axes) self._slice_input = self._slice_input.with_ndim(ndim) self._ndim = ndim self._clear_extent() @property @abstractmethod def data(self): # user writes own docstring raise NotImplementedError() @data.setter @abstractmethod def data(self, data): raise NotImplementedError() @property @abstractmethod def _extent_data(self) -> np.ndarray: """Extent of layer in data coordinates. Returns ------- extent_data : array, shape (2, D) """ raise NotImplementedError() @property def _extent_world(self) -> np.ndarray: """Range of layer in world coordinates. Returns ------- extent_world : array, shape (2, D) """ # Get full nD bounding box return get_extent_world( self._extent_data, self._data_to_world, self._array_like ) @cached_property def extent(self) -> Extent: """Extent of layer in data and world coordinates.""" extent_data = self._extent_data data_to_world = self._data_to_world extent_world = get_extent_world( extent_data, data_to_world, self._array_like ) return Extent( data=extent_data, world=extent_world, step=abs(data_to_world.scale), ) def _clear_extent(self): """Clears the cached extent. This should be called whenever this data or transform information changes, and should be called before any related events get emitted so that they use the updated extent values. """ if 'extent' in self.__dict__: del self.extent self.events.extent() self.refresh() @property def _slice_indices(self): """(D, ) array: Slice indices in data coordinates.""" if len(self._slice_input.not_displayed) == 0: # All dims are displayed dimensions return (slice(None),) * self.ndim return self._slice_input.data_indices( self._data_to_world.inverse, getattr(self, '_round_index', True), ) @abstractmethod def _get_ndim(self): raise NotImplementedError() def _get_base_state(self): """Get dictionary of attributes on base layer. Returns ------- state : dict Dictionary of attributes on base layer. """ base_dict = { 'name': self.name, 'metadata': self.metadata, 'scale': list(self.scale), 'translate': list(self.translate), 'rotate': [list(r) for r in self.rotate], 'shear': list(self.shear), 'affine': self.affine.affine_matrix, 'opacity': self.opacity, 'blending': self.blending, 'visible': self.visible, 'experimental_clipping_planes': [ plane.dict() for plane in self.experimental_clipping_planes ], } return base_dict @abstractmethod def _get_state(self): raise NotImplementedError() @property def _type_string(self): return self.__class__.__name__.lower() def as_layer_data_tuple(self): state = self._get_state() state.pop('data', None) return self.data, state, self._type_string @property def thumbnail(self): """array: Integer array of thumbnail for the layer""" return self._thumbnail @thumbnail.setter def thumbnail(self, thumbnail): if 0 in thumbnail.shape: thumbnail = np.zeros(self._thumbnail_shape, dtype=np.uint8) if thumbnail.dtype != np.uint8: with warnings.catch_warnings(): warnings.simplefilter("ignore") thumbnail = convert_to_uint8(thumbnail) padding_needed = np.subtract(self._thumbnail_shape, thumbnail.shape) pad_amounts = [(p // 2, (p + 1) // 2) for p in padding_needed] thumbnail = np.pad(thumbnail, pad_amounts, mode='constant') # blend thumbnail with opaque black background background = np.zeros(self._thumbnail_shape, dtype=np.uint8) background[..., 3] = 255 f_dest = thumbnail[..., 3][..., None] / 255 f_source = 1 - f_dest thumbnail = thumbnail * f_dest + background * f_source self._thumbnail = thumbnail.astype(np.uint8) self.events.thumbnail() @property def ndim(self): """int: Number of dimensions in the data.""" return self._ndim @property def help(self): """str: displayed in status bar bottom right.""" return self._help @help.setter def help(self, help): if help == self.help: return self._help = help self.events.help(help=help) @property def interactive(self): """bool: Determine if canvas pan/zoom interactivity is enabled.""" return self._interactive @interactive.setter def interactive(self, interactive): if interactive == self._interactive: return self._interactive = interactive self.events.interactive(interactive=interactive) @property def cursor(self): """str: String identifying cursor displayed over canvas.""" return self._cursor @cursor.setter def cursor(self, cursor): if cursor == self.cursor: return self._cursor = cursor self.events.cursor(cursor=cursor) @property def cursor_size(self): """int | None: Size of cursor if custom. None yields default size.""" return self._cursor_size @cursor_size.setter def cursor_size(self, cursor_size): if cursor_size == self.cursor_size: return self._cursor_size = cursor_size self.events.cursor_size(cursor_size=cursor_size) @property def experimental_clipping_planes(self): return self._experimental_clipping_planes @experimental_clipping_planes.setter def experimental_clipping_planes( self, value: Union[ dict, ClippingPlane, List[Union[ClippingPlane, dict]], ClippingPlaneList, ], ): self._experimental_clipping_planes.clear() if value is None: return if isinstance(value, (ClippingPlane, dict)): value = [value] for new_plane in value: plane = ClippingPlane() plane.update(new_plane) self._experimental_clipping_planes.append(plane) @property def bounding_box(self): return self._overlays['bounding_box'] def set_view_slice(self): with self.dask_optimized_slicing(): self._set_view_slice() @abstractmethod def _set_view_slice(self): raise NotImplementedError() def _slice_dims(self, point=None, ndisplay=2, order=None): """Slice data with values from a global dims model. Note this will likely be moved off the base layer soon. Parameters ---------- point : list Values of data to slice at in world coordinates. ndisplay : int Number of dimensions to be displayed. order : list of int Order of dimensions, where last `ndisplay` will be rendered in canvas. """ slice_input = self._make_slice_input(point, ndisplay, order) if self._slice_input == slice_input: return self._slice_input = slice_input self.refresh() self._reset_editable() def _make_slice_input( self, point=None, ndisplay=2, order=None ) -> _SliceInput: if point is None: point = (0,) * self.ndim else: point = tuple(point) ndim = len(point) if order is None: order = tuple(range(ndim)) # Correspondence between dimensions across all layers and # dimensions of this layer. point = point[-self.ndim :] order = tuple( self._world_to_layer_dims(world_dims=order, ndim_world=ndim) ) return _SliceInput( ndisplay=ndisplay, point=point, order=order, ) @abstractmethod def _update_thumbnail(self): raise NotImplementedError() @abstractmethod def _get_value(self, position): """Value of the data at a position in data coordinates. Parameters ---------- position : tuple Position in data coordinates. Returns ------- value : tuple Value of the data. """ raise NotImplementedError() def get_value( self, position: Tuple[float], *, view_direction: Optional[np.ndarray] = None, dims_displayed: Optional[List[int]] = None, world=False, ): """Value of the data at a position. If the layer is not visible, return None. Parameters ---------- position : tuple of float Position in either data or world coordinates. view_direction : Optional[np.ndarray] A unit vector giving the direction of the ray in nD world coordinates. The default value is None. dims_displayed : Optional[List[int]] A list of the dimensions currently being displayed in the viewer. The default value is None. world : bool If True the position is taken to be in world coordinates and converted into data coordinates. False by default. Returns ------- value : tuple, None Value of the data. If the layer is not visible return None. """ if self.visible: if world: ndim_world = len(position) if dims_displayed is not None: # convert the dims_displayed to the layer dims.This accounts # for differences in the number of dimensions in the world # dims versus the layer and for transpose and rolls. dims_displayed = dims_displayed_world_to_layer( dims_displayed, ndim_world=ndim_world, ndim_layer=self.ndim, ) position = self.world_to_data(position) if (dims_displayed is not None) and (view_direction is not None): if len(dims_displayed) == 2 or self.ndim == 2: value = self._get_value(position=tuple(position)) elif len(dims_displayed) == 3: view_direction = self._world_to_data_ray( list(view_direction) ) start_point, end_point = self.get_ray_intersections( position=position, view_direction=view_direction, dims_displayed=dims_displayed, world=False, ) value = self._get_value_3d( start_point=start_point, end_point=end_point, dims_displayed=dims_displayed, ) else: value = self._get_value(position) else: value = None # This should be removed as soon as possible, it is still # used in Points and Shapes. self._value = value return value def _get_value_3d( self, start_point: np.ndarray, end_point: np.ndarray, dims_displayed: List[int], ) -> Union[float, int]: """Get the layer data value along a ray Parameters ---------- start_point : np.ndarray The start position of the ray used to interrogate the data. end_point : np.ndarray The end position of the ray used to interrogate the data. dims_displayed : List[int] The indices of the dimensions currently displayed in the Viewer. Returns ------- value The data value along the supplied ray. """ return None def projected_distance_from_mouse_drag( self, start_position: np.ndarray, end_position: np.ndarray, view_direction: np.ndarray, vector: np.ndarray, dims_displayed: Union[List, np.ndarray], ): """Calculate the length of the projection of a line between two mouse clicks onto a vector (or array of vectors) in data coordinates. Parameters ---------- start_position : np.ndarray Starting point of the drag vector in data coordinates end_position : np.ndarray End point of the drag vector in data coordinates view_direction : np.ndarray Vector defining the plane normal of the plane onto which the drag vector is projected. vector : np.ndarray (3,) unit vector or (n, 3) array thereof on which to project the drag vector from start_event to end_event. This argument is defined in data coordinates. dims_displayed : Union[List, np.ndarray] (3,) list of currently displayed dimensions Returns ------- projected_distance : (1, ) or (n, ) np.ndarray of float """ start_position = self._world_to_displayed_data( start_position, dims_displayed ) end_position = self._world_to_displayed_data( end_position, dims_displayed ) view_direction = self._world_to_displayed_data_ray( view_direction, dims_displayed ) return drag_data_to_projected_distance( start_position, end_position, view_direction, vector ) @contextmanager def block_update_properties(self): previous = self._update_properties self._update_properties = False try: yield finally: self._update_properties = previous def _set_highlight(self, force=False): """Render layer highlights when appropriate. Parameters ---------- force : bool Bool that forces a redraw to occur when `True`. """ pass def refresh(self, event=None): """Refresh all layer data based on current view slice.""" if self.visible: self.set_view_slice() self.events.set_data() self._update_thumbnail() self._set_highlight(force=True) def world_to_data(self, position): """Convert from world coordinates to data coordinates. Parameters ---------- position : tuple, list, 1D array Position in world coordinates. If longer then the number of dimensions of the layer, the later dimensions will be used. Returns ------- tuple Position in data coordinates. """ if len(position) >= self.ndim: coords = list(position[-self.ndim :]) else: coords = [0] * (self.ndim - len(position)) + list(position) return tuple(self._transforms[1:].simplified.inverse(coords)) def data_to_world(self, position): """Convert from data coordinates to world coordinates. Parameters ---------- position : tuple, list, 1D array Position in data coordinates. If longer then the number of dimensions of the layer, the later dimensions will be used. Returns ------- tuple Position in world coordinates. """ if len(position) >= self.ndim: coords = list(position[-self.ndim :]) else: coords = [0] * (self.ndim - len(position)) + list(position) return tuple(self._transforms[1:].simplified(coords)) def _world_to_displayed_data( self, position: np.ndarray, dims_displayed: np.ndarray ) -> tuple: """Convert world to data coordinates for displayed dimensions only. Parameters ---------- position : tuple, list, 1D array Position in world coordinates. If longer then the number of dimensions of the layer, the later dimensions will be used. dims_displayed : list, 1D array Indices of displayed dimensions of the data. Returns ------- tuple Position in data coordinates for the displayed dimensions only """ position_nd = self.world_to_data(position) position_ndisplay = np.asarray(position_nd)[dims_displayed] return tuple(position_ndisplay) @property def _data_to_world(self) -> Affine: """The transform from data to world coordinates. This affine transform is composed from the affine property and the other transform properties in the following order: affine * (rotate * shear * scale + translate) """ return self._transforms[1:3].simplified def _world_to_data_ray(self, vector) -> tuple: """Convert a vector defining an orientation from world coordinates to data coordinates. For example, this would be used to convert the view ray. Parameters ---------- vector : tuple, list, 1D array A vector in world coordinates. Returns ------- tuple Vector in data coordinates. """ p1 = np.asarray(self.world_to_data(vector)) p0 = np.asarray(self.world_to_data(np.zeros_like(vector))) normalized_vector = (p1 - p0) / np.linalg.norm(p1 - p0) return tuple(normalized_vector) def _world_to_displayed_data_ray( self, vector_world, dims_displayed ) -> np.ndarray: """Convert an orientation from world to displayed data coordinates. For example, this would be used to convert the view ray. Parameters ---------- vector_world : tuple, list, 1D array A vector in world coordinates. Returns ------- tuple Vector in data coordinates. """ vector_data_nd = np.asarray(self._world_to_data_ray(vector_world)) vector_data_ndisplay = vector_data_nd[dims_displayed] vector_data_ndisplay /= np.linalg.norm(vector_data_ndisplay) return vector_data_ndisplay def _world_to_layer_dims( self, *, world_dims: List[int], ndim_world: int ) -> List[int]: """Map world dimensions to layer dimensions while maintaining order. This is used to map dimensions from the full world space defined by ``Dims`` to the subspace that a layer inhabits, so that those can be used to index the layer's data and associated coordinates. For example a world ``Dims.order`` of [2, 1, 0, 3] would map to [0, 1] for a layer with two dimensions and [1, 0, 2] for a layer with three dimensions as those correspond to the relative order of the last two and three world dimensions respectively. Parameters ---------- world_dims : List[int] The world dimensions. ndim_world : int The number of dimensions in the world coordinate system. Returns ------- List[int] The corresponding layer dimensions with the same ordering as the given world dimensions. """ offset = ndim_world - self.ndim order = np.array(world_dims) if offset <= 0: return list(range(-offset)) + list(order - offset) else: return list(order[order >= offset] - offset) def _display_bounding_box(self, dims_displayed: np.ndarray): """An axis aligned (ndisplay, 2) bounding box around the data""" return self._extent_data[:, dims_displayed].T def click_plane_from_click_data( self, click_position: np.ndarray, view_direction: np.ndarray, dims_displayed: List, ) -> Tuple[np.ndarray, np.ndarray]: """Calculate a (point, normal) plane parallel to the canvas in data coordinates, centered on the centre of rotation of the camera. Parameters ---------- click_position : np.ndarray click position in world coordinates from mouse event. view_direction : np.ndarray view direction in world coordinates from mouse event. dims_displayed : List dimensions of the data array currently in view. Returns ------- click_plane : Tuple[np.ndarray, np.ndarray] tuple of (plane_position, plane_normal) in data coordinates. """ click_position = np.asarray(click_position) view_direction = np.asarray(view_direction) plane_position = self.world_to_data(click_position)[dims_displayed] plane_normal = self._world_to_data_ray(view_direction)[dims_displayed] return plane_position, plane_normal def get_ray_intersections( self, position: List[float], view_direction: np.ndarray, dims_displayed: List[int], world: bool = True, ) -> Union[Tuple[np.ndarray, np.ndarray], Tuple[None, None]]: """Get the start and end point for the ray extending from a point through the data bounding box. Parameters ---------- position the position of the point in nD coordinates. World vs. data is set by the world keyword argument. view_direction : np.ndarray a unit vector giving the direction of the ray in nD coordinates. World vs. data is set by the world keyword argument. dims_displayed a list of the dimensions currently being displayed in the viewer. world : bool True if the provided coordinates are in world coordinates. Default value is True. Returns ------- start_point : np.ndarray The point on the axis-aligned data bounding box that the cursor click intersects with. This is the point closest to the camera. The point is the full nD coordinates of the layer data. If the click does not intersect the axis-aligned data bounding box, None is returned. end_point : np.ndarray The point on the axis-aligned data bounding box that the cursor click intersects with. This is the point farthest from the camera. The point is the full nD coordinates of the layer data. If the click does not intersect the axis-aligned data bounding box, None is returned. """ if len(dims_displayed) != 3: return None, None # create the bounding box in data coordinates bounding_box = self._display_bounding_box(dims_displayed) start_point, end_point = self._get_ray_intersections( position=position, view_direction=view_direction, dims_displayed=dims_displayed, world=world, bounding_box=bounding_box, ) return start_point, end_point def _get_offset_data_position(self, position: List[float]) -> List[float]: """Adjust position for offset between viewer and data coordinates.""" return position def _get_ray_intersections( self, position: List[float], view_direction: np.ndarray, dims_displayed: List[int], world: bool = True, bounding_box: Optional[np.ndarray] = None, ) -> Union[Tuple[np.ndarray, np.ndarray], Tuple[None, None]]: """Get the start and end point for the ray extending from a point through the data bounding box. Parameters ---------- position the position of the point in nD coordinates. World vs. data is set by the world keyword argument. view_direction : np.ndarray a unit vector giving the direction of the ray in nD coordinates. World vs. data is set by the world keyword argument. dims_displayed a list of the dimensions currently being displayed in the viewer. world : bool True if the provided coordinates are in world coordinates. Default value is True. bounding_box : np.ndarray A (2, 3) bounding box around the data currently in view Returns ------- start_point : np.ndarray The point on the axis-aligned data bounding box that the cursor click intersects with. This is the point closest to the camera. The point is the full nD coordinates of the layer data. If the click does not intersect the axis-aligned data bounding box, None is returned. end_point : np.ndarray The point on the axis-aligned data bounding box that the cursor click intersects with. This is the point farthest from the camera. The point is the full nD coordinates of the layer data. If the click does not intersect the axis-aligned data bounding box, None is returned.""" # get the view direction and click position in data coords # for the displayed dimensions only if world is True: view_dir = self._world_to_displayed_data_ray( view_direction, dims_displayed ) click_pos_data = self._world_to_displayed_data( position, dims_displayed ) else: # adjust for any offset between viewer and data coordinates position = self._get_offset_data_position(position) view_dir = np.asarray(view_direction)[dims_displayed] click_pos_data = np.asarray(position)[dims_displayed] # Determine the front and back faces front_face_normal, back_face_normal = find_front_back_face( click_pos_data, bounding_box, view_dir ) if front_face_normal is None and back_face_normal is None: # click does not intersect the data bounding box return None, None # Calculate ray-bounding box face intersections start_point_displayed_dimensions = ( intersect_line_with_axis_aligned_bounding_box_3d( click_pos_data, view_dir, bounding_box, front_face_normal ) ) end_point_displayed_dimensions = ( intersect_line_with_axis_aligned_bounding_box_3d( click_pos_data, view_dir, bounding_box, back_face_normal ) ) # add the coordinates for the axes not displayed start_point = np.asarray(position) start_point[dims_displayed] = start_point_displayed_dimensions end_point = np.asarray(position) end_point[dims_displayed] = end_point_displayed_dimensions return start_point, end_point def _update_draw( self, scale_factor, corner_pixels_displayed, shape_threshold ): """Update canvas scale and corner values on draw. For layer multiscale determining if a new resolution level or tile is required. Parameters ---------- scale_factor : float Scale factor going from canvas to world coordinates. corner_pixels_displayed : array, shape (2, 2) Coordinates of the top-left and bottom-right canvas pixels in world coordinates. shape_threshold : tuple Requested shape of field of view in data coordinates. """ self.scale_factor = scale_factor displayed_axes = self._slice_input.displayed # we need to compute all four corners to compute a complete, # data-aligned bounding box, because top-left/bottom-right may not # remain top-left and bottom-right after transformations. all_corners = list(itertools.product(*corner_pixels_displayed.T)) # Note that we ignore the first transform which is tile2data data_corners = ( self._transforms[1:] .simplified.set_slice(displayed_axes) .inverse(all_corners) ) # find the maximal data-axis-aligned bounding box containing all four # canvas corners and round them to ints data_bbox = np.stack( [np.min(data_corners, axis=0), np.max(data_corners, axis=0)] ) data_bbox_int = np.stack( [np.floor(data_bbox[0]), np.ceil(data_bbox[1])] ).astype(int) if self._slice_input.ndisplay == 2 and self.multiscale: level, scaled_corners = compute_multiscale_level_and_corners( data_bbox_int, shape_threshold, self.downsample_factors[:, displayed_axes], ) corners = np.zeros((2, self.ndim), dtype=int) # The corner_pixels attribute stores corners in the data # space of the selected level. Using the level's data # shape only works for images, but that's the only case we # handle now and downsample_factors is also only on image layers. max_coords = np.take(self.data[level].shape, displayed_axes) corners[:, displayed_axes] = np.clip(scaled_corners, 0, max_coords) display_shape = tuple( corners[1, displayed_axes] - corners[0, displayed_axes] ) if any(s == 0 for s in display_shape): return if self.data_level != level or not np.all( self.corner_pixels == corners ): self._data_level = level self.corner_pixels = corners self.refresh() else: # The stored corner_pixels attribute must contain valid indices. corners = np.zeros((2, self.ndim), dtype=int) # Some empty layers (e.g. Points) may have a data extent that only # contains nans, in which case the integer valued corner pixels # cannot be meaningfully set. displayed_extent = self.extent.data[:, displayed_axes] if not np.all(np.isnan(displayed_extent)): data_bbox_clipped = np.clip( data_bbox_int, displayed_extent[0], displayed_extent[1] ) corners[:, displayed_axes] = data_bbox_clipped self.corner_pixels = corners def _get_source_info(self): components = {} if self.source.reader_plugin: components['layer_base'] = os.path.basename(self.source.path or '') components['source_type'] = 'plugin' try: components['plugin'] = pm.get_manifest( self.source.reader_plugin ).display_name except KeyError: components['plugin'] = self.source.reader_plugin return components elif self.source.sample: components['layer_base'] = self.name components['source_type'] = 'sample' try: components['plugin'] = pm.get_manifest( self.source.sample[0] ).display_name except KeyError: components['plugin'] = self.source.sample[0] return components elif self.source.widget: components['layer_base'] = self.name components['source_type'] = 'widget' components['plugin'] = self.source.widget._function.__name__ return components else: components['layer_base'] = self.name components['source_type'] = '' components['plugin'] = '' return components def get_source_str(self): source_info = self._get_source_info() return ( source_info['layer_base'] + ', ' + source_info['source_type'] + ' : ' + source_info['plugin'] ) def get_status( self, position: Optional[Tuple[float, ...]] = None, *, view_direction: Optional[np.ndarray] = None, dims_displayed: Optional[List[int]] = None, world=False, ): """ Status message information of the data at a coordinate position. Parameters ---------- position : tuple of float Position in either data or world coordinates. view_direction : Optional[np.ndarray] A unit vector giving the direction of the ray in nD world coordinates. The default value is None. dims_displayed : Optional[List[int]] A list of the dimensions currently being displayed in the viewer. The default value is None. world : bool If True the position is taken to be in world coordinates and converted into data coordinates. False by default. Returns ------- source_info : dict Dictionary containing a information that can be used as a status update. """ if position is not None: value = self.get_value( position, view_direction=view_direction, dims_displayed=dims_displayed, world=world, ) else: value = None source_info = self._get_source_info() source_info['coordinates'] = generate_layer_coords_status( position[-self.ndim :], value ) return source_info def _get_tooltip_text( self, position, *, view_direction: Optional[np.ndarray] = None, dims_displayed: Optional[List[int]] = None, world: bool = False, ): """ tooltip message of the data at a coordinate position. Parameters ---------- position : tuple Position in either data or world coordinates. view_direction : Optional[np.ndarray] A unit vector giving the direction of the ray in nD world coordinates. The default value is None. dims_displayed : Optional[List[int]] A list of the dimensions currently being displayed in the viewer. The default value is None. world : bool If True the position is taken to be in world coordinates and converted into data coordinates. False by default. Returns ------- msg : string String containing a message that can be used as a tooltip. """ return "" def save(self, path: str, plugin: Optional[str] = None) -> List[str]: """Save this layer to ``path`` with default (or specified) plugin. Parameters ---------- path : str A filepath, directory, or URL to open. Extensions may be used to specify output format (provided a plugin is available for the requested format). plugin : str, optional Name of the plugin to use for saving. If ``None`` then all plugins corresponding to appropriate hook specification will be looped through to find the first one that can save the data. Returns ------- list of str File paths of any files that were written. """ from napari.plugins.io import save_layers return save_layers(path, [self], plugin=plugin) def _on_selection(self, selected: bool): # This method is a temporary workaround to the fact that the Points # layer needs to know when its selection state changes so that it can # update the highlight state. This, along with the events.select and # events.deselect emitters, (and the LayerList._on_selection_event # method) can be removed once highlighting logic has been removed from # the layer model. if selected: self.events.select() else: self.events.deselect() @classmethod def create( cls, data, meta: dict = None, layer_type: Optional[str] = None ) -> Layer: """Create layer from `data` of type `layer_type`. Primarily intended for usage by reader plugin hooks and creating a layer from an unwrapped layer data tuple. Parameters ---------- data : Any Data in a format that is valid for the corresponding `layer_type`. meta : dict, optional Dict of keyword arguments that will be passed to the corresponding layer constructor. If any keys in `meta` are not valid for the corresponding layer type, an exception will be raised. layer_type : str Type of layer to add. Must be the (case insensitive) name of a Layer subclass. If not provided, the layer is assumed to be "image", unless data.dtype is one of (np.int32, np.uint32, np.int64, np.uint64), in which case it is assumed to be "labels". Raises ------ ValueError If ``layer_type`` is not one of the recognized layer types. TypeError If any keyword arguments in ``meta`` are unexpected for the corresponding `add_*` method for this layer_type. Examples -------- A typical use case might be to upack a tuple of layer data with a specified layer_type. >>> data = ( ... np.random.random((10, 2)) * 20, ... {'face_color': 'blue'}, ... 'points', ... ) >>> Layer.create(*data) """ from napari import layers from napari.layers.image._image_utils import guess_labels layer_type = (layer_type or '').lower() # assumes that big integer type arrays are likely labels. if not layer_type: layer_type = guess_labels(data) if layer_type not in layers.NAMES: raise ValueError( trans._( "Unrecognized layer_type: '{layer_type}'. Must be one of: {layer_names}.", deferred=True, layer_type=layer_type, layer_names=layers.NAMES, ) ) Cls = getattr(layers, layer_type.title()) try: return Cls(data, **(meta or {})) except Exception as exc: # noqa: BLE001 if 'unexpected keyword argument' not in str(exc): raise exc bad_key = str(exc).split('keyword argument ')[-1] raise TypeError( trans._( "_add_layer_from_data received an unexpected keyword argument ({bad_key}) for layer type {layer_type}", deferred=True, bad_key=bad_key, layer_type=layer_type, ) ) from exc mgui.register_type(type_=List[Layer], return_callback=add_layers_to_viewer) napari-0.5.0a1/napari/layers/image/000077500000000000000000000000001437041365600170545ustar00rootroot00000000000000napari-0.5.0a1/napari/layers/image/__init__.py000066400000000000000000000005221437041365600211640ustar00rootroot00000000000000from napari.layers.image import _image_key_bindings from napari.layers.image.image import Image # Note that importing _image_key_bindings is needed as the Image layer gets # decorated with keybindings during that process, but it is not directly needed # by our users and so is deleted below del _image_key_bindings __all__ = ['Image'] napari-0.5.0a1/napari/layers/image/_image_constants.py000066400000000000000000000052171437041365600227500ustar00rootroot00000000000000from collections import OrderedDict from enum import auto from napari.utils.misc import StringEnum from napari.utils.translations import trans class Interpolation(StringEnum): """INTERPOLATION: Vispy interpolation mode. The spatial filters used for interpolation are from vispy's spatial filters. The filters are built in the file below: https://github.com/vispy/vispy/blob/main/vispy/glsl/build-spatial-filters.py """ BESSEL = auto() CUBIC = auto() LINEAR = auto() BLACKMAN = auto() CATROM = auto() GAUSSIAN = auto() HAMMING = auto() HANNING = auto() HERMITE = auto() KAISER = auto() LANCZOS = auto() MITCHELL = auto() NEAREST = auto() SPLINE16 = auto() SPLINE36 = auto() @classmethod def view_subset(cls): return ( cls.CUBIC, cls.LINEAR, cls.KAISER, cls.NEAREST, cls.SPLINE36, ) class ImageRendering(StringEnum): """Rendering: Rendering mode for the layer. Selects a preset rendering mode in vispy * translucent: voxel colors are blended along the view ray until the result is opaque. * mip: maximum intensity projection. Cast a ray and display the maximum value that was encountered. * minip: minimum intensity projection. Cast a ray and display the minimum value that was encountered. * attenuated_mip: attenuated maximum intensity projection. Cast a ray and attenuate values based on integral of encountered values, display the maximum value that was encountered after attenuation. This will make nearer objects appear more prominent. * additive: voxel colors are added along the view ray until the result is saturated. * iso: isosurface. Cast a ray until a certain threshold is encountered. At that location, lighning calculations are performed to give the visual appearance of a surface. * average: average intensity projection. Cast a ray and display the average of values that were encountered. """ TRANSLUCENT = auto() ADDITIVE = auto() ISO = auto() MIP = auto() MINIP = auto() ATTENUATED_MIP = auto() AVERAGE = auto() class VolumeDepiction(StringEnum): """Depiction: 3D depiction mode for images. Selects a preset depiction mode in vispy * volume: images are rendered as 3D volumes. * plane: images are rendered as 2D planes embedded in 3D. """ VOLUME = auto() PLANE = auto() VOLUME_DEPICTION_TRANSLATION = OrderedDict( [ (VolumeDepiction.VOLUME, trans._('volume')), (VolumeDepiction.PLANE, trans._('plane')), ] ) napari-0.5.0a1/napari/layers/image/_image_key_bindings.py000066400000000000000000000051721437041365600234010ustar00rootroot00000000000000from __future__ import annotations from app_model.types import KeyCode import napari from napari.layers.base._base_constants import Mode from napari.layers.image.image import Image from napari.layers.utils.interactivity_utils import ( orient_plane_normal_around_cursor, ) from napari.layers.utils.layer_utils import register_layer_action from napari.utils.translations import trans def register_image_action(description: str, repeatable: bool = False): return register_layer_action(Image, description, repeatable) @Image.bind_key(KeyCode.KeyZ) @register_image_action(trans._('Orient plane normal along z-axis')) def orient_plane_normal_along_z(layer: Image): orient_plane_normal_around_cursor(layer, plane_normal=(1, 0, 0)) @Image.bind_key(KeyCode.KeyY) @register_image_action(trans._('orient plane normal along y-axis')) def orient_plane_normal_along_y(layer: Image): orient_plane_normal_around_cursor(layer, plane_normal=(0, 1, 0)) @Image.bind_key(KeyCode.KeyX) @register_image_action(trans._('orient plane normal along x-axis')) def orient_plane_normal_along_x(layer: Image): orient_plane_normal_around_cursor(layer, plane_normal=(0, 0, 1)) @Image.bind_key(KeyCode.KeyO) @register_image_action(trans._('orient plane normal along view direction')) def orient_plane_normal_along_view_direction(layer: Image): viewer = napari.viewer.current_viewer() if viewer.dims.ndisplay != 3: return def sync_plane_normal_with_view_direction(event=None): """Plane normal syncronisation mouse callback.""" layer.plane.normal = layer._world_to_displayed_data_ray( viewer.camera.view_direction, [-3, -2, -1] ) # update plane normal and add callback to mouse drag sync_plane_normal_with_view_direction() viewer.camera.events.angles.connect(sync_plane_normal_with_view_direction) yield # remove callback on key release viewer.camera.events.angles.disconnect( sync_plane_normal_with_view_direction ) @Image.bind_key(KeyCode.Space) def hold_to_pan_zoom(layer): """Hold to pan and zoom in the viewer.""" if layer._mode != Mode.PAN_ZOOM: # on key press prev_mode = layer.mode layer.mode = Mode.PAN_ZOOM yield # on key release layer.mode = prev_mode @register_image_action(trans._('Transform')) def activate_image_transform_mode(layer): layer.mode = Mode.TRANSFORM @register_image_action(trans._('Pan/zoom')) def activate_image_pan_zoom_mode(layer): layer.mode = Mode.PAN_ZOOM image_fun_to_mode = [ (activate_image_pan_zoom_mode, Mode.PAN_ZOOM), (activate_image_transform_mode, Mode.TRANSFORM), ] napari-0.5.0a1/napari/layers/image/_image_loader.py000066400000000000000000000016101437041365600221730ustar00rootroot00000000000000"""ImageLoader class. """ from napari.layers.image._image_slice_data import ImageSliceData class ImageLoader: """The default synchronous ImageLoader.""" def load(self, data: ImageSliceData) -> bool: """Load the ImageSliceData synchronously. Parameters ---------- data : ImageSliceData The data to load. Returns ------- bool True if load happened synchronously. """ data.load_sync() return True def match(self, data: ImageSliceData) -> bool: """Return True if data matches what we are loading. Parameters ---------- data : ImageSliceData Does this data match what we are loading? Returns ------- bool Return True if data matches. """ return True # Always true for synchronous loader. napari-0.5.0a1/napari/layers/image/_image_mouse_bindings.py000066400000000000000000000064641437041365600237460ustar00rootroot00000000000000from __future__ import annotations from typing import TYPE_CHECKING import numpy as np from napari.utils.geometry import ( clamp_point_to_bounding_box, point_in_bounding_box, ) if TYPE_CHECKING: from napari.layers.image.image import Image from napari.utils.events import Event def move_plane_along_normal(layer: Image, event: Event): """Move a layers slicing plane along its normal vector on click and drag.""" # early exit clauses if ( 'Shift' not in event.modifiers or layer.visible is False or layer.interactive is False or len(event.dims_displayed) < 3 ): return # Store mouse position at start of drag initial_position_world = np.asarray(event.position) initial_view_direction_world = np.asarray(event.view_direction) initial_position_data = layer._world_to_displayed_data( initial_position_world, event.dims_displayed ) initial_view_direction_data = layer._world_to_displayed_data_ray( initial_view_direction_world, event.dims_displayed ) # Calculate intersection of click with plane through data in data coordinates intersection = layer.plane.intersect_with_line( line_position=initial_position_data, line_direction=initial_view_direction_data, ) # Check if click was on plane and if not, exit early. if not point_in_bounding_box( intersection, layer.extent.data[:, event.dims_displayed] ): return layer.plane.position = intersection # Store original plane position and disable interactivity during plane drag original_plane_position = np.copy(layer.plane.position) layer.interactive = False yield while event.type == 'mouse_move': # Project mouse drag onto plane normal drag_distance = layer.projected_distance_from_mouse_drag( start_position=initial_position_world, end_position=np.asarray(event.position), view_direction=np.asarray(event.view_direction), vector=layer.plane.normal, dims_displayed=event.dims_displayed, ) # Calculate updated plane position updated_position = original_plane_position + ( drag_distance * np.array(layer.plane.normal) ) clamped_plane_position = clamp_point_to_bounding_box( updated_position, layer._display_bounding_box(event.dims_displayed) ) layer.plane.position = clamped_plane_position yield # Re-enable volume_layer interactivity after the drag layer.interactive = True def set_plane_position(layer: Image, event: Event): """Set plane position on double click.""" # early exit clauses if ( layer.visible is False or layer.interactive is False or len(event.dims_displayed) < 3 ): return # Calculate intersection of click with plane through data in data coordinates intersection = layer.plane.intersect_with_line( line_position=np.asarray(event.position)[event.dims_displayed], line_direction=np.asarray(event.view_direction)[event.dims_displayed], ) # Check if click was on plane and if not, exit early. if not point_in_bounding_box( intersection, layer.extent.data[:, event.dims_displayed] ): return layer.plane.position = intersection napari-0.5.0a1/napari/layers/image/_image_slice.py000066400000000000000000000103611437041365600220270ustar00rootroot00000000000000"""ImageSlice class. """ from __future__ import annotations import logging from typing import TYPE_CHECKING, Callable import numpy as np from napari.layers.image._image_loader import ImageLoader from napari.layers.image._image_slice_data import ImageSliceData from napari.layers.image._image_view import ImageView from napari.utils import config LOGGER = logging.getLogger("napari.loader") if TYPE_CHECKING: from napari.types import ArrayLike def _create_loader_class() -> ImageLoader: """Return correct ImageLoader for sync or async. Returns ------- ImageLoader Return ImageLoader for sync or ChunkImageLoader for async. """ if config.async_loading: from napari.layers.image.experimental._chunked_image_loader import ( ChunkedImageLoader, ) return ChunkedImageLoader() else: return ImageLoader() class ImageSlice: """The slice of the image that we are currently viewing. Parameters ---------- image : ArrayLike The initial image used as the image and the thumbnail source. image_converter : Callable[[ArrayLike], ArrayLike] ImageView uses this to convert from raw to viewable. rgb : bool True if the image is RGB format. Otherwise its RGBA. Attributes ---------- image : ImageView The main image for this slice. thumbnail : ImageView The source image used to compute the smaller thumbnail image. rgb : bool Is the image in RGB or RGBA format. loaded : bool Has the data for this slice been loaded yet. """ def __init__( self, image: ArrayLike, image_converter: Callable[[ArrayLike], ArrayLike], rgb: bool = False, ) -> None: LOGGER.debug("ImageSlice.__init__") self.image: ImageView = ImageView(image, image_converter) self.thumbnail: ImageView = ImageView(image, image_converter) self.rgb = rgb self.loader = _create_loader_class() # With async there can be a gap between when the ImageSlice is # created and the data is actually loaded. However initialize # as True in case we aren't even doing async loading. self.loaded = True def _set_raw_images( self, image: ArrayLike, thumbnail_source: ArrayLike ) -> None: """Set the image and its thumbnail. If floating point / grayscale then clip to [0..1]. Parameters ---------- image : ArrayLike Set this as the main image. thumbnail_source : ArrayLike Derive the thumbnail from this image. """ # Single scale images don't have a separate thumbnail so we just # use the image itself. if thumbnail_source is None: thumbnail_source = image if self.rgb and image.dtype.kind == 'f': image = np.clip(image, 0, 1) thumbnail_source = np.clip(thumbnail_source, 0, 1) self.image.raw = image # save a computation of view image if thumbnail and image is equal if thumbnail_source is image: self.thumbnail._raw = self.image._raw self.thumbnail._view = self.image._view else: self.thumbnail.raw = thumbnail_source def load(self, data: ImageSliceData) -> bool: """Load this data into the slice. Parameters ---------- data : ImageSliceData The data to load into this slice. Returns ------- bool Return True if load was synchronous. """ self.loaded = False # False until self._on_loaded is calls return self.loader.load(data) def on_loaded(self, data: ImageSliceData) -> bool: """Data was loaded, show the new data. Parameters ---------- data : ImageSliceData The newly loaded data we want to show. Returns ------- bool True if the data was used, False if was for the wrong slice. """ if not self.loader.match(data): return False # data was not used. # Display the newly loaded data. self._set_raw_images(data.image, data.thumbnail_source) self.loaded = True return True # data was used. napari-0.5.0a1/napari/layers/image/_image_slice_data.py000066400000000000000000000030471437041365600230230ustar00rootroot00000000000000"""ImageSliceData class. """ from __future__ import annotations from typing import TYPE_CHECKING, Optional, Tuple import numpy as np from napari.layers.base import Layer if TYPE_CHECKING: from napari.types import ArrayLike class ImageSliceData: """The contents of an ImageSlice. Parameters ---------- layer : Layer The layer that contains the data. indices : Tuple[Optional[slice], ...] The indices of this slice. image : ArrayList The image to display in the slice. thumbnail_source : ArrayList The source used to create the thumbnail for the slice. """ def __init__( self, layer: Layer, indices: Tuple[Optional[slice], ...], image: ArrayLike, thumbnail_source: ArrayLike, ) -> None: self.layer = layer self.indices = indices self.image = image self.thumbnail_source = thumbnail_source def load_sync(self) -> None: """Call asarray on our images to load them.""" self.image = np.asarray(self.image) if self.thumbnail_source is not None: self.thumbnail_source = np.asarray(self.thumbnail_source) def transpose(self, order: tuple) -> None: """Transpose our images. Parameters ---------- order : tuple Transpose the image into this order. """ self.image = np.transpose(self.image, order) if self.thumbnail_source is not None: self.thumbnail_source = np.transpose(self.thumbnail_source, order) napari-0.5.0a1/napari/layers/image/_image_utils.py000066400000000000000000000062371437041365600220770ustar00rootroot00000000000000"""guess_rgb, guess_multiscale, guess_labels. """ from typing import Tuple import numpy as np from napari.layers._data_protocols import LayerDataProtocol from napari.layers._multiscale_data import MultiScaleData from napari.utils.translations import trans def guess_rgb(shape): """Guess if the passed shape comes from rgb data. If last dim is 3 or 4 assume the data is rgb, including rgba. Parameters ---------- shape : list of int Shape of the data that should be checked. Returns ------- bool If data is rgb or not. """ ndim = len(shape) last_dim = shape[-1] return ndim > 2 and last_dim in (3, 4) def guess_multiscale(data) -> Tuple[bool, LayerDataProtocol]: """Guess whether the passed data is multiscale, process it accordingly. If shape of arrays along first axis is strictly decreasing, the data is multiscale. If it is the same shape everywhere, it is not. Various ambiguous conditions in between will result in a ValueError being raised, or in an "unwrapping" of data, if data contains only one element. Parameters ---------- data : array or list of array Data that should be checked. Returns ------- multiscale : bool True if the data is thought to be multiscale, False otherwise. data : list or array The input data, perhaps with the leading axis removed. """ # If the data has ndim and is not one-dimensional then cannot be multiscale # If data is a zarr array, this check ensure that subsets of it are not # instantiated. (`for d in data` instantiates `d` as a NumPy array if # `data` is a zarr array.) if isinstance(data, MultiScaleData): return True, data if hasattr(data, 'ndim') and data.ndim > 1: return False, data if isinstance(data, (list, tuple)) and len(data) == 1: # pyramid with only one level, unwrap return False, data[0] shapes = [d.shape for d in data] sizes = np.array([np.prod(shape, dtype=np.uint64) for shape in shapes]) if len(sizes) <= 1: return False, data consistent = bool(np.all(sizes[:-1] > sizes[1:])) if np.all(sizes == sizes[0]): # note: the individual array case should be caught by the first # code line in this function, hasattr(ndim) and ndim > 1. raise ValueError( trans._( 'Input data should be an array-like object, or a sequence of arrays of decreasing size. Got arrays of single shape: {shape}', deferred=True, shape=shapes[0], ) ) if not consistent: raise ValueError( trans._( 'Input data should be an array-like object, or a sequence of arrays of decreasing size. Got arrays in incorrect order, shapes: {shapes}', deferred=True, shapes=shapes, ) ) return True, MultiScaleData(data) def guess_labels(data): """Guess if array contains labels data.""" if hasattr(data, 'dtype') and data.dtype in ( np.int32, np.uint32, np.int64, np.uint64, ): return 'labels' return 'image' napari-0.5.0a1/napari/layers/image/_image_view.py000066400000000000000000000037361437041365600217120ustar00rootroot00000000000000"""ImageView class. """ from __future__ import annotations from typing import TYPE_CHECKING if TYPE_CHECKING: from napari.types import ArrayLike, Callable class ImageView: """A raw image and a viewable version of it. Small class that groups together two related images, the raw one and the viewable one. The image_converter passed in is either Image._raw_to_displayed or Labels._raw_to_displayed. The Image one does nothing but the Labels ones does colormapping. Parameters ---------- view_image : ArrayLike Default viewable image, raw is set to the same thing. image_converter : Callable[[ArrayLike], ArrayLike] Used to convert images from raw to viewable. Attributes ---------- view : ArrayLike The raw image. image_convert : ImageConvert Converts from raw to viewable. """ def __init__( self, view_image: ArrayLike, image_converter: Callable[[ArrayLike], ArrayLike], ) -> None: """Create an ImageView with some default image.""" self.view = view_image self.image_converter = image_converter @property def view(self): """The viewable image.""" return self._view @view.setter def view(self, view_image: ArrayLike): """Set the viewed and raw image. Parameters ---------- view_image : ArrayLike The viewable and raw images are set to this. """ self._view = view_image self._raw = view_image @property def raw(self): """The raw image.""" return self._raw @raw.setter def raw(self, raw_image: ArrayLike): """Set the raw image, viewable image is computed. Parameters ---------- raw_image : ArrayLike The raw image to set. """ self._raw = raw_image # Update the view image based on this new raw image. self._view = self.image_converter(raw_image) napari-0.5.0a1/napari/layers/image/_slice.py000066400000000000000000000144441437041365600206730ustar00rootroot00000000000000import warnings from dataclasses import dataclass, field from typing import Any, Optional, Tuple, Union import numpy as np from napari.layers.utils._slice_input import _SliceInput from napari.utils._dask_utils import DaskIndexer from napari.utils.transforms import Affine from napari.utils.translations import trans @dataclass(frozen=True) class _ImageSliceResponse: """Contains all the output data of slicing an image layer. Attributes ---------- data : array like The sliced image data. In general, if you need this to be a `numpy.ndarray` you should call `np.asarray`. Though if the corresponding request was not lazy, this is likely a `numpy.ndarray`. thumbnail: array like or none The thumbnail image data, which may be a different resolution to the sliced image data for multi-scale images. For single-scale images, this will be `None`, which indicates that the thumbnail data is the same as the sliced image data. tile_to_data: Affine The affine transform from the sliced data to the full data at the highest resolution. For single-scale images, this will be the identity matrix. dims : _SliceInput Describes the slicing plane or bounding box in the layer's dimensions. indices : tuple of ints or slices The slice indices in the layer's data space. """ data: Any = field(repr=False) thumbnail: Optional[Any] = field(repr=False) tile_to_data: Affine = field(repr=False) dims: _SliceInput indices: Tuple[Union[int, slice], ...] @dataclass(frozen=True) class _ImageSliceRequest: """A callable that stores all the input data needed to slice an image layer. This should be treated a deeply immutable structure, even though some fields can be modified in place. It is like a function that has captured all its inputs already. In general, the calling an instance of this may take a long time, so you may want to run it off the main thread. Attributes ---------- dims : _SliceInput Describes the slicing plane or bounding box in the layer's dimensions. data : Any The layer's data field, which is the main input to slicing. indices : tuple of ints or slices The slice indices in the layer's data space. lazy : bool If True, do not materialize the data with `np.asarray` during execution. Otherwise, False. This should be True for the experimental async code (as the load occurs on a separate thread) but False for the new async where `execute` is expected to be run on a separate thread. others See the corresponding attributes in `Layer` and `Image`. """ dims: _SliceInput data: Any = field(repr=False) dask_indexer: DaskIndexer indices: Tuple[Union[int, slice], ...] multiscale: bool = field(repr=False) corner_pixels: np.ndarray rgb: bool = field(repr=False) data_level: int = field(repr=False) thumbnail_level: int = field(repr=False) level_shapes: np.ndarray = field(repr=False) downsample_factors: np.ndarray = field(repr=False) lazy: bool = field(default=False, repr=False) def __call__(self) -> _ImageSliceResponse: with self.dask_indexer(): return ( self._call_multi_scale() if self.multiscale else self._call_single_scale() ) def _call_single_scale(self) -> _ImageSliceResponse: image = self.data[self.indices] if not self.lazy: image = np.asarray(image) # `Layer.multiscale` is mutable so we need to pass back the identity # transform to ensure `tile2data` is properly set on the layer. ndim = self.dims.ndim tile_to_data = Affine( name='tile2data', linear_matrix=np.eye(ndim), ndim=ndim ) return _ImageSliceResponse( data=image, thumbnail=None, tile_to_data=tile_to_data, dims=self.dims, indices=self.indices, ) def _call_multi_scale(self) -> _ImageSliceResponse: if self.dims.ndisplay == 3: warnings.warn( trans._( 'Multiscale rendering is only supported in 2D. In 3D, only the lowest resolution scale is displayed', deferred=True, ), category=UserWarning, ) level = len(self.data) - 1 else: level = self.data_level indices = self._slice_indices_at_level(level) # Calculate the tile-to-data transform. scale = np.ones(self.dims.ndim) for d in self.dims.displayed: scale[d] = self.downsample_factors[level][d] translate = np.zeros(self.dims.ndim) if self.dims.ndisplay == 2: for d in self.dims.displayed: indices[d] = slice( self.corner_pixels[0, d], self.corner_pixels[1, d], 1, ) translate = self.corner_pixels[0] * scale # This only needs to be a ScaleTranslate but different types # of transforms in a chain don't play nicely together right now. tile_to_data = Affine( name='tile2data', scale=scale, translate=translate, ndim=self.dims.ndim, ) thumbnail_indices = self._slice_indices_at_level(self.thumbnail_level) image = self.data[level][tuple(indices)] thumbnail = self.data[self.thumbnail_level][tuple(thumbnail_indices)] if not self.lazy: image = np.asarray(image) thumbnail = np.asarray(thumbnail) return _ImageSliceResponse( data=image, thumbnail=thumbnail, tile_to_data=tile_to_data, dims=self.dims, indices=self.indices, ) def _slice_indices_at_level( self, level: int ) -> Tuple[Union[int, float, slice], ...]: indices = np.array(self.indices) axes = self.dims.not_displayed ds_indices = indices[axes] / self.downsample_factors[level][axes] ds_indices = np.round(ds_indices.astype(float)).astype(int) ds_indices = np.clip(ds_indices, 0, self.level_shapes[level][axes] - 1) indices[axes] = ds_indices return indices napari-0.5.0a1/napari/layers/image/_tests/000077500000000000000000000000001437041365600203555ustar00rootroot00000000000000napari-0.5.0a1/napari/layers/image/_tests/__init__.py000066400000000000000000000000001437041365600224540ustar00rootroot00000000000000napari-0.5.0a1/napari/layers/image/_tests/test_big_image_timing.py000066400000000000000000000022021437041365600252340ustar00rootroot00000000000000import time import dask.array as da import pytest import zarr from napari.layers import Image data_dask = da.random.random( size=(100_000, 1000, 1000), chunks=(1, 1000, 1000) ) data_zarr = zarr.zeros((100_000, 1000, 1000)) @pytest.mark.parametrize( 'kwargs', [ dict(multiscale=False, contrast_limits=[0, 1]), dict(multiscale=False), dict(contrast_limits=[0, 1]), {}, ], ids=('all', 'multiscale', 'clims', 'nothing'), ) @pytest.mark.parametrize('data', [data_dask, data_zarr], ids=('dask', 'zarrs')) def test_timing_fast_big_dask(data, kwargs): now = time.monotonic() assert Image(data, **kwargs).data.shape == data.shape elapsed = time.monotonic() - now assert ( elapsed < 2 ), "Test took to long some computation are likely not lazy" def test_non_visible_images(): """Test loading non-visible images doesn't trigger compute.""" data_dask_2D = da.random.random((100_000, 100_000)) layer = Image( data_dask_2D, visible=False, multiscale=False, contrast_limits=[0, 1], ) assert layer.data.shape == data_dask_2D.shape napari-0.5.0a1/napari/layers/image/_tests/test_image.py000066400000000000000000000644601437041365600230620ustar00rootroot00000000000000import dask.array as da import numpy as np import pytest import xarray as xr from napari._tests.utils import check_layer_world_data_extent from napari.layers import Image from napari.layers.image._image_constants import ImageRendering from napari.layers.utils.plane import ClippingPlaneList, SlicingPlane from napari.utils import Colormap from napari.utils.transforms.transform_utils import rotate_to_matrix def test_random_image(): """Test instantiating Image layer with random 2D data.""" shape = (10, 15) np.random.seed(0) data = np.random.random(shape) layer = Image(data) assert np.all(layer.data == data) assert layer.ndim == len(shape) np.testing.assert_array_equal(layer.extent.data[1], shape) assert layer.rgb is False assert layer.multiscale is False assert layer._data_view.shape == shape[-2:] def test_negative_image(): """Test instantiating Image layer with negative data.""" shape = (10, 15) np.random.seed(0) # Data between -1.0 and 1.0 data = 2 * np.random.random(shape) - 1.0 layer = Image(data) assert np.all(layer.data == data) assert layer.ndim == len(shape) np.testing.assert_array_equal(layer.extent.data[1], shape) assert layer.rgb is False assert layer._data_view.shape == shape[-2:] # Data between -10 and 10 data = 20 * np.random.random(shape) - 10 layer = Image(data) assert np.all(layer.data == data) assert layer.ndim == len(shape) np.testing.assert_array_equal(layer.extent.data[1], shape) assert layer.rgb is False assert layer._data_view.shape == shape[-2:] def test_all_zeros_image(): """Test instantiating Image layer with all zeros data.""" shape = (10, 15) data = np.zeros(shape, dtype=float) layer = Image(data) assert np.all(layer.data == data) assert layer.ndim == len(shape) np.testing.assert_array_equal(layer.extent.data[1], shape) assert layer.rgb is False assert layer._data_view.shape == shape[-2:] def test_integer_image(): """Test instantiating Image layer with integer data.""" shape = (10, 15) np.random.seed(0) data = np.round(10 * np.random.random(shape)).astype(int) layer = Image(data) assert np.all(layer.data == data) assert layer.ndim == len(shape) np.testing.assert_array_equal(layer.extent.data[1], shape) assert layer.rgb is False assert layer._data_view.shape == shape[-2:] def test_bool_image(): """Test instantiating Image layer with bool data.""" shape = (10, 15) data = np.zeros(shape, dtype=bool) layer = Image(data) assert np.all(layer.data == data) assert layer.ndim == len(shape) np.testing.assert_array_equal(layer.extent.data[1], shape) assert layer.rgb is False assert layer._data_view.shape == shape[-2:] def test_3D_image(): """Test instantiating Image layer with random 3D data.""" shape = (10, 15, 6) np.random.seed(0) data = np.random.random(shape) layer = Image(data) assert np.all(layer.data == data) assert layer.ndim == len(shape) np.testing.assert_array_equal(layer.extent.data[1], shape) assert layer.rgb is False assert layer._data_view.shape == shape[-2:] def test_3D_image_shape_1(): """Test instantiating Image layer with random 3D data with shape 1 axis.""" shape = (1, 10, 15) np.random.seed(0) data = np.random.random(shape) layer = Image(data) assert np.all(layer.data == data) assert layer.ndim == len(shape) np.testing.assert_array_equal(layer.extent.data[1], shape) assert layer.rgb is False assert layer._data_view.shape == shape[-2:] def test_4D_image(): """Test instantiating Image layer with random 4D data.""" shape = (10, 15, 6, 8) np.random.seed(0) data = np.random.random(shape) layer = Image(data) assert np.all(layer.data == data) assert layer.ndim == len(shape) np.testing.assert_array_equal(layer.extent.data[1], shape) assert layer.rgb is False assert layer._data_view.shape == shape[-2:] def test_5D_image_shape_1(): """Test instantiating Image layer with random 5D data with shape 1 axis.""" shape = (4, 1, 2, 10, 15) np.random.seed(0) data = np.random.random(shape) layer = Image(data) assert np.all(layer.data == data) assert layer.ndim == len(shape) np.testing.assert_array_equal(layer.extent.data[1], shape) assert layer.rgb is False assert layer._data_view.shape == shape[-2:] def test_rgb_image(): """Test instantiating Image layer with RGB data.""" shape = (10, 15, 3) np.random.seed(0) data = np.random.random(shape) layer = Image(data) assert np.all(layer.data == data) assert layer.ndim == len(shape) - 1 np.testing.assert_array_equal(layer.extent.data[1], shape[:-1]) assert layer.rgb is True assert layer._data_view.shape == shape[-3:] def test_rgba_image(): """Test instantiating Image layer with RGBA data.""" shape = (10, 15, 4) np.random.seed(0) data = np.random.random(shape) layer = Image(data) assert np.all(layer.data == data) assert layer.ndim == len(shape) - 1 np.testing.assert_array_equal(layer.extent.data[1], shape[:-1]) assert layer.rgb is True assert layer._data_view.shape == shape[-3:] def test_negative_rgba_image(): """Test instantiating Image layer with negative RGBA data.""" shape = (10, 15, 4) np.random.seed(0) # Data between -1.0 and 1.0 data = 2 * np.random.random(shape) - 1 layer = Image(data) assert np.all(layer.data == data) assert layer.ndim == len(shape) - 1 np.testing.assert_array_equal(layer.extent.data[1], shape[:-1]) assert layer.rgb is True assert layer._data_view.shape == shape[-3:] # Data between -10 and 10 data = 20 * np.random.random(shape) - 10 layer = Image(data) assert np.all(layer.data == data) assert layer.ndim == len(shape) - 1 np.testing.assert_array_equal(layer.extent.data[1], shape[:-1]) assert layer.rgb is True assert layer._data_view.shape == shape[-3:] def test_non_rgb_image(): """Test forcing Image layer to be 3D and not rgb.""" shape = (10, 15, 3) np.random.seed(0) data = np.random.random(shape) layer = Image(data, rgb=False) assert np.all(layer.data == data) assert layer.ndim == len(shape) np.testing.assert_array_equal(layer.extent.data[1], shape) assert layer.rgb is False assert layer._data_view.shape == shape[-2:] @pytest.mark.parametrize("shape", [(10, 15, 6), (10, 10)]) def test_error_non_rgb_image(shape): """Test error on trying non rgb as rgb.""" # If rgb is set to be True in constructor but the last dim has a # size > 4 or ndim not >= 3 then data cannot actually be rgb data = np.empty(shape) with pytest.raises(ValueError, match="'rgb' was set to True but"): Image(data, rgb=True) def test_changing_image(): """Test changing Image data.""" shape_a = (10, 15) shape_b = (20, 12) np.random.seed(0) data_a = np.random.random(shape_a) data_b = np.random.random(shape_b) layer = Image(data_a) layer.data = data_b assert np.all(layer.data == data_b) assert layer.ndim == len(shape_b) np.testing.assert_array_equal(layer.extent.data[1], shape_b) assert layer.rgb is False assert layer._data_view.shape == shape_b[-2:] def test_changing_image_dims(): """Test changing Image data including dimensionality.""" shape_a = (10, 15) shape_b = (20, 12, 6) np.random.seed(0) data_a = np.random.random(shape_a) data_b = np.random.random(shape_b) layer = Image(data_a) # Prep indices for switch to 3D layer.data = data_b assert np.all(layer.data == data_b) assert layer.ndim == len(shape_b) np.testing.assert_array_equal(layer.extent.data[1], shape_b) assert layer.rgb is False assert layer._data_view.shape == shape_b[-2:] def test_name(): """Test setting layer name.""" np.random.seed(0) data = np.random.random((10, 15)) layer = Image(data) assert layer.name == 'Image' layer = Image(data, name='random') assert layer.name == 'random' layer.name = 'img' assert layer.name == 'img' def test_visiblity(): """Test setting layer visibility.""" np.random.seed(0) data = np.random.random((10, 15)) layer = Image(data) assert layer.visible is True layer.visible = False assert layer.visible is False layer = Image(data, visible=False) assert layer.visible is False layer.visible = True assert layer.visible is True def test_opacity(): """Test setting layer opacity.""" np.random.seed(0) data = np.random.random((10, 15)) layer = Image(data) assert layer.opacity == 1 layer.opacity = 0.5 assert layer.opacity == 0.5 layer = Image(data, opacity=0.6) assert layer.opacity == 0.6 layer.opacity = 0.3 assert layer.opacity == 0.3 def test_blending(): """Test setting layer blending.""" np.random.seed(0) data = np.random.random((10, 15)) layer = Image(data) assert layer.blending == 'translucent' layer.blending = 'additive' assert layer.blending == 'additive' layer = Image(data, blending='additive') assert layer.blending == 'additive' layer.blending = 'opaque' assert layer.blending == 'opaque' layer.blending = 'minimum' assert layer.blending == 'minimum' def test_interpolation(): """Test setting image interpolation mode.""" np.random.seed(0) data = np.random.random((10, 15)) layer = Image(data) with pytest.deprecated_call(): assert layer.interpolation == 'nearest' assert layer.interpolation2d == 'nearest' assert layer.interpolation3d == 'linear' with pytest.deprecated_call(): layer = Image(data, interpolation2d='bicubic') assert layer.interpolation2d == 'cubic' with pytest.deprecated_call(): assert layer.interpolation == 'cubic' layer.interpolation2d = 'linear' assert layer.interpolation2d == 'linear' with pytest.deprecated_call(): assert layer.interpolation == 'linear' def test_colormaps(): """Test setting test_colormaps.""" np.random.seed(0) data = np.random.random((10, 15)) layer = Image(data) assert layer.colormap.name == 'gray' assert isinstance(layer.colormap, Colormap) layer.colormap = 'magma' assert layer.colormap.name == 'magma' assert isinstance(layer.colormap, Colormap) cmap = Colormap([[0.0, 0.0, 0.0, 0.0], [0.3, 0.7, 0.2, 1.0]]) layer.colormap = 'custom', cmap assert layer.colormap.name == 'custom' assert layer.colormap == cmap cmap = Colormap([[0.0, 0.0, 0.0, 0.0], [0.7, 0.2, 0.6, 1.0]]) layer.colormap = {'new': cmap} assert layer.colormap.name == 'new' assert layer.colormap == cmap layer = Image(data, colormap='magma') assert layer.colormap.name == 'magma' assert isinstance(layer.colormap, Colormap) cmap = Colormap([[0.0, 0.0, 0.0, 0.0], [0.3, 0.7, 0.2, 1.0]]) layer = Image(data, colormap=('custom', cmap)) assert layer.colormap.name == 'custom' assert layer.colormap == cmap cmap = Colormap([[0.0, 0.0, 0.0, 0.0], [0.7, 0.2, 0.6, 1.0]]) layer = Image(data, colormap={'new': cmap}) assert layer.colormap.name == 'new' assert layer.colormap == cmap def test_contrast_limits(): """Test setting color limits.""" np.random.seed(0) data = np.random.random((10, 15)) layer = Image(data) assert layer.contrast_limits[0] >= 0 assert layer.contrast_limits[1] <= 1 assert layer.contrast_limits[0] < layer.contrast_limits[1] assert layer.contrast_limits == layer.contrast_limits_range # Change contrast_limits property contrast_limits = [0, 2] layer.contrast_limits = contrast_limits assert layer.contrast_limits == contrast_limits assert layer.contrast_limits_range == contrast_limits # Set contrast_limits as keyword argument layer = Image(data, contrast_limits=contrast_limits) assert layer.contrast_limits == contrast_limits assert layer.contrast_limits_range == contrast_limits def test_contrast_limits_range(): """Test setting color limits range.""" np.random.seed(0) data = np.random.random((10, 15)) layer = Image(data) assert layer.contrast_limits_range[0] >= 0 assert layer.contrast_limits_range[1] <= 1 assert layer.contrast_limits_range[0] < layer.contrast_limits_range[1] # If all data is the same value the contrast_limits_range and # contrast_limits defaults to [0, 1] data = np.zeros((10, 15)) layer = Image(data) assert layer.contrast_limits_range == [0, 1] assert layer.contrast_limits == [0.0, 1.0] def test_set_contrast_limits_range(): """Test setting color limits range.""" np.random.seed(0) data = np.random.random((10, 15)) * 100 layer = Image(data) layer.contrast_limits_range = [0, 100] layer.contrast_limits = [20, 40] assert layer.contrast_limits_range == [0, 100] assert layer.contrast_limits == [20, 40] # clim values should stay within the contrast limits range layer.contrast_limits_range = [0, 30] assert layer.contrast_limits == [20, 30] # setting clim range outside of clim should override clim layer.contrast_limits_range = [0, 10] assert layer.contrast_limits == [0, 10] # in both directions... layer.contrast_limits_range = [0, 100] layer.contrast_limits = [20, 40] layer.contrast_limits_range = [60, 100] assert layer.contrast_limits == [60, 100] @pytest.mark.parametrize( 'contrast_limits_range', ( [-2, -1], # range below lower boundary of [0, 1] [-1, 0], # range on lower boundary of [0, 1] [1, 2], # range on upper boundary of [0, 1] [2, 3], # range above upper boundary of [0, 1] ), ) def test_set_contrast_limits_range_at_boundary_of_contrast_limits( contrast_limits_range, ): """See https://github.com/napari/napari/issues/5257""" layer = Image(np.zeros((6, 5)), contrast_limits=[0, 1]) layer.contrast_limits_range = contrast_limits_range assert layer.contrast_limits == contrast_limits_range def test_gamma(): """Test setting gamma.""" np.random.seed(0) data = np.random.random((10, 15)) layer = Image(data) assert layer.gamma == 1 # Change gamma property gamma = 0.7 layer.gamma = gamma assert layer.gamma == gamma # Set gamma as keyword argument layer = Image(data, gamma=gamma) assert layer.gamma == gamma def test_rendering(): """Test setting rendering.""" np.random.seed(0) data = np.random.random((20, 10, 15)) layer = Image(data) assert layer.rendering == 'mip' # Change rendering property layer.rendering = 'translucent' assert layer.rendering == 'translucent' # Change rendering property layer.rendering = 'attenuated_mip' assert layer.rendering == 'attenuated_mip' # Change rendering property layer.rendering = 'iso' assert layer.rendering == 'iso' # Change rendering property layer.rendering = 'additive' assert layer.rendering == 'additive' def test_iso_threshold(): """Test setting iso_threshold.""" np.random.seed(0) data = np.random.random((10, 15)) layer = Image(data) assert np.min(data) <= layer.iso_threshold <= np.max(data) # Change iso_threshold property iso_threshold = 0.7 layer.iso_threshold = iso_threshold assert layer.iso_threshold == iso_threshold # Set iso_threshold as keyword argument layer = Image(data, iso_threshold=iso_threshold) assert layer.iso_threshold == iso_threshold def test_attenuation(): """Test setting attenuation.""" np.random.seed(0) data = np.random.random((10, 15)) layer = Image(data) assert layer.attenuation == 0.05 # Change attenuation property attenuation = 0.07 layer.attenuation = attenuation assert layer.attenuation == attenuation # Set attenuation as keyword argument layer = Image(data, attenuation=attenuation) assert layer.attenuation == attenuation def test_metadata(): """Test setting image metadata.""" np.random.seed(0) data = np.random.random((10, 15)) layer = Image(data) assert layer.metadata == {} layer = Image(data, metadata={'unit': 'cm'}) assert layer.metadata == {'unit': 'cm'} def test_value(): """Test getting the value of the data at the current coordinates.""" np.random.seed(0) data = np.random.random((10, 15)) layer = Image(data) value = layer.get_value((0,) * 2) assert value == data[0, 0] @pytest.mark.parametrize( 'position,view_direction,dims_displayed,world', [ ((0, 0, 0), [1, 0, 0], [0, 1, 2], False), ((0, 0, 0), [1, 0, 0], [0, 1, 2], True), ((0, 0, 0, 0), [0, 1, 0, 0], [1, 2, 3], True), ], ) def test_value_3d(position, view_direction, dims_displayed, world): """Currently get_value should return None in 3D""" np.random.seed(0) data = np.random.random((10, 15, 15)) layer = Image(data) layer._slice_dims([0, 0, 0], ndisplay=3) value = layer.get_value( position, view_direction=view_direction, dims_displayed=dims_displayed, world=world, ) assert value is None def test_message(): """Test converting value and coords to message.""" np.random.seed(0) data = np.random.random((10, 15)) layer = Image(data) msg = layer.get_status((0,) * 2) assert type(msg) == dict def test_message_3d(): """Test converting values and coords to message in 3D.""" np.random.seed(0) data = np.random.random((10, 15, 15)) layer = Image(data) layer._slice_dims(ndisplay=3) msg = layer.get_status( (0, 0, 0), view_direction=[1, 0, 0], dims_displayed=[0, 1, 2] ) assert type(msg) == dict def test_thumbnail(): """Test the image thumbnail for square data.""" np.random.seed(0) data = np.random.random((30, 30)) layer = Image(data) layer._update_thumbnail() assert layer.thumbnail.shape == layer._thumbnail_shape def test_narrow_thumbnail(): """Ensure that the thumbnail generation works for very narrow images. See: https://github.com/napari/napari/issues/641 and https://github.com/napari/napari/issues/489 """ image = np.random.random((1, 2048)) layer = Image(image) layer._update_thumbnail() thumbnail = layer.thumbnail[..., :3] # ignore alpha channel middle_row = thumbnail.shape[0] // 2 assert np.all(thumbnail[: middle_row - 1] == 0) assert np.all(thumbnail[middle_row + 1 :] == 0) assert np.mean(thumbnail[middle_row - 1 : middle_row + 1]) > 0 @pytest.mark.parametrize('dtype', [np.float32, np.float64]) def test_out_of_range_image(dtype): data = -1.7 - 0.001 * np.random.random((10, 15)).astype(dtype) layer = Image(data) layer._update_thumbnail() @pytest.mark.parametrize('dtype', [np.float32, np.float64]) def test_out_of_range_no_contrast(dtype): data = np.full((10, 15), -3.2, dtype=dtype) layer = Image(data) layer._update_thumbnail() @pytest.mark.parametrize( "scale", [ (None), ([1, 1]), (np.array([1, 1])), (da.from_array([1, 1], chunks=1)), (da.from_array([1, 1], chunks=2)), (xr.DataArray(np.array([1, 1]))), (xr.DataArray(np.array([1, 1]), dims=('dimension_name'))), ], ) def test_image_scale(scale): np.random.seed(0) data = np.random.random((10, 15)) Image(data, scale=scale) @pytest.mark.parametrize( "translate", [ (None), ([1, 1]), (np.array([1, 1])), (da.from_array([1, 1], chunks=1)), (da.from_array([1, 1], chunks=2)), (xr.DataArray(np.array([1, 1]))), (xr.DataArray(np.array([1, 1]), dims=('dimension_name'))), ], ) def test_image_translate(translate): np.random.seed(0) data = np.random.random((10, 15)) Image(data, translate=translate) def test_image_scale_broadcast(): """Test scale is broadcast.""" data = np.random.random((5, 10, 15)) layer = Image(data, scale=(2, 2)) np.testing.assert_almost_equal(layer.scale, (1, 2, 2)) def test_image_translate_broadcast(): """Test translate is broadcast.""" data = np.random.random((5, 10, 15)) layer = Image(data, translate=(2, 2)) np.testing.assert_almost_equal(layer.translate, (0, 2, 2)) def test_grid_translate(): np.random.seed(0) data = np.random.random((10, 15)) layer = Image(data) translate = np.array([15, 15]) layer._translate_grid = translate np.testing.assert_allclose(layer._translate_grid, translate) def test_world_data_extent(): """Test extent after applying transforms.""" np.random.seed(0) shape = (6, 10, 15) data = np.random.random(shape) layer = Image(data) extent = np.array(((0,) * 3, shape)) check_layer_world_data_extent(layer, extent, (3, 1, 1), (10, 20, 5), True) def test_data_to_world_2d_scale_translate_affine_composed(): data = np.ones((4, 3)) scale = (3, 2) translate = (-4, 8) affine = [[4, 0, 0], [0, 1.5, 0], [0, 0, 1]] image = Image(data, scale=scale, translate=translate, affine=affine) np.testing.assert_array_equal(image.scale, scale) np.testing.assert_array_equal(image.translate, translate) np.testing.assert_array_equal(image.affine, affine) np.testing.assert_almost_equal( image._data_to_world.affine_matrix, ((12, 0, -16), (0, 3, 12), (0, 0, 1)), ) @pytest.mark.parametrize('scale', ((1, 1), (-1, 1), (1, -1), (-1, -1))) @pytest.mark.parametrize('angle_degrees', range(-180, 180, 30)) def test_rotate_with_reflections_in_scale(scale, angle_degrees): # See the GitHub issue for more details: # https://github.com/napari/napari/issues/2984 data = np.ones((4, 3)) rotate = rotate_to_matrix(angle_degrees, ndim=2) image = Image(data, scale=scale, rotate=rotate) np.testing.assert_array_equal(image.scale, scale) np.testing.assert_array_equal(image.rotate, rotate) def test_2d_image_with_channels_and_2d_scale_translate_then_scale_translate_padded(): # See the GitHub issue for more details: # https://github.com/napari/napari/issues/2973 image = Image(np.ones((20, 20, 2)), scale=(1, 1), translate=(3, 4)) np.testing.assert_array_equal(image.scale, (1, 1, 1)) np.testing.assert_array_equal(image.translate, (0, 3, 4)) @pytest.mark.parametrize('affine_size', range(3, 6)) def test_2d_image_with_channels_and_affine_broadcasts(affine_size): # For more details, see the GitHub issue: # https://github.com/napari/napari/issues/3045 image = Image(np.ones((1, 1, 1, 100, 100)), affine=np.eye(affine_size)) np.testing.assert_array_equal(image.affine, np.eye(6)) @pytest.mark.parametrize('affine_size', range(3, 6)) def test_2d_image_with_channels_and_affine_assignment_broadcasts(affine_size): # For more details, see the GitHub issue: # https://github.com/napari/napari/issues/3045 image = Image(np.ones((1, 1, 1, 100, 100))) image.affine = np.eye(affine_size) np.testing.assert_array_equal(image.affine, np.eye(6)) def test_image_state_update(): """Test that an image can be updated from the output of its _get_state method() """ image = Image(np.ones((32, 32, 32))) state = image._get_state() for k, v in state.items(): setattr(image, k, v) def test_instantiate_with_plane_parameter_dict(): """Test that an image layer can be instantiated with plane parameters in a dictionary. """ plane_parameters = { 'position': (32, 32, 32), 'normal': (1, 1, 1), 'thickness': 22, } image = Image(np.ones((32, 32, 32)), plane=plane_parameters) for k, v in plane_parameters.items(): if k == 'normal': v = tuple(v / np.linalg.norm(v)) assert v == getattr(image.plane, k, v) def test_instiantiate_with_plane(): """Test that an image layer can be instantiated with plane parameters in a Plane. """ plane = SlicingPlane(position=(32, 32, 32), normal=(1, 1, 1), thickness=22) image = Image(np.ones((32, 32, 32)), plane=plane) for k, v in plane.dict().items(): assert v == getattr(image.plane, k, v) def test_instantiate_with_clipping_planelist(): planes = ClippingPlaneList.from_array(np.ones((2, 2, 3))) image = Image(np.ones((32, 32, 32)), experimental_clipping_planes=planes) assert len(image.experimental_clipping_planes) == 2 def test_instantiate_with_experimental_clipping_planes_dict(): planes = [ {'position': (0, 0, 0), 'normal': (0, 0, 1)}, {'position': (0, 1, 0), 'normal': (1, 0, 0)}, ] image = Image(np.ones((32, 32, 32)), experimental_clipping_planes=planes) for i in range(len(planes)): assert ( image.experimental_clipping_planes[i].position == planes[i]['position'] ) assert ( image.experimental_clipping_planes[i].normal == planes[i]['normal'] ) def test_tensorstore_image(): """Test an image coming from a tensorstore array.""" ts = pytest.importorskip('tensorstore') data = ts.array( np.full(shape=(1024, 1024), fill_value=255, dtype=np.uint8) ) layer = Image(data) assert np.all(layer.data == data) @pytest.mark.parametrize( "start_position, end_position, view_direction, vector, expected_value", [ # drag vector parallel to view direction # projected onto perpendicular vector ([0, 0, 0], [0, 0, 1], [0, 0, 1], [1, 0, 0], 0), # same as above, projection onto multiple perpendicular vectors # should produce multiple results ([0, 0, 0], [0, 0, 1], [0, 0, 1], [[1, 0, 0], [0, 1, 0]], [0, 0]), # drag vector perpendicular to view direction # projected onto itself ([0, 0, 0], [0, 1, 0], [0, 0, 1], [0, 1, 0], 1), # drag vector perpendicular to view direction # projected onto itself ([0, 0, 0], [0, 1, 0], [0, 0, 1], [0, 1, 0], 1), ], ) def test_projected_distance_from_mouse_drag( start_position, end_position, view_direction, vector, expected_value ): image = Image(np.ones((32, 32, 32))) image._slice_dims(point=[0, 0, 0], ndisplay=3) result = image.projected_distance_from_mouse_drag( start_position, end_position, view_direction, vector, dims_displayed=[0, 1, 2], ) assert np.allclose(result, expected_value) def test_rendering_init(): np.random.seed(0) data = np.random.rand(10, 10, 10) layer = Image(data, rendering='iso') assert layer.rendering == ImageRendering.ISO.value napari-0.5.0a1/napari/layers/image/_tests/test_image_slice.py000066400000000000000000000021231437041365600242250ustar00rootroot00000000000000import numpy as np from napari.layers.image._image_slice import ImageSlice def _converter(array): return array * 2 def test_image_slice(): """Test ImageSlice and ImageView.""" image1 = np.random.random((32, 16)) image2 = np.random.random((32, 16)) # Create a slice and check it was created as expected. image_slice = ImageSlice(image1, _converter) assert image_slice.rgb is False assert id(image_slice.image.view) == id(image1) assert id(image_slice.image.raw) == id(image1) # Update the slice and see the conversion happened. image_slice.image.raw = image2 assert id(image_slice.image.raw) == id(image2) assert np.all(image_slice.image.view == image2 * 2) # Test ImageSlice.set_raw_images(). image3 = np.random.random((32, 16)) image4 = np.random.random((32, 16)) image_slice._set_raw_images(image3, image4) assert id(image_slice.image.raw) == id(image3) assert id(image_slice.thumbnail.raw) == id(image4) assert np.all(image_slice.image.view == image3 * 2) assert np.all(image_slice.thumbnail.view == image4 * 2) napari-0.5.0a1/napari/layers/image/_tests/test_image_utils.py000066400000000000000000000053431437041365600242750ustar00rootroot00000000000000import time import dask.array as da import numpy as np import pytest import skimage from hypothesis import given from hypothesis.extra.numpy import array_shapes from skimage.transform import pyramid_gaussian from napari.layers.image._image_utils import guess_multiscale, guess_rgb data_dask = da.random.random( size=(100_000, 1000, 1000), chunks=(1, 1000, 1000) ) def test_guess_rgb(): shape = (10, 15) assert not guess_rgb(shape) shape = (10, 15, 6) assert not guess_rgb(shape) shape = (10, 15, 3) assert guess_rgb(shape) shape = (10, 15, 4) assert guess_rgb(shape) @given(shape=array_shapes(min_dims=3, min_side=0)) def test_guess_rgb_property(shape): assert guess_rgb(shape) == (shape[-1] in (3, 4)) def test_guess_multiscale(): data = np.random.random((10, 15)) assert not guess_multiscale(data)[0] data = np.random.random((10, 15, 6)) assert not guess_multiscale(data)[0] data = [np.random.random((10, 15, 6))] assert not guess_multiscale(data)[0] data = [np.random.random((10, 15, 6)), np.random.random((5, 7, 3))] assert guess_multiscale(data)[0] data = [np.random.random((10, 15, 6)), np.random.random((10, 7, 3))] assert guess_multiscale(data)[0] data = tuple(data) assert guess_multiscale(data)[0] if skimage.__version__ > '0.19': pyramid_kwargs = {'channel_axis': None} else: pyramid_kwargs = {'multichannel': False} data = tuple( pyramid_gaussian(np.random.random((10, 15)), **pyramid_kwargs) ) assert guess_multiscale(data)[0] data = np.asarray( tuple(pyramid_gaussian(np.random.random((10, 15)), **pyramid_kwargs)), dtype=object, ) assert guess_multiscale(data)[0] # Check for integer overflow with big data s = 8192 data = [da.ones((s,) * 3), da.ones((s // 2,) * 3), da.ones((s // 4,) * 3)] assert guess_multiscale(data)[0] def test_guess_multiscale_strip_single_scale(): data = [np.empty((10, 10))] guess, data_out = guess_multiscale(data) assert data_out is data[0] assert guess is False def test_guess_multiscale_non_array_list(): """Check that non-decreasing list input raises ValueError""" data = [ np.empty((10, 15, 6)), ] * 2 # noqa: E231 with pytest.raises(ValueError): _, _ = guess_multiscale(data) def test_guess_multiscale_incorrect_order(): data = [np.empty((10, 15)), np.empty((5, 6)), np.empty((20, 15))] with pytest.raises(ValueError): _, _ = guess_multiscale(data) def test_timing_multiscale_big(): now = time.monotonic() assert not guess_multiscale(data_dask)[0] elapsed = time.monotonic() - now assert elapsed < 2, "test was too slow, computation was likely not lazy" napari-0.5.0a1/napari/layers/image/_tests/test_multiscale.py000066400000000000000000000372531437041365600241420ustar00rootroot00000000000000import numpy as np import pytest import skimage from skimage.transform import pyramid_gaussian from napari._tests.utils import check_layer_world_data_extent from napari.layers import Image from napari.utils import Colormap def test_random_multiscale(): """Test instantiating Image layer with random 2D multiscale data.""" shapes = [(40, 20), (20, 10), (10, 5)] np.random.seed(0) data = [np.random.random(s) for s in shapes] layer = Image(data, multiscale=True) assert layer.data == data assert layer.multiscale is True assert layer.ndim == len(shapes[0]) np.testing.assert_array_equal(layer.extent.data[1], shapes[0]) assert layer.rgb is False assert layer._data_view.ndim == 2 def test_infer_multiscale(): """Test instantiating Image layer with random 2D multiscale data.""" shapes = [(40, 20), (20, 10), (10, 5)] np.random.seed(0) data = [np.random.random(s) for s in shapes] layer = Image(data) assert layer.data == data assert layer.multiscale is True assert layer.ndim == len(shapes[0]) np.testing.assert_array_equal(layer.extent.data[1], shapes[0]) assert layer.rgb is False assert layer._data_view.ndim == 2 def test_infer_tuple_multiscale(): """Test instantiating Image layer with random 2D multiscale data.""" shapes = [(40, 20), (20, 10), (10, 5)] np.random.seed(0) data = [np.random.random(s) for s in shapes] layer = Image(data) assert layer.data == data assert layer.multiscale is True assert layer.ndim == len(shapes[0]) np.testing.assert_array_equal(layer.extent.data[1], shapes[0]) assert layer.rgb is False assert layer._data_view.ndim == 2 def test_blocking_multiscale(): """Test instantiating Image layer blocking 2D multiscale data.""" shape = (40, 20) np.random.seed(0) data = np.random.random(shape) layer = Image(data, multiscale=False) assert np.all(layer.data == data) assert layer.multiscale is False assert layer.ndim == len(shape) np.testing.assert_array_equal(layer.extent.data[1], shape) assert layer.rgb is False assert layer._data_view.ndim == 2 def test_multiscale_tuple(): """Test instantiating Image layer multiscale tuple.""" shape = (40, 20) np.random.seed(0) img = np.random.random(shape) if skimage.__version__ > '0.19': pyramid_kwargs = {'channel_axis': None} else: pyramid_kwargs = {'multichannel': False} data = list(pyramid_gaussian(img, **pyramid_kwargs)) layer = Image(data) assert layer.data == data assert layer.multiscale is True assert layer.ndim == len(shape) np.testing.assert_array_equal(layer.extent.data[1], shape) assert layer.rgb is False assert layer._data_view.ndim == 2 def test_3D_multiscale(): """Test instantiating Image layer with 3D data.""" shapes = [(8, 40, 20), (4, 20, 10), (2, 10, 5)] np.random.seed(0) data = [np.random.random(s) for s in shapes] layer = Image(data, multiscale=True) assert layer.data == data assert layer.ndim == len(shapes[0]) np.testing.assert_array_equal(layer.extent.data[1], shapes[0]) assert layer.rgb is False assert layer._data_view.ndim == 2 def test_non_uniform_3D_multiscale(): """Test instantiating Image layer non-uniform 3D data.""" shapes = [(8, 40, 20), (8, 20, 10), (8, 10, 5)] np.random.seed(0) data = [np.random.random(s) for s in shapes] layer = Image(data, multiscale=True) assert layer.data == data assert layer.ndim == len(shapes[0]) np.testing.assert_array_equal(layer.extent.data[1], shapes[0]) assert layer.rgb is False assert layer._data_view.ndim == 2 def test_rgb_multiscale(): """Test instantiating Image layer with RGB data.""" shapes = [(40, 20, 3), (20, 10, 3), (10, 5, 3)] np.random.seed(0) data = [np.random.random(s) for s in shapes] layer = Image(data, multiscale=True) assert layer.data == data assert layer.ndim == len(shapes[0]) - 1 np.testing.assert_array_equal(layer.extent.data[1], shapes[0][:-1]) assert layer.rgb is True assert layer._data_view.ndim == 3 def test_3D_rgb_multiscale(): """Test instantiating Image layer with 3D RGB data.""" shapes = [(8, 40, 20, 3), (4, 20, 10, 3), (2, 10, 5, 3)] np.random.seed(0) data = [np.random.random(s) for s in shapes] layer = Image(data, multiscale=True) assert layer.data == data assert layer.ndim == len(shapes[0]) - 1 np.testing.assert_array_equal(layer.extent.data[1], shapes[0][:-1]) assert layer.rgb is True assert layer._data_view.ndim == 3 def test_non_rgb_image(): """Test forcing Image layer to be 3D and not rgb.""" shapes = [(40, 20, 3), (20, 10, 3), (10, 5, 3)] np.random.seed(0) data = [np.random.random(s) for s in shapes] layer = Image(data, multiscale=True, rgb=False) assert layer.data == data assert layer.ndim == len(shapes[0]) np.testing.assert_array_equal(layer.extent.data[1], shapes[0]) assert layer.rgb is False def test_name(): """Test setting layer name.""" shapes = [(40, 20), (20, 10), (10, 5)] np.random.seed(0) data = [np.random.random(s) for s in shapes] layer = Image(data, multiscale=True) assert layer.name == 'Image' layer = Image(data, multiscale=True, name='random') assert layer.name == 'random' layer.name = 'img' assert layer.name == 'img' def test_visiblity(): """Test setting layer visibility.""" shapes = [(40, 20), (20, 10), (10, 5)] np.random.seed(0) data = [np.random.random(s) for s in shapes] layer = Image(data, multiscale=True) assert layer.visible is True layer.visible = False assert layer.visible is False layer = Image(data, multiscale=True, visible=False) assert layer.visible is False layer.visible = True assert layer.visible is True def test_opacity(): """Test setting layer opacity.""" shapes = [(40, 20), (20, 10), (10, 5)] np.random.seed(0) data = [np.random.random(s) for s in shapes] layer = Image(data, multiscale=True) assert layer.opacity == 1.0 layer.opacity = 0.5 assert layer.opacity == 0.5 layer = Image(data, multiscale=True, opacity=0.6) assert layer.opacity == 0.6 layer.opacity = 0.3 assert layer.opacity == 0.3 def test_blending(): """Test setting layer blending.""" shapes = [(40, 20), (20, 10), (10, 5)] np.random.seed(0) data = [np.random.random(s) for s in shapes] layer = Image(data, multiscale=True) assert layer.blending == 'translucent' layer.blending = 'additive' assert layer.blending == 'additive' layer = Image(data, multiscale=True, blending='additive') assert layer.blending == 'additive' layer.blending = 'opaque' assert layer.blending == 'opaque' def test_interpolation(): """Test setting image interpolation mode.""" shapes = [(40, 20), (20, 10), (10, 5)] np.random.seed(0) data = [np.random.random(s) for s in shapes] layer = Image(data, multiscale=True) with pytest.deprecated_call(): assert layer.interpolation == 'nearest' assert layer.interpolation2d == 'nearest' assert layer.interpolation3d == 'linear' with pytest.deprecated_call(): layer = Image(data, multiscale=True, interpolation2d='bicubic') assert layer.interpolation2d == 'cubic' with pytest.deprecated_call(): assert layer.interpolation == 'cubic' layer.interpolation2d = 'linear' with pytest.deprecated_call(): assert layer.interpolation == 'linear' assert layer.interpolation2d == 'linear' def test_colormaps(): """Test setting test_colormaps.""" shapes = [(40, 20), (20, 10), (10, 5)] np.random.seed(0) data = [np.random.random(s) for s in shapes] layer = Image(data, multiscale=True) assert layer.colormap.name == 'gray' assert isinstance(layer.colormap, Colormap) layer.colormap = 'magma' assert layer.colormap.name == 'magma' assert isinstance(layer.colormap, Colormap) cmap = Colormap([[0.0, 0.0, 0.0, 0.0], [0.3, 0.7, 0.2, 1.0]]) layer.colormap = 'custom', cmap assert layer.colormap.name == 'custom' assert layer.colormap == cmap cmap = Colormap([[0.0, 0.0, 0.0, 0.0], [0.7, 0.2, 0.6, 1.0]]) layer.colormap = {'new': cmap} assert layer.colormap.name == 'new' assert layer.colormap == cmap layer = Image(data, multiscale=True, colormap='magma') assert layer.colormap.name == 'magma' assert isinstance(layer.colormap, Colormap) cmap = Colormap([[0.0, 0.0, 0.0, 0.0], [0.3, 0.7, 0.2, 1.0]]) layer = Image(data, multiscale=True, colormap=('custom', cmap)) assert layer.colormap.name == 'custom' assert layer.colormap == cmap cmap = Colormap([[0.0, 0.0, 0.0, 0.0], [0.7, 0.2, 0.6, 1.0]]) layer = Image(data, multiscale=True, colormap={'new': cmap}) assert layer.colormap.name == 'new' assert layer.colormap == cmap def test_contrast_limits(): """Test setting color limits.""" shapes = [(40, 20), (20, 10), (10, 5)] np.random.seed(0) data = [np.random.random(s) for s in shapes] layer = Image(data, multiscale=True) assert layer.contrast_limits[0] >= 0 assert layer.contrast_limits[1] <= 1 assert layer.contrast_limits[0] < layer.contrast_limits[1] # Change contrast_limits property contrast_limits = [0, 2] layer.contrast_limits = contrast_limits assert layer.contrast_limits == contrast_limits assert layer._contrast_limits_range == contrast_limits # Set contrast_limits as keyword argument layer = Image(data, multiscale=True, contrast_limits=contrast_limits) assert layer.contrast_limits == contrast_limits assert layer._contrast_limits_range == contrast_limits def test_contrast_limits_range(): """Test setting color limits range.""" shapes = [(40, 20), (20, 10), (10, 5)] np.random.seed(0) data = [np.random.random(s) for s in shapes] layer = Image(data, multiscale=True) assert layer._contrast_limits_range[0] >= 0 assert layer._contrast_limits_range[1] <= 1 assert layer._contrast_limits_range[0] < layer._contrast_limits_range[1] # If all data is the same value the contrast_limits_range and # contrast_limits defaults to [0, 1] shapes = [(40, 20), (20, 10), (10, 5)] data = [np.zeros(s) for s in shapes] layer = Image(data, multiscale=True) assert layer._contrast_limits_range == [0, 1] assert layer.contrast_limits == [0.0, 1.0] def test_metadata(): """Test setting image metadata.""" shapes = [(40, 20), (20, 10), (10, 5)] np.random.seed(0) data = [np.random.random(s) for s in shapes] layer = Image(data, multiscale=True) assert layer.metadata == {} layer = Image(data, multiscale=True, metadata={'unit': 'cm'}) assert layer.metadata == {'unit': 'cm'} def test_value(): """Test getting the value of the data at the current coordinates.""" shapes = [(40, 20), (20, 10), (10, 5)] np.random.seed(0) data = [np.random.random(s) for s in shapes] layer = Image(data, multiscale=True) value = layer.get_value((0,) * 2) assert layer.data_level == 2 np.testing.assert_allclose(value, (2, data[2][0, 0])) def test_corner_value(): """Test getting the value of the data at the new position.""" shapes = [(40, 20), (20, 10), (10, 5)] np.random.seed(0) data = [np.random.random(s) for s in shapes] layer = Image(data, multiscale=True) value = layer.get_value((0,) * 2) target_position = (39, 19) target_level = 0 layer.data_level = target_level layer.corner_pixels[1] = shapes[target_level] # update requested view layer.refresh() # Test position at corner of image value = layer.get_value(target_position) np.testing.assert_allclose( value, (target_level, data[target_level][target_position]) ) # Test position at outside image value = layer.get_value((40, 20)) assert value[1] is None def test_message(): """Test converting value and coords to message.""" shapes = [(40, 20), (20, 10), (10, 5)] np.random.seed(0) data = [np.random.random(s) for s in shapes] layer = Image(data, multiscale=True) msg = layer.get_status((0,) * 2) assert type(msg) == dict def test_thumbnail(): """Test the image thumbnail for square data.""" shapes = [(40, 40), (20, 20), (10, 10)] np.random.seed(0) data = [np.random.random(s) for s in shapes] layer = Image(data, multiscale=True) layer._update_thumbnail() assert layer.thumbnail.shape == layer._thumbnail_shape def test_not_create_random_multiscale(): """Test instantiating Image layer with random 2D data.""" shape = (20_000, 20) np.random.seed(0) data = np.random.random(shape) layer = Image(data) assert np.all(layer.data == data) assert layer.multiscale is False def test_world_data_extent(): """Test extent after applying transforms.""" np.random.seed(0) shapes = [(6, 40, 80), (3, 20, 40), (1, 10, 20)] data = [np.random.random(s) for s in shapes] layer = Image(data) extent = np.array(((0,) * 3, shapes[0])) check_layer_world_data_extent(layer, extent, (3, 1, 1), (10, 20, 5), True) def test_5D_multiscale(): """Test 5D multiscale data.""" shapes = [(1, 2, 5, 20, 20), (1, 2, 5, 10, 10), (1, 2, 5, 5, 5)] np.random.seed(0) data = [np.random.random(s) for s in shapes] layer = Image(data, multiscale=True) assert layer.data == data assert layer.multiscale is True assert layer.ndim == len(shapes[0]) def test_multiscale_data_protocol(): """Test multiscale data provides basic data protocol.""" shapes = [(2, 5, 20, 20), (2, 5, 10, 10), (2, 5, 5, 5)] np.random.seed(0) data = [np.random.random(s) for s in shapes] layer = Image(data, multiscale=True) assert '3 levels' in repr(layer.data) assert layer.data == data assert layer.data_raw is data assert layer.data is not data assert layer.multiscale is True assert layer.data.dtype == float assert layer.data.shape == shapes[0] assert isinstance(layer.data[0], np.ndarray) @pytest.mark.parametrize( ('corner_pixels_world', 'exp_level', 'exp_corner_pixels_data'), ( ([[5, 5], [15, 15]], 0, [[5, 5], [15, 15]]), # Multiscale level selection uses > rather than >= so use -1 and 21 # instead of 0 and 20 to ensure that the FOV is big enough. ([[-1, -1], [21, 21]], 1, [[0, 0], [10, 10]]), ([[-11, -11], [31, 31]], 2, [[0, 0], [5, 5]]), ), ) def test_update_draw_variable_fov_fixed_canvas_size( corner_pixels_world, exp_level, exp_corner_pixels_data ): shapes = [(20, 20), (10, 10), (5, 5)] data = [np.zeros(s) for s in shapes] layer = Image(data, multiscale=True) canvas_size_pixels = (10, 10) layer._update_draw( scale_factor=1, corner_pixels_displayed=np.array(corner_pixels_world), shape_threshold=canvas_size_pixels, ) assert layer.data_level == exp_level np.testing.assert_equal(layer.corner_pixels, exp_corner_pixels_data) @pytest.mark.parametrize( ('canvas_size_pixels', 'exp_level', 'exp_corner_pixels_data'), ( ([16, 16], 0, [[0, 0], [20, 20]]), ([8, 8], 1, [[0, 0], [10, 10]]), ([4, 4], 2, [[0, 0], [5, 5]]), ), ) def test_update_draw_variable_canvas_size_fixed_fov( canvas_size_pixels, exp_level, exp_corner_pixels_data ): shapes = [(20, 20), (10, 10), (5, 5)] data = [np.zeros(s) for s in shapes] layer = Image(data, multiscale=True) corner_pixels_world = np.array([[0, 0], [20, 20]]) layer._update_draw( scale_factor=1, corner_pixels_displayed=corner_pixels_world, shape_threshold=canvas_size_pixels, ) assert layer.data_level == exp_level np.testing.assert_equal(layer.corner_pixels, exp_corner_pixels_data) napari-0.5.0a1/napari/layers/image/_tests/test_volume.py000066400000000000000000000131041437041365600232740ustar00rootroot00000000000000import numpy as np from napari.layers import Image from napari.layers.image._image_mouse_bindings import move_plane_along_normal def test_random_volume(): """Test instantiating Image layer with random 3D data.""" shape = (10, 15, 20) np.random.seed(0) data = np.random.random(shape) layer = Image(data) layer._slice_dims(ndisplay=3) assert np.all(layer.data == data) assert layer.ndim == len(shape) np.testing.assert_array_equal(layer.extent.data[1], shape) assert layer._data_view.shape == shape[-3:] def test_switching_displayed_dimensions(): """Test instantiating data then switching to displayed.""" shape = (10, 15, 20) np.random.seed(0) data = np.random.random(shape) layer = Image(data) assert np.all(layer.data == data) assert layer.ndim == len(shape) np.testing.assert_array_equal(layer.extent.data[1], shape) # check displayed data is initially 2D assert layer._data_view.shape == shape[-2:] layer._slice_dims(ndisplay=3) # check displayed data is now 3D assert layer._data_view.shape == shape[-3:] layer._slice_dims(ndisplay=2) # check displayed data is now 2D assert layer._data_view.shape == shape[-2:] layer = Image(data) layer._slice_dims(ndisplay=3) assert np.all(layer.data == data) assert layer.ndim == len(shape) np.testing.assert_array_equal(layer.extent.data[1], shape) # check displayed data is initially 3D assert layer._data_view.shape == shape[-3:] layer._slice_dims(ndisplay=2) # check displayed data is now 2D assert layer._data_view.shape == shape[-2:] layer._slice_dims(ndisplay=3) # check displayed data is now 3D assert layer._data_view.shape == shape[-3:] def test_all_zeros_volume(): """Test instantiating Image layer with all zeros data.""" shape = (10, 15, 20) data = np.zeros(shape, dtype=float) layer = Image(data) layer._slice_dims(ndisplay=3) assert np.all(layer.data == data) assert layer.ndim == len(shape) np.testing.assert_array_equal(layer.extent.data[1], shape) assert layer._data_view.shape == shape[-3:] def test_integer_volume(): """Test instantiating Image layer with integer data.""" shape = (10, 15, 20) np.random.seed(0) data = np.round(10 * np.random.random(shape)).astype(int) layer = Image(data) layer._slice_dims(ndisplay=3) assert np.all(layer.data == data) assert layer.ndim == len(shape) np.testing.assert_array_equal(layer.extent.data[1], shape) assert layer._data_view.shape == shape[-3:] def test_3D_volume(): """Test instantiating Image layer with random 3D data.""" shape = (10, 15, 6) np.random.seed(0) data = np.random.random(shape) layer = Image(data) layer._slice_dims(ndisplay=3) assert np.all(layer.data == data) assert layer.ndim == len(shape) np.testing.assert_array_equal(layer.extent.data[1], shape) assert layer._data_view.shape == shape[-3:] def test_4D_volume(): """Test instantiating multiple Image layers with random 4D data.""" shape = (10, 15, 6, 8) np.random.seed(0) data = np.random.random(shape) layer = Image(data) layer._slice_dims(ndisplay=3) assert np.all(layer.data == data) assert layer.ndim == len(shape) np.testing.assert_array_equal(layer.extent.data[1], shape) assert layer._data_view.shape == shape[-3:] def test_changing_volume(): """Test changing Image data.""" shape_a = (10, 15, 30) shape_b = (20, 12, 6) np.random.seed(0) data_a = np.random.random(shape_a) data_b = np.random.random(shape_b) layer = Image(data_a) layer._slice_dims(ndisplay=3) layer.data = data_b assert np.all(layer.data == data_b) assert layer.ndim == len(shape_b) np.testing.assert_array_equal(layer.extent.data[1], shape_b) assert layer._data_view.shape == shape_b[-3:] def test_scale(): """Test instantiating anisotropic 3D volume.""" shape = (10, 15, 20) scale = [3, 1, 1] full_shape = tuple(np.multiply(shape, scale)) np.random.seed(0) data = np.random.random(shape) layer = Image(data, scale=scale) layer._slice_dims(ndisplay=3) assert np.all(layer.data == data) assert layer.ndim == len(shape) np.testing.assert_array_equal( layer.extent.world[1] - layer.extent.world[0], full_shape ) pixel_extent_end = np.asarray(full_shape) - 0.5 * np.asarray(scale) np.testing.assert_array_equal(layer.extent.world[1], pixel_extent_end) # Note that the scale appears as the step size in the range assert layer._data_view.shape == shape[-3:] def test_value(): """Test getting the value of the data at the current coordinates.""" np.random.seed(0) data = np.random.random((10, 15, 20)) layer = Image(data) layer._slice_dims(ndisplay=3) value = layer.get_value((0,) * 3) assert value == data[0, 0, 0] def test_message(): """Test converting value and coords to message.""" np.random.seed(0) data = np.random.random((10, 15, 20)) layer = Image(data) layer._slice_dims(ndisplay=3) msg = layer.get_status((0,) * 3) assert type(msg) == dict def test_plane_drag_callback(): """Plane drag callback should only be active when depicting as plane.""" np.random.seed(0) data = np.random.random((10, 15, 20)) layer = Image(data, depiction='volume') assert move_plane_along_normal not in layer.mouse_drag_callbacks layer.depiction = 'plane' assert move_plane_along_normal in layer.mouse_drag_callbacks layer.depiction = 'volume' assert move_plane_along_normal not in layer.mouse_drag_callbacks napari-0.5.0a1/napari/layers/image/experimental/000077500000000000000000000000001437041365600215515ustar00rootroot00000000000000napari-0.5.0a1/napari/layers/image/experimental/__init__.py000066400000000000000000000006161437041365600236650ustar00rootroot00000000000000"""layers.image.experimental """ from napari.layers.image.experimental.octree_chunk import ( OctreeChunk, OctreeChunkGeom, ) from napari.layers.image.experimental.octree_intersection import ( OctreeIntersection, ) from napari.layers.image.experimental.octree_level import OctreeLevel __all__ = [ "OctreeChunk", "OctreeChunkGeom", "OctreeIntersection", "OctreeLevel", ] napari-0.5.0a1/napari/layers/image/experimental/_chunk_set.py000066400000000000000000000036711437041365600242540ustar00rootroot00000000000000"""ChunkSet class. Used by the OctreeLoader. """ from __future__ import annotations from typing import TYPE_CHECKING, Dict, List, Set if TYPE_CHECKING: from napari.components.experimental.chunk._request import OctreeLocation from napari.layers.image.experimental.octree_chunk import OctreeChunk class ChunkSet: """A set of chunks with fast location membership test. We use a dict as an ordered set, and then a set with just the locations so OctreeLoader._cancel_futures() can quickly test if a location is in the set. """ def __init__(self) -> None: self._dict: Dict[OctreeChunk, int] = {} self._locations: Set[OctreeLocation] = set() def __len__(self) -> int: """Return the size of the size. Returns ------- int The size of the set. """ return len(self._dict) def __contains__(self, chunk: OctreeChunk) -> bool: """Return true if the set contains this chunk. Returns ------- bool True if the set contains the given chunk. """ return chunk in self._dict def add(self, chunks: List[OctreeChunk]) -> None: """Add these chunks to the set. Parameters ---------- chunks : List[OctreeChunk] Add these chunks to the set. """ for chunk in chunks: self._dict[chunk] = 1 self._locations.add(chunk.location) def chunks(self) -> List[OctreeChunk]: """Get all the chunks in the set. Returns ------- List[OctreeChunk] All the chunks in the set. """ return self._dict.keys() def has_location(self, location: OctreeLocation) -> bool: """Return True if the set contains this location. Returns ------- bool True if the set contains this location. """ return location in self._locations napari-0.5.0a1/napari/layers/image/experimental/_chunked_image_loader.py000066400000000000000000000044031437041365600263740ustar00rootroot00000000000000"""ChunkedImageLoader class. This is for pre-Octree Image class only. """ import logging from typing import Optional from napari.layers.image._image_loader import ImageLoader from napari.layers.image.experimental._chunked_slice_data import ( ChunkedSliceData, ) from napari.layers.image.experimental._image_location import ImageLocation LOGGER = logging.getLogger("napari.loader") class ChunkedImageLoader(ImageLoader): """Load images using the Chunkloader: synchronously or asynchronously. Attributes ---------- _current : Optional[ImageLocation] The location we are currently loading or showing. """ def __init__(self) -> None: # We're showing nothing to start. self._current: Optional[ImageLocation] = None def load(self, data: ChunkedSliceData) -> bool: """Load this ChunkedSliceData (sync or async). Parameters ---------- data : ChunkedSliceData The data to load Returns ------- bool True if load happened synchronously. """ location = ImageLocation(data.layer, data.indices) LOGGER.debug("ChunkedImageLoader.load") if self._current is not None and self._current == location: # We are already showing this slice, or its being loaded # asynchronously. return False # Now "showing" this slice, even if it hasn't loaded yet. self._current = location if data.load_chunks(): return True # Load was sync, load is done. return False # Load was async, so not loaded yet. def match(self, data: ChunkedSliceData) -> bool: """Return True if slice data matches what we are loading. Parameters ---------- data : ChunkedSliceData Does this data match what we are loading? Returns ------- bool Return True if data matches. """ location = data.request.location if self._current == location: LOGGER.debug("ChunkedImageLoader.match: accept %s", location) return True # Data was for a slice we are no longer looking at. LOGGER.debug("ChunkedImageLoader.match: reject %s", location) return False napari-0.5.0a1/napari/layers/image/experimental/_chunked_slice_data.py000066400000000000000000000073441437041365600260630ustar00rootroot00000000000000"""ChunkedSliceData class. This is for pre-Octree Image class only. """ from __future__ import annotations import logging from typing import TYPE_CHECKING, Optional from napari.components.experimental.chunk import ChunkRequest, chunk_loader from napari.layers.base import Layer from napari.layers.image._image_slice_data import ImageSliceData from napari.layers.image.experimental._image_location import ImageLocation LOGGER = logging.getLogger("napari.loader") if TYPE_CHECKING: from napari.types import ArrayLike class ChunkedSliceData(ImageSliceData): """SliceData that works with ChunkLoader. Parameters ---------- layer : Layer The layer that contains the data. indices : Tuple[Optional[slice], ...] The indices of this slice. image : ArrayList The image to display in the slice. thumbnail_source : ArrayList The source used to create the thumbnail for the slice. request : Optional[ChunkRequest] The ChunkRequest that was used to load this data. """ def __init__( self, layer: Layer, indices, image: ArrayLike, thumbnail_source: ArrayLike, request: Optional[ChunkRequest] = None, ) -> None: super().__init__(layer, indices, image, thumbnail_source) # When ChunkedSliceData is first created self.request is # None, it will get set one of two ways: # # 1. Synchronous load: our load_chunks() method will set # self.request with the satisfied ChunkRequest. # # 2. Asynchronous load: Image.on_chunk_loaded() will create # a new ChunkedSliceData using our from_request() # classmethod. It will set the completed self.request. # self.request = request self.thumbnail_image = None def load_chunks(self) -> bool: """Load this slice data's chunks sync or async. Returns ------- bool True if chunks were loaded synchronously. """ # Always load the image. chunks = {'image': self.image} # Optionally load th e thumbnail_source if it exists. if self.thumbnail_source is not None: chunks['thumbnail_source'] = self.thumbnail_source def _should_cancel(chunk_request: ChunkRequest) -> bool: """Cancel any requests for this same data_id. The must be requests for other slices, but we only ever show one slice at a time, so they are stale. """ return chunk_request.location.data_id == id(self.image) # Cancel loads for any other data_id/slice besides this one. chunk_loader.cancel_requests(_should_cancel) # Create the request and load it. location = ImageLocation(self.layer, self.indices) self.request = ChunkRequest(location, chunks) satisfied_request = chunk_loader.load_request(self.request) if satisfied_request is None: return False # Load was async. # Load was sync. self.request = satisfied_request self.image = self.request.chunks.get('image') self.thumbnail_image = self.request.chunks.get('thumbnail_source') return True @classmethod def from_request(cls, layer: Layer, request: ChunkRequest): """Create an ChunkedSliceData from a ChunkRequest. Parameters ---------- layer : Layer The layer for this request. request : ChunkRequest The request that was loaded. """ indices = request.location.indices image = request.chunks.get('image') thumbnail_slice = request.chunks.get('thumbnail_slice') return cls(layer, indices, image, thumbnail_slice, request) napari-0.5.0a1/napari/layers/image/experimental/_image_location.py000066400000000000000000000057601437041365600252440ustar00rootroot00000000000000"""ImageLocation class. ImageLocation is the pre-octree Image class's ChunkLocation. When we request that the ChunkLoader load a chunk, we use this ChunkLocation to identify the chunk we are requesting and once it's loaded. """ import numpy as np from napari.components.experimental.chunk import ChunkLocation, LayerRef from napari.layers import Layer def get_data_id(data) -> int: """Return the data_id to use for this layer. Parameters ---------- data Get the data_id for this data. Notes ----- We use data_id rather than just the layer_id, because if someone changes the data out from under a layer, we do not want to use the wrong chunks. """ if isinstance(data, list): assert data # data should not be empty for image layers. return id(data[0]) # Just use the ID from the 0'th layer. return id(data) # Not a list, just use it. class ImageLocation(ChunkLocation): """The hashable location of a chunk within an image layer. Attributes ---------- data_id : int The id of the data in the layer. data_level : int The level in the data (for multi-scale). indices The indices of the slice. """ def __init__(self, layer: Layer, indices) -> None: super().__init__(LayerRef.from_layer(layer)) self.data_id: int = get_data_id(layer.data) self.data_level: int = layer._data_level self.indices = indices def __str__(self): return f"location=({self.data_id}, {self.data_level}, {self.indices}) " def __eq__(self, other) -> bool: return ( super().__eq__(other) and self.data_id == other.data_id and self.data_level == other.data_level and self._same_indices(other) ) def _same_indices(self, other) -> bool: """Return True if this location has same indices as the other location. Returns ------- bool True if indices are the same. """ # TODO_OCTREE: Why is this sometimes ndarray and sometimes not? # We should normalize when the ImageLocation is constructed? if isinstance(self.indices, np.ndarray): return (self.indices == other.indices).all() return self.indices == other.indices def __hash__(self) -> int: """Return has of this location. Returns ------- int The hash of the location. """ return hash( ( self.layer_ref.layer_id, self.data_id, self.data_level, _flatten(self.indices), ) ) def _flatten(indices) -> tuple: """Return a flat tuple of integers to represent the indices. Slice objects are not hashable, so we convert them. """ result = [] for x in indices: if isinstance(x, slice): result.extend([x.start, x.stop, x.step]) else: result.append(x) return tuple(result) napari-0.5.0a1/napari/layers/image/experimental/_octree_loader.py000066400000000000000000000437551437041365600251070ustar00rootroot00000000000000"""OctreeLoader class. Uses ChunkLoader to load data into OctreeChunks in the octree. """ from __future__ import annotations import logging from typing import TYPE_CHECKING, List, Set from napari.layers.image.experimental._chunk_set import ChunkSet from napari.layers.image.experimental.octree import Octree if TYPE_CHECKING: from napari.components.experimental.chunk import ( ChunkRequest, LayerRef, OctreeLocation, ) from napari.layers.image.experimental.octree_chunk import OctreeChunk LOGGER = logging.getLogger("napari.octree.loader") LOADER = logging.getLogger("napari.loader.futures") # TODO_OCTREE make this a config. This is how many levels "up" we look # for tiles to draw at levels above the ideal how. These tiles give # us lots of coverage quickly, so we load and draw then even before # the ideal level NUM_ANCESTOR_LEVELS = 3 class OctreeLoader: """Load data into the OctreeChunks in the octree. The loader is given drawn_set, the chunks we are currently drawing, and ideal_chunks, the chunks which are in view at the desired level of the octree. The ideal level was chosen because its image pixels best match the screen pixels. Using higher resolution than that is okay, but it's wasted time and memory. Using lower resolution is better than nothing, but it's going to be blurrier than the ideal level. Our get_drawable_chunks() method iterates through the ideal_chunks choosing what chunks to load, in what order, and producing the set of chunks the visual should draw. Choosing what chunks to load and draw is the heart of octree rendering. We use the tree structure to find child or parent chunks, or chunks futher up the tree: ancestor chunks. The goal is to pretty quickly load all the ideal chunks, since that's what we really want to draw. But in the meantime we load and display chunks at lower or high resolutions. In some cases because they already loaded and even already being drawn. In other cases though we load chunk from high level because they provide "coverage" quickly. As you go up to higher levels from the ideal level, the chunks on those levels cover more and more chunks on the ideal level. As you go up levels they cover this number of ideal chunks: 4, 16, 64. The data from higher levels is blurry compared to the ideal level, but getting something "reasonable" on the screen quickly often leads to the best user experience. For example, even "blurry" data is often good enough for them to keep navigating, to keep panning and zooming looking for whatever they are looking for. Parameters ---------- octree : Octree We are loading chunks for this octree. layer_ref : LayerRef A weak reference to the layer the octree lives in. Attributes ---------- _octree : Octree We are loading chunks for this octree. _layer_ref : LayerRef A weak reference to the layer the octree lives in. """ def __init__(self, octree: Octree, layer_ref: LayerRef) -> None: self._octree = octree self._layer_ref = layer_ref def get_drawable_chunks( self, drawn_set: Set[OctreeChunk], ideal_chunks: List[OctreeChunk], ideal_level: int, ) -> List[OctreeChunk]: """Return the chunks that should be drawn. The ideal chunks are within the bounds of the OctreeView, but they may or may not be in memory. We only return chunks which are in memory. Generally we want to draw the "best available" data. However, that data might not be at the ideal level. So we look in two directions: 1) Up, to find a chunk at a higher (coarser) level. 2) Down, to look for a drawable chunk at a lower (finer) level. The TiledImageVisual can draw overlapping tiles/chunks. For example suppose below B and C are ideal chunks, but B is drawable while C is not. We search up from C and find A. ---------- | A | | --- ---| | B | C | |--------- TiledImageVisual will render A first, because it's at a higher level, and then B. So the visual will render B and A with B on top. The region defined by C is showing A, until C is ready to draw. The first thing we do is find the best single chunk in memory that will cover all the ideal chunks, and draw that. Drawing this chunk will happen right away and ensure that something is always drawn and the canvas never flickers to empty. Worst case we draw the root tile. Next we look through all the ideal chunks and see what are the already drawn chunks that we should just leave there. If the ideal chunk has been drawn then it does not need any additional coverage and we move on. We next look to see if all four children are already drawn, this happens most often when zooming out, and if so we leave them there. If the ideal chunk is in memory we'll draw it too. If not, we then look to see what the closet in memory ancestor and closet drawn ancestors are. If they are the same then we'll draw that chunk, along with the ideal chunk if it is in memory too. If they are not the same then we'll draw the closest drawn ancestor and either the closest in memory chunk or the ideal chunk if it is in memory too. Finally, we will start loading any ideal chunks that aren't in memory that we want to draw. Parameters ---------- drawn_set : Set[OctreeChunk] The chunks which the visual is currently drawing. ideal_chunks : List[OctreeChunk] The chunks which are visible to the current view. Returns ------- List[OctreeChunk] The chunks that should be drawn. """ LOGGER.debug( "get_drawable_chunks: Starting with draw_set=%d ideal_chunks=%d", len(drawn_set), len(ideal_chunks), ) # This is an ordered set. It's a set because many ideal chunks will # have the same ancestors, but we only want them in here once. seen = ChunkSet() # Find the closest ancestor that will cover all the ideal chunks # that is in memory. Worst case take the root tile. This chunk # ensures that the best thing that we can immediately draw will # always be drawn and so the canvas will never flicker to empty # which is very disconcerting. seen.add(self._get_closest_ancestor(ideal_chunks)) # Now get coverage for the ideal chunks. The coverage chunks might # include the ideal chunk itself and/or chunks from other levels. for ideal_chunk in ideal_chunks: seen.add(self._get_coverage(ideal_chunk, drawn_set)) # Add the ideal chunks AFTER all the coverage ones, we want to load # these after, because the coverage ones cover a much bigger area, # better to see them first, even though they are lower resolution. seen.add(ideal_chunks) # Cancel in-progress loads for any chunks we can no long see. When # panning or zooming rapidly, it's very common that chunks fall out # of view before the load was even started. We need to cancel those # loads or it will tie up the loader loading chunks we aren't even # going to display. self._cancel_unseen(seen) drawable = [] # Load everything in seen if needed. for chunk in seen.chunks(): # The ideal level is priority 0, 1 is one level above idea, etc. priority = chunk.location.level_index - ideal_level if chunk.in_memory: drawable.append(chunk) elif chunk.needs_load and self._load_chunk(chunk, priority): drawable.append(chunk) # It was a sync load, ready to draw. # Useful for debugging but very spammy. # log_chunks("drawable", drawable) return drawable def _get_closest_ancestor( self, ideal_chunks: List[OctreeChunk] ) -> List[OctreeChunk]: """Get closest in memory ancestor chunk. Look through all the in memory ancestor chunks to determine the closest one. If none are found then use the root tile. Parameters ------- ideal_chunks : List[OctreeChunk] Ideal chunks. Returns ------- List[OctreeChunk] Closest in memory ancestor chunk. """ ancestors = [] for ideal_chunk in ideal_chunks: # Get the in memory ancestors of the current chunk chunk_ancestors = self._octree.get_ancestors( ideal_chunk, create=False, in_memory=True ) ancestors.append(chunk_ancestors) common_ancestors = list(set.intersection(*map(set, ancestors))) if len(common_ancestors) > 0: # Find the common ancestor with the smallest level, i.e. the highest # resolution level_indices = [c.location.level_index for c in common_ancestors] best_ancestor_index = level_indices.index(min(level_indices)) # Take the last common ancestor which will be the most recent return [common_ancestors[best_ancestor_index]] else: # No in memory common ancestors were found so return the root tile. # We say create=True because the root is not part of the current # intersection. However since it's permanent once created and # loaded it should always be available. As long as we don't garbage # collect it! root_tile = self._octree.levels[-1].get_chunk(0, 0, create=True) return [root_tile] def _get_permanent_chunks(self) -> List[OctreeChunk]: """Get any permanent chunks we want to always draw. Right now it's just the root tile. We draw this so that we always have at least some minimal coverage when the camera moves to a new place. On a big enough dataset though when zoomed in we might be "inside" a single pixel of the root tile. So it's just providing a background color at that point. Returns ------- List[OctreeChunk] Any extra chunks we should draw. """ # We say create=True because the root is not part of the current # intersection. However since it's permanent once created and # loaded it should always be available. As long as we don't garbage # collect it! root_tile = self._octree.levels[-1].get_chunk(0, 0, create=True) return [root_tile] def _get_coverage( self, ideal_chunk: OctreeChunk, drawn_set: Set[OctreeChunk] ) -> List[OctreeChunk]: """Return the chunks to draw for this one ideal chunk. If the ideal chunk is already being drawn, we return it alone. It's all we need to draw to cover the chunk. If it's not being draw we look up down the tree to find what chunks we can to draw to "cover" this chunk. Note that drawn_set might be smaller than what get_drawable_chunks has been returning, because it only contains chunks that are actually got drawn to the screen. That are in VRAM. The visual might take time to load chunks into VRAM. So we might return the same chunks from get_drawable_chunks() many times in a row before it gets drawn. It might only one chunk per frame into VRAM, for example. Parameters ---------- ideal_chunk : OctreeChunk The ideal chunk we'd like to draw. drawn_set : Set[OctreeChunk] The chunks which the visual is currently drawing. Returns ------- List[OctreeChunk] The chunks that should be drawn to cover this one ideal chunk. """ # If the ideal chunk is already being drawn, that's all we need, # there is no point in returning more than that. if ideal_chunk.in_memory and ideal_chunk in drawn_set: return [ideal_chunk] # If not, get alternates for this chunk, from other levels. # If the ideal chunk is in memory then we'll want to draw that one # too though if ideal_chunk.in_memory: best_in_memory_chunk = [ideal_chunk] else: best_in_memory_chunk = [] # First get any direct children which are in memory. Do not create # OctreeChunks or use children that are not already in memory # because it's better to create and load higher levels. children = self._octree.get_children( ideal_chunk, create=False, in_memory=True ) # Only keep the children which are already drawn, as drawing is # expensive don't want to draw them unnecessarily. children = [chunk for chunk in children if chunk in drawn_set] # If all children are in memory and are already drawn just return them # as they will cover the whole chunk. ndim = 2 # right now we only support a 2D quadtree if len(children) == 2**ndim: return children + best_in_memory_chunk # Get the closest ancestor that is already in memory that # covers the ideal chunk. Don't create chunks because it is better to # just create the ideal chunks. Note that the most distant ancestor is # returned first, so need to look at the end of the list to get closest # one. ancestors = self._octree.get_ancestors( ideal_chunk, create=False, in_memory=True ) # Get the drawn ancestors drawn_ancestors = [chunk for chunk in ancestors if chunk in drawn_set] # Get the closest in memory ancestor if len(ancestors) > 0: ancestors = [ancestors[-1]] # Get the closest drawn ancestor if len(drawn_ancestors) > 0: drawn_ancestors = [drawn_ancestors[-1]] # If the closest ancestor is drawn just take that one if len(ancestors) > 0 and ancestors == drawn_ancestors: return children + drawn_ancestors + best_in_memory_chunk else: # If the ideal chunk is in memory take that one if len(best_in_memory_chunk) > 0: return children + drawn_ancestors + best_in_memory_chunk else: # Otherwise that the close in memory ancestor return children + drawn_ancestors + ancestors def _load_chunk(self, octree_chunk: OctreeChunk, priority: int) -> None: """Load the data for one OctreeChunk. Parameters ---------- octree_chunk : OctreeChunk Load the data for this chunk. """ # We only want to load a chunk if it's not already in memory, if a # load was not started on it. assert not octree_chunk.in_memory assert not octree_chunk.loading # The ChunkLoader takes a dict of chunks that should be loaded at # the same time. Today we only ever ask it to a load a single chunk # at a time. In the future we might want to load multiple layers at # once, so they are in sync, or load multiple locations to bundle # things up for efficiency. chunks = {'data': octree_chunk.data} # Mark that this chunk is being loaded. octree_chunk.loading = True from napari.components.experimental.chunk import ( ChunkRequest, chunk_loader, ) # Create the ChunkRequest and load it with the ChunkLoader. request = ChunkRequest(octree_chunk.location, chunks, priority) satisfied_request = chunk_loader.load_request(request) if satisfied_request is None: # An async load was initiated. The load will probably happen in a # worker thread. When the load completes QtChunkReceiver will call # OctreeImage.on_chunk_loaded() with the data. return False # The load was synchronous. Some situations were the # ChunkLoader loads synchronously: # # 1) The force_synchronous config option is set. # 2) The data already was an ndarray, there's nothing to "load". # 3) The data is Dask or similar, but based on past loads it's # loading so quickly that we decided to load it synchronously. # 4) The data is Dask or similar, but we already loaded this # exact chunk before, so it was in the cache. # # Whatever the reason, the data is now ready to draw. octree_chunk.data = satisfied_request.chunks.get('data') # The chunk has been loaded, it's now a drawable chunk. assert octree_chunk.in_memory return True def _cancel_unseen(self, seen: ChunkSet) -> None: """Cancel in-progress loads not in the seen set. Parameters ---------- seen : ChunkSet The set of chunks the loader can see. """ from napari.components.experimental.chunk import chunk_loader def _should_cancel(chunk_request: ChunkRequest) -> bool: """Cancel if we are no longer seeing this location.""" return not seen.has_location(chunk_request.location) cancelled = chunk_loader.cancel_requests(_should_cancel) for request in cancelled: self._on_cancel_request(request.location) def _on_cancel_request(self, location: OctreeLocation) -> None: """Request for this location was cancelled. Parameters ---------- location : OctreeLocation Set that this chunk is no longer loading. """ # Get chunk for this location, don't create the chunk, but it ought # to be there since there was a load in progress. chunk: OctreeChunk = self._octree.get_chunk_at_location( location, create=False ) if chunk is None: LOADER.error("_cancel_load: Chunk did not exist %s", location) return # Chunk is no longer loading. chunk.loading = False napari-0.5.0a1/napari/layers/image/experimental/_octree_slice.py000066400000000000000000000176671437041365600247430ustar00rootroot00000000000000"""OctreeSlice class. For viewing one slice of a multiscale image using an octree. """ from __future__ import annotations import logging import math from typing import TYPE_CHECKING, Optional import numpy as np from napari.layers.image.experimental._octree_loader import OctreeLoader from napari.layers.image.experimental.octree import Octree from napari.layers.image.experimental.octree_intersection import ( OctreeIntersection, OctreeView, ) from napari.layers.image.experimental.octree_level import ( OctreeLevel, OctreeLevelInfo, ) from napari.layers.image.experimental.octree_util import OctreeMetadata from napari.utils.translations import trans LOGGER = logging.getLogger("napari.octree.slice") if TYPE_CHECKING: from napari.components.experimental.chunk import ( ChunkRequest, LayerRef, OctreeLocation, ) from napari.layers.image.experimental.octree_chunk import OctreeChunk class OctreeSlice: """A viewed slice of a multiscale image using an octree. Parameters ---------- data The multi-scale data. layer_ref : LayerRef Reference to the layer containing the slice. meta : OctreeMetadata The base shape and other info. Attributes ---------- loader : OctreeLoader Uses the napari ChunkLoader to load OctreeChunks. """ def __init__( self, data, layer_ref: LayerRef, meta: OctreeMetadata, ) -> None: self.data = data self._meta = meta slice_id = id(self) self._octree = Octree(slice_id, data, meta) self.loader: OctreeLoader = OctreeLoader(self._octree, layer_ref) thumbnail_image = np.zeros( (32, 32, 3) ) # blank until we have a real one self.thumbnail = thumbnail_image @property def loaded(self) -> bool: """True if the data has been loaded. Because octree multiscale is async, we say we are loaded up front even though none of our chunks/tiles might be loaded yet. Returns ------- bool True if the data as been loaded. """ return self.data is not None @property def octree_level_info(self) -> Optional[OctreeLevelInfo]: """Information about the current octree level. Returns ------- Optional[OctreeLevelInfo] Information about current octree level, if there is one. """ if self._octree is None: return None try: return self._octree.levels[self.octree_level].info except IndexError as exc: index = self.octree_level num_levels = len(self._octree.levels) raise IndexError( trans._( "Octree level {index} is not in range(0, {num_levels})", deferred=True, index=index, num_levels=num_levels, ) ) from exc def get_intersection(self, view: OctreeView) -> OctreeIntersection: """Return the given view's intersection with the octree. The OctreeIntersection primarily contains the set of tiles at some level that need to be drawn to depict view. The "ideal level" is generally chosen automatically based on the screen resolution described by the OctreeView. Parameters ---------- view : OctreeView Intersect this view with the octree. Returns ------- OctreeIntersection The given view's intersection with the octree. """ level = self._get_auto_level(view) return OctreeIntersection(level, view) def _get_auto_level(self, view: OctreeView) -> OctreeLevel: """Return the automatically selected octree level for this view. Parameters ---------- view : OctreeView Get the OctreeLevel for this view. Returns ------- OctreeLevel The automatically chosen OctreeLevel. """ index = self._get_auto_level_index(view) if index < 0 or index >= self._octree.num_levels: raise ValueError( trans._( "Invalid octree level {index}", deferred=True, index=index, ) ) return self._octree.levels[index] def _get_auto_level_index(self, view: OctreeView) -> int: """Return the automatically selected octree level index for this view. Parameters ---------- view : OctreeView Get the octree level index for this view. Returns ------- int The automatically chosen octree level index. """ if not view.auto_level: # Return current level, do not update it. return self.octree_level # Find the right level automatically. Choose a level where the texels # in the octree tiles are around the same size as screen pixels. # We can do this smarter in the future, maybe have some hysteresis # so you don't "pop" to the next level as easily, so there is some # sort of dead zone between levels? ratio = view.data_width / view.canvas[0] if ratio <= 1: return 0 # Show the best we've got! # Choose the right level... max_level = self._octree.num_levels - 1 return min(math.floor(math.log2(ratio)), max_level) def _get_octree_chunk(self, location: OctreeLocation) -> OctreeChunk: """Return the OctreeChunk at his location. Do not create the chunk if it doesn't exist. Parameters ---------- location : OctreeLocation Return the chunk at this location. Returns ------- OctreeChunk The returned chunk. """ level = self._octree.levels[location.level_index] return level.get_chunk(location.row, location.col, create=False) def on_chunk_loaded(self, request: ChunkRequest) -> bool: """Called when an asynchronous ChunkRequest was loaded. This overrides Image.on_chunk_loaded() fully. Parameters ---------- request : ChunkRequest The request for the chunk that was loaded. Returns ------- bool True if the chunk's data was added to the octree. """ location = request.location if location.slice_id != id(self): # There was probably a load in progress when the slice was changed. # The original load finished, but we are now showing a new slice. # Don't consider it error, just ignore the chunk. LOGGER.debug( "on_chunk_loaded: wrong slice_id: %s", location, ) return False # Do not add the chunk. octree_chunk = self._get_octree_chunk(location) if octree_chunk is None: # This location in the octree does not contain an OctreeChunk. # That's unexpected, because locations are turned into # OctreeChunk's when a load is initiated. So this is an error, # but log it and keep going, maybe some transient weirdness? LOGGER.error( "on_chunk_loaded: missing OctreeChunk: %s", octree_chunk, ) return False # Did not add the chunk. LOGGER.debug("on_chunk_loaded: adding %s", octree_chunk) # Get the data from the request. incoming_data = request.chunks.get('data') # Loaded data should always be an ndarray. assert isinstance(incoming_data, np.ndarray) # Add that data to the octree's OctreeChunk. Now the chunk can be draw. octree_chunk.data = incoming_data # Setting data should mean: assert octree_chunk.in_memory assert not octree_chunk.needs_load return True # Chunk was added. napari-0.5.0a1/napari/layers/image/experimental/_tests/000077500000000000000000000000001437041365600230525ustar00rootroot00000000000000napari-0.5.0a1/napari/layers/image/experimental/_tests/test_location.py000066400000000000000000000025021437041365600262720ustar00rootroot00000000000000import numpy as np from napari.layers.image import Image def _create_layer() -> Image: """Return a small random Image layer.""" data = np.random.random((32, 16)) return Image(data) def test_image_location(): """Test the pre-octree ImageLocation class. An ImageLocation is just BaseLocation plus indices. """ from napari.layers.image.experimental._image_location import ImageLocation layer1 = _create_layer() layer2 = _create_layer() locations1_0 = ( ImageLocation(layer1, (0, 0)), ImageLocation(layer1, (0, 0)), ) locations1_1 = ( ImageLocation(layer1, (0, 1)), ImageLocation(layer1, (0, 1)), ) locations2_0 = ( ImageLocation(layer2, (0, 0)), ImageLocation(layer2, (0, 0)), ) locations2_1 = ( ImageLocation(layer2, (0, 1)), ImageLocation(layer2, (0, 1)), ) # All identical pairs should be the same. assert locations1_0[0] == locations1_0[1] assert locations1_1[0] == locations1_1[1] assert locations2_0[0] == locations2_0[1] assert locations2_1[0] == locations2_1[1] # Nothing else should be the same for i in range(0, 2): assert locations1_0[i] != locations1_1[i] assert locations1_0[i] != locations2_0[i] assert locations1_0[i] != locations2_1[i] napari-0.5.0a1/napari/layers/image/experimental/_tests/test_octree_import.py000066400000000000000000000010111437041365600273270ustar00rootroot00000000000000import os import subprocess import sys from napari._tests.utils import skip_on_win_ci CREATE_VIEWER_SCRIPT = """ import numpy as np import napari v = napari.view_image(np.random.rand(512, 512)) """ @skip_on_win_ci def test_octree_import(): """Test we can create a viewer with NAPARI_OCTREE.""" cmd = [sys.executable, '-c', CREATE_VIEWER_SCRIPT] env = os.environ.copy() env['NAPARI_OCTREE'] = '1' env['NAPARI_CONFIG'] = '' # don't try to save config subprocess.run(cmd, check=True, env=env) napari-0.5.0a1/napari/layers/image/experimental/_tests/test_octree_util.py000066400000000000000000000013151437041365600270010ustar00rootroot00000000000000import pytest from napari.layers.image.experimental.octree_util import ( linear_index, spiral_index, ) @pytest.mark.parametrize( "ranges", [ [(0, 7), (0, 9)], [(0, 8), (0, 8)], [(0, 8), (0, 9)], [(0, 8), (0, 10)], [(10, 23), (10, 24)], [(21, 38), (2, 15)], [(22, 38), (2, 16)], ], ) def test_spiral_index_against_linear(ranges): """Test spiral index set and linear index set match""" row_range, col_range = ranges row_range = range(*row_range) col_range = range(*col_range) spiral = set(list(spiral_index(row_range, col_range))) linear = set(list(linear_index(row_range, col_range))) assert spiral == linear napari-0.5.0a1/napari/layers/image/experimental/octree.py000066400000000000000000000340071437041365600234100ustar00rootroot00000000000000"""Octree class. """ from __future__ import annotations import logging import math from typing import TYPE_CHECKING, List, Optional from napari.layers.image.experimental.octree_level import ( OctreeLevel, log_levels, ) from napari.layers.image.experimental.octree_tile_builder import ( create_downsampled_levels, ) from napari.layers.image.experimental.octree_util import OctreeMetadata from napari.utils.perf import block_timer from napari.utils.translations import trans LOGGER = logging.getLogger("napari.octree") if TYPE_CHECKING: from napari.components.experimental.chunk._request import OctreeLocation from napari.layers.image.experimental.octree_chunk import OctreeChunk class Octree: """A sparse region octree that holds hold 2D or 3D images. The Octree is sparse in that it contains the ArrayLike data for the image, but it does not contain chunks or nodes or anything for that data. There is no actual tree for the data, only the multiscale data itself. Instead Octree just provides methods so that other classes like OctreeLevel, OctreeSlice and OctreeLoader can create and store OctreeChunks for just the portion of the tree we are rendering. This class knows how to go from chunks to their parents or children, but those parents or children are only created as needed. Notes ----- Future work related to geometry: Eventually we want our octree to hold geometry, not just images. Geometry such as points and meshes. For geometry a sparse octree might make more sense than this full/complete region octree. With geometry there might be lots of empty space in between small dense pockets of geometry. Some parts of tree might need to be very deep, but it would be a waste for the tree to be that deep everywhere. Parameters ---------- slice_id : int: The id of the slice this octree is in. data The underlying multi-scale data. meta : OctreeMetadata The base shape and other information. """ def __init__(self, slice_id: int, data, meta: OctreeMetadata) -> None: self.slice_id = slice_id self.data = data self.meta = meta _check_downscale_ratio(self.data) # We expect a ratio of 2. self.levels = [ OctreeLevel(slice_id, data[i], meta, i) for i in range(len(data)) ] if not self.levels: # Probably we will allow empty trees, but for now raise: raise ValueError( trans._( "Data of shape {shape} resulted no octree levels?", deferred=True, shape=data.shape, ) ) LOGGER.info("Multiscale data has %d levels.", len(self.levels)) # If there is more than one level and the root level contains more # than one tile, add extra levels until the root does consist of a # single tile. We have to do this because we cannot draw tiles larger # than the standard size right now. # If there is only one level than we'll only ever be able to show tiles # from that level. if len(self.data) > 1 and self.levels[-1].info.num_tiles > 1: self.levels.extend(self._get_extra_levels()) LOGGER.info("Octree now has %d total levels:", len(self.levels)) log_levels(self.levels) # Now the root should definitely contain only a single tile if there is # more than one level if len(self.data) > 1: assert self.levels[-1].info.num_tiles == 1 # This is now the total number of levels, including the extra ones. self.num_levels = len(self.data) def get_level(self, level_index: int) -> OctreeLevel: """Get the given OctreeLevel. Parameters ---------- level_index : int Get the OctreeLevel with this index. Returns ------- OctreeLevel The requested level. """ try: return self.levels[level_index] except IndexError as exc: raise IndexError( trans._( "Level {level_index} is not in range(0, {top})", deferred=True, level_index=level_index, top=len(self.levels), ) ) from exc def get_chunk_at_location( self, location: OctreeLocation, create: bool = False ) -> None: """Get chunk get this location, create if needed if create=True. Parameters ---------- location : OctreeLocation Get chunk at this location. create : bool If True create the chunk if it doesn't exist. """ return self.get_chunk( location.level_index, location.row, location.col, create=create ) def get_chunk( self, level_index: int, row: int, col: int, create=False ) -> Optional[OctreeChunk]: """Get chunk at this location, create if needed if create=True Parameters ---------- level_index : int Get chunk from this level. row : int Get chunk at this row. col : int Get chunk at this col. """ level: OctreeLevel = self.get_level(level_index) return level.get_chunk(row, col, create=create) def get_parent( self, octree_chunk: OctreeChunk, create: bool = False, ) -> Optional[OctreeChunk]: """Return the parent of this octree_chunk. If the chunk at the root level, then this always return None. Otherwise it returns the parent if it exists, or if create=True it creates the parent and returns it. Parameters ---------- octree_chunk : OctreeChunk Return the parent of this chunk. Returns ------- Optional[OctreeChunk] The parent of the chunk if there was one or we created it. """ ancestors = self.get_ancestors(octree_chunk, 1, create=create) # If no parent exists yet then returns None if len(ancestors) == 0: return None else: return ancestors[0] def get_ancestors( self, octree_chunk: OctreeChunk, num_levels=None, create=False, in_memory: bool = False, ) -> List[OctreeChunk]: """Return the num_levels nearest ancestors. If create=False only returns the ancestors if they exist. If create=True it will create the ancestors as needed. Parameters ---------- octree_chunk : OctreeChunk Return the nearest ancestors of this chunk. num_levels : int, optional Number of levels to look. If not provided then all are looked back till the root level. create : bool Whether to create the chunk of not is it doesn't exist. in_memory : bool Whether to return only in memory chunks or not. Returns ------- List[OctreeChunk] Up to num_level nearest ancestors of the given chunk. Sorted so the most-distant ancestor comes first. """ ancestors = [] location = octree_chunk.location # Starting point, we look up from here. level_index = location.level_index row, col = location.row, location.col if num_levels is None: stop_level = self.num_levels - 1 else: stop_level = min(self.num_levels - 1, level_index + num_levels) # Search up one level at a time. while level_index < stop_level: # Get the next level up. Coords are halved each level. level_index += 1 row, col = int(row / 2), int(col / 2) # Get chunk at this location. ancestor = self.get_chunk(level_index, row, col, create=create) if create: assert ancestor # Since create=True ancestors.append(ancestor) # Keep non-None children, and if requested in-memory ones. def keep_chunk(octree_chunk) -> bool: return octree_chunk is not None and ( not in_memory or octree_chunk.in_memory ) # Reverse to provide the most distant ancestor first. return list(filter(keep_chunk, reversed(ancestors))) def get_children( self, octree_chunk: OctreeChunk, create: bool = False, in_memory: bool = True, ) -> List[OctreeChunk]: """Return the children of this octree_chunk. If create is False then we only return children that exist, so we will return between 0 and 4 children. If create is True then we will create any children that don't exist. If octree_chunk is in level 0 then we will always return 0 children. Parameters ---------- octree_chunk : OctreeChunk Return the children of this chunk. Returns ------- List[OctreeChunk] The children of the given chunk. """ location = octree_chunk.location if location.level_index == 0: return [] # This is the base level so no children. child_level_index: int = location.level_index - 1 child_level: OctreeLevel = self.levels[child_level_index] row, col = location.row * 2, location.col * 2 children = [ child_level.get_chunk(row, col, create=create), child_level.get_chunk(row, col + 1, create=create), child_level.get_chunk(row + 1, col, create=create), child_level.get_chunk(row + 1, col + 1, create=create), ] # Keep non-None children, and if requested in-memory ones. def keep_chunk(octree_chunk) -> bool: return octree_chunk is not None and ( not in_memory or octree_chunk.in_memory ) return list(filter(keep_chunk, children)) def _get_extra_levels(self) -> List[OctreeLevel]: """Compute the extra levels and return them. Returns ------- List[OctreeLevel] The extra levels. """ with block_timer("_create_extra_levels") as timer: extra_levels = self._create_extra_levels(self.slice_id) LOGGER.info( "Created %d additional levels in %.3fms", len(extra_levels), timer.duration_ms, ) return extra_levels def _create_extra_levels(self, slice_id: int) -> List[OctreeLevel]: """Add additional levels to the octree. Keep adding levels until we each a root level where the image data fits inside a single tile. Parameters ---------- slice_id : int The id of the slice this octree is in. Returns ------- List[OctreeLevels] The new downsampled levels we created. Notes ----- Whoever created this multiscale data probably did not know our tile size. So their root level might be pretty large. We've seen root levels larger than 8000x8000 pixels. Since our visual can't have variable size tiles or large tiles yet, we compute/downsample additional levels ourselves and add them to the data. We do this until we reach a level that fits within a single tile. For example, for that 8000x8000 pixel root level, if we are using (256, 256) tiles we'll keep adding levels until we get to a level that fits within (256, 256). Unfortunately, we can't be sure our downsampling approach will visually match the rest of the data. That's probably okay because these are the lowest-resolution levels. But this another reason it'd be better if our visuals could draw large tiles when needed. Also, downsampling can be very slow. """ # Create additional data levels so that the root level # consists of only a single tile, using our standard/only # tile size. tile_size = self.meta.tile_size new_levels = create_downsampled_levels( self.data[-1], len(self.data), tile_size ) # Add the data. self.data.extend(new_levels) # Return an OctreeLevel for each new data level. num_current = len(self.levels) return [ OctreeLevel( slice_id, new_data, self.meta, num_current + index, ) for index, new_data in enumerate(new_levels) ] def print_info(self): """Print information about our tiles.""" for level in self.levels: level.print_info() def _check_downscale_ratio(data) -> None: """Raise exception if downscale ratio is not 2. For now we only support downscale ratios of 2. We could support other ratios, but the assumption that each octree level is half the size of the previous one is baked in pretty deeply right now. Raises ------ ValueError If downscale ratio is not 2. """ if not isinstance(data, list) or len(data) < 2: return # There aren't even two levels. # _dump_levels(data) ratio = math.sqrt(data[0].size / data[1].size) # Really should be exact, but it will most likely be off by a ton # if its off, so allow a small fudge factor. if not math.isclose(ratio, 2, rel_tol=0.01): raise ValueError( trans._( "Multiscale data has downsampling ratio of {ratio}, expected 2.", deferred=True, ratio=ratio, ) ) def _dump_levels(data) -> None: """Print the levels and the size of the levels.""" last_size = None for level in data: if last_size is not None: downscale = math.sqrt(last_size / level.size) print( f"size={level.size} shape={level.shape} downscale={downscale}" ) else: print(f"size={level.size} shape={level.shape} base level") last_size = level.size napari-0.5.0a1/napari/layers/image/experimental/octree_chunk.py000066400000000000000000000121171437041365600245760ustar00rootroot00000000000000"""OctreeChunkGeom, OctreeLocation and OctreeChunk classes. """ from __future__ import annotations import logging from typing import TYPE_CHECKING, List, NamedTuple import numpy as np if TYPE_CHECKING: from napari.components.experimental.chunk._request import OctreeLocation from napari.types import ArrayLike LOGGER = logging.getLogger("napari.octree") class OctreeChunkGeom(NamedTuple): """Position and size of the chunk, for rendering.""" pos: np.ndarray size: np.ndarray class OctreeChunk: """A geographically meaningful portion of the full 2D or 3D image. For 2D images a chunk is a "tile". It's a 2D square region of pixels which are part of the full 2D image. For level 0 of the octree, the pixels are 1:1 identical to the full image. For level 1 or greater the pixels are downsampled from the full resolution image. For 3D, not yet implemented, a chunk is a sub-volume. Again for level 0 the voxels are at the full resolution of the full image, but for other levels the voxels are downsampled. The highest level of the tree contains a single chunk which depicts the entire image, whether 2D or 3D. Parameters ---------- data : ArrayLike The data to draw for this chunk. location : OctreeLocation The location of this chunk, including the level_index, row, col. geom : OctreeChunkGeom The position and size of the chunk. Attributes ---------- _orig_data : ArrayLike The original unloaded data that we use to implement OctreeChunk.clear(). loading : bool If True the chunk has been queued to be loaded. """ def __init__( self, data: ArrayLike, location: OctreeLocation, geom: OctreeChunkGeom ) -> None: self._data = data self.location = location self.geom = geom self.loading = False # Are we currently being loaded. self._orig_data = data # For clear(), this might go away. def __str__(self): return f"{self.location}" def __hash__(self): return hash(self.location) @property def data(self) -> ArrayLike: """Return the data associated with this chunk. Before the chunk has been loaded this might be an ndarray or it might be Dask array or other array-like object. After the chunk has been loaded it will always be an ndarray. By "loaded" we mean the bytes are in memory and ready to be drawn. """ return self._data @data.setter def data(self, data: np.ndarray) -> None: """Set the new data for this chunk. We set the data after a chunk has been loaded. Parameters ---------- data : np.ndarray The new data for the chunk. """ # An ndarray means the data is actual bytes in memory. assert isinstance(data, np.ndarray) # Assign and note the loading process has now finished. self._data = data self.loading = False @property def in_memory(self) -> bool: """Return True if the data is fully in memory. Returns ------- bool True if data is fully in memory. """ return isinstance(self.data, np.ndarray) @property def needs_load(self) -> bool: """Return true if this chunk needs to loaded. An unloaded chunk's data might be a Dask or similar deferred array. A loaded chunk's data is always an ndarray. Returns ------- True if the chunk needs to be loaded. """ return not self.in_memory and not self.loading def clear(self) -> None: """Clear out our loaded data, return to the original. This is only done when running without the cache, so that we reload the data again. With computation the loaded data might be different each time, so we need to do it each time. TODO_OCTREE: Can we get rid of clear() if we always nuke the contents of every chunk as soon as it's no longer in view? If we do that the same chunk will have to be re-created if it comes into view a second time, but in most cases the data itself should be cached so that shouldn't take long. """ self._data = self._orig_data self.loading = False def log_chunks( label: str, chunks: List[OctreeChunk], location: OctreeLocation = None, ) -> None: """Log the given chunks with an intro header message. Parameters ---------- label : str Prefix the log message with this label. chunks : List[OctreeChunk] The chunks to log. location : Optional[OctreeLocation] Append the log message with this location. """ if location is None: LOGGER.debug("%s has %d chunks:", label, len(chunks)) else: LOGGER.debug("%s has %d chunks at %s", label, len(chunks), location) for i, chunk in enumerate(chunks): LOGGER.debug( "Chunk %d %s in_memory=%d loading=%d", i, chunk.location, chunk.in_memory, chunk.loading, ) napari-0.5.0a1/napari/layers/image/experimental/octree_image.py000066400000000000000000000423011437041365600245460ustar00rootroot00000000000000"""OctreeImage class. An eventual replacement for Image that combines single-scale and chunked (tiled) multi-scale into one implementation. """ from __future__ import annotations import logging from typing import TYPE_CHECKING, List, Set import numpy as np from napari.layers.image.experimental._octree_slice import ( OctreeSlice, OctreeView, ) from napari.layers.image.experimental.octree_chunk import OctreeChunk from napari.layers.image.experimental.octree_intersection import ( OctreeIntersection, ) from napari.layers.image.experimental.octree_level import OctreeLevelInfo from napari.layers.image.experimental.octree_util import ( OctreeDisplayOptions, OctreeMetadata, ) from napari.layers.image.image import _ImageBase from napari.utils.events import Event from napari.utils.translations import trans if TYPE_CHECKING: from napari.components.experimental.chunk import ChunkRequest LOGGER = logging.getLogger("napari.octree.image") class _OctreeImageBase(_ImageBase): """Image layer rendered using an octree. Experimental variant of Image that renders using an octree. For 2D images the octree is really just a quadtree. For 3D volumes it will be a real octree. This class is intended to eventually replace the existing Image class. Notes ----- The original Image class handled single-scale and multi-scale images, but they were handled quite differently. And its multi-scale did not use chunks or tiles. It worked well on local data, but was basically unusable for remote or high latency data. OctreeImage always uses chunk/tiles. Today those tiles are always "small". However, as a special case, if an image is smaller than the max texture size, we could some day allow OctreeImage to set its tile size equal to that image size. At that point "small" images would be draw with a single texture, the same way the old Image class drew then. So it would be very efficient. But larger images would have multiple chunks/tiles and multiple levels. Unlike the original Image class multi-scale, the chunks/tiles mean we only have to incrementally load more data as the user pans and zooms. The goal is OctreeImage gets renamed to just Image and it efficiently handles images of any size. It make take a while to get there. Attributes ---------- _view : OctreeView Describes a view frustum which implies what portion of the OctreeImage needs to be draw. _slice : OctreeSlice When _set_view_slice() is called we create a OctreeSlice() that's looking at some specific slice of the data. _display : OctreeDisplayOptions Settings for how we draw the octree, such as tile size. """ def __init__(self, *args, **kwargs) -> None: self._view: OctreeView = None self._slice: OctreeSlice = None self._intersection: OctreeIntersection = None self._display = OctreeDisplayOptions() # super().__init__ will call our _set_view_slice() which is kind # of annoying since we are aren't fully constructed yet. super().__init__(*args, **kwargs) # Call after super().__init__ self.events.add(octree_level=Event, tile_size=Event) # TODO_OCTREE: this is hack that we assign OctreeDisplayOptions # this event after super().__init__(). Needs to be cleaned up. self._display.loaded_event = self.events.loaded def _get_value(self, position): """Override Image._get_value(position).""" return (0, (0, 0)) # TODO_OCTREE: need to implement this. @property def loaded(self) -> bool: """Has the data for this layer been loaded yet. As far as the visual system is concerned we are always "loaded" in that we can always be drawn. Because our VispyTiledImageLayer can always be drawn. Even if no chunk/tiles are loaded yet. """ return True @property def _empty(self) -> bool: """Is this layer completely empty so it can't be drawn. As with self.loaded, we are never really empty. Our VispyTiledImageLayer can always be drawn. Even if there is nothing to draw. """ return False def _update_thumbnail(self): # TODO_OCTREE: replace Image._update_thumbnail with nothing for # the moment until we decide how to do thumbnail. pass @property def _data_view(self): """Viewable image for the current slice. (compatibility)""" # Override Image._data_view return np.zeros((64, 64, 3)) # fake: does octree need this? @property def display(self) -> OctreeDisplayOptions: """The display options for this octree image layer.""" return self._display @property def tile_size(self) -> int: """Return the edge length of single tile, for example 256. Returns ------- int The edge length of a single tile. """ return self._display.tile_size @tile_size.setter def tile_size(self, tile_size: int) -> None: """Set new tile_size. Parameters ---------- tile_size : int The new tile size. """ self._display.tile_size = tile_size self.events.tile_size() self._slice = None # For now must explicitly delete it self.refresh() # Creates a new slice. @property def tile_shape(self) -> tuple: """Return the shape of a single tile, for example 256x256x3. Returns ------- tuple The shape of a single tile. """ if self.multiscale: init_shape = self.data[0].shape else: init_shape = self.data.shape tile_shape = (self.tile_size, self.tile_size) if self.rgb: # Add the color dimension (usually 3 or 4) tile_shape += (init_shape[-1],) return tile_shape @property def meta(self) -> OctreeMetadata: """Information about the current octree. Returns ------- OctreeMetadata Octree dimensions and other info. """ if self._slice is None: return None return self._slice.meta @property def octree_level_info(self) -> OctreeLevelInfo: """Information about the current level of the current octree. Returns ------- OctreeLevelInfo Information about the current octree level. """ if self._slice is None: return None return self._slice.octree_level_info @property def data_level(self) -> int: """Current level of multiscale. The base full resolution image is level 0. The highest and coarsest level usually contains only a single tile. """ return self._data_level @data_level.setter def data_level(self, level: int) -> None: """Set the octree level we should be displaying. Parameters ---------- level : int Display this octree level. """ if self._data_level == level: return # It didn't change. # Quickly check for less than 0. We can't check for a level # that's too high because the Octree might have extended levels? if level < 0: raise ValueError( trans._( "Octree level {level} is negative.", deferred=True, level=level, ) ) self._data_level = level self.events.octree_level() if self._slice is not None: # This will raise if the level is too high. self._slice.octree_level = level self.events.loaded() # redraw @property def num_octree_levels(self) -> int: """Return the total number of octree levels. Returns ------- int The number of octree levels. """ return len(self.data) # Multiscale def _new_empty_slice(self) -> None: """Initialize the current slice to an empty image. Overides Image._new_empty_slice() and does nothing because we don't need an empty slice. We create self._slice when self._set_view_slice() is called. The empty slice was needed to satisfy the old VispyImageLayer that used a single ImageVisual. But OctreeImage is drawn with VispyTiledImageVisual. It does not need an empty image. It gets chunks from our self.drawable_chunks property, and it will just draw nothing if that returns an empty list. When OctreeImage become the only image class, this can go away. """ def get_drawable_chunks( self, drawn_set: Set[OctreeChunk] ) -> List[OctreeChunk]: """Get the chunks in the current slice which are drawable. The visual calls this and then draws what we send it. The call to get_intersection() will chose the appropriate level of the octree to intersect, and then return all the chunks within the intersection with that level. These are the "ideal" chunks because they are at the level whose resolution best matches the current screen resolution. Drawing chunks at a lower level than this will work fine, but it's a waste in that those chunks will just be downsampled by the card. You won't see any "extra" resolution at all. The card can do this super fast, so the issue not such much speed as it is RAM and VRAM. In the opposite direction, drawing chunks from a higher, the number of chunks and storage goes down quickly. The only issue there is visual quality, the imagery might look blurry. Parameters ---------- drawn_set : Set[OctreeChunk] The chunks that are currently being drawn by the visual. Returns ------- List[OctreeChunk] The drawable chunks. """ if self._slice is None or self._view is None: LOGGER.debug("get_drawable_chunks: No slice or view") return [] # There is nothing to draw. # TODO_OCTREE: Make this a config option, maybe different # expansion_factor each level above the ideal level? expansion_factor = 1.1 view = self._view.expand(expansion_factor) # Get the current intersection and save it off. self._intersection = self._slice.get_intersection(view) if self._intersection is None: LOGGER.debug("get_drawable_chunks: Intersection is empty") return [] # No chunks to draw. # Get the ideal chunks. These are the chunks at the preferred # resolution. The ones we ideally want to draw once they are in RAM # and in VRAM. When all loading is done, we will draw all the ideal # chunks. ideal_chunks = self._intersection.get_chunks(create=True) ideal_level = self._intersection.level.info.level_index # log_chunks("ideal_chunks", ideal_chunks) # If we are seting the data level level automatically, then update # our level to match what was chosen for the intersection. if self._view.auto_level: self._data_level = ideal_level # The loader will initiate loads on any ideal chunks which are not # yet in memory. And it will return the chunks we should draw. The # chunks we should draw might be ideal chunks, if they are in # memory, but they also might be chunks from higher or lower levels # in the octree. In general we try to draw "cover the view" with # the "best available" data. return self._slice.loader.get_drawable_chunks( drawn_set, ideal_chunks, ideal_level ) def _update_draw( self, scale_factor, corner_pixels_displayed, shape_threshold ) -> None: """Override Layer._update_draw completely. The base Layer._update_draw does stuff for the legacy multi-scale that we don't want. And it calls refresh() which we don't need. We create our OctreeView() here which has the corners in it. Parameters ---------- scale_factor : float Scale factor going from canvas to world coordinates. corner_pixels_displayed : array Coordinates of the top-left and bottom-right canvas pixels in world coordinates. shape_threshold : tuple Requested shape of field of view in data coordinates. """ # Compute our 2D corners from the incoming n-d corner_pixels displayed_sorted = sorted(self._slice_input.displayed) data_corners = ( self._transforms[1:] .simplified.set_slice(displayed_sorted) .inverse(corner_pixels_displayed) ) # Update our self._view to to capture the state of things right # before we are drawn. Our self._view will used by our # drawable_chunks() method. self._view = OctreeView(data_corners, shape_threshold, self.display) def get_intersection(self) -> OctreeIntersection: """The the interesection between the current view and the octree. Returns ------- OctreeIntersection The intersection between the current view and the octree. """ if self._slice is None: return None return self._slice.get_intersection(self._view) def _outside_data_range(self, indices) -> bool: """Return True if requested slice is outside of data range. Returns ------- bool True if requested slice is outside data range. """ extent = self._extent_data not_disp = self._slice_input.not_displayed return np.any( np.less( [indices[ax] for ax in not_disp], [extent[0, ax] for ax in not_disp], ) ) or np.any( np.greater( [indices[ax] for ax in not_disp], [extent[1, ax] for ax in not_disp], ) ) def _set_view_slice(self) -> None: """Set the view given the indices to slice with. This replaces Image._set_view_slice() entirely. The hope is eventually this class OctreeImage becomes Image. And the non-tiled multiscale logic in Image._set_view_slice goes away entirely. """ # Consider non-multiscale data as just having a single level from napari.components.experimental.chunk import LayerRef multilevel_data = self.data if self.multiscale else [self.data] if self._slice is not None: # For now bail out so we don't nuke an existing slice which # contains an existing octree. Soon we'll need to figure out # if we are really changing slices (and need a new octree). return indices = np.array(self._slice_indices) if self._outside_data_range(indices): return # Indices to get at the data we are currently viewing. indices = self._get_slice_indices() # TODO_OCTREE: easier way to do this? base_shape = multilevel_data[0].shape base_shape_2d = [base_shape[i] for i in self._slice_input.displayed] layer_ref = LayerRef.from_layer(self) meta = OctreeMetadata( layer_ref, base_shape_2d, len(multilevel_data), self._display.tile_size, ) # OctreeSlice wants all the levels, but only the dimensions # of each level that we are currently viewing. slice_data = [level_data[indices] for level_data in multilevel_data] layer_ref = LayerRef.from_layer(self) # Create the slice, it will create the actual Octree. self._slice = OctreeSlice( slice_data, layer_ref, meta, ) def _get_slice_indices(self) -> tuple: """Get the slice indices including possible depth for RGB.""" indices = tuple(self._slice_indices) if self.rgb: indices += (slice(None),) return indices def on_chunk_loaded(self, request: ChunkRequest) -> None: """An asynchronous ChunkRequest was loaded. Override Image.on_chunk_loaded() fully. Parameters ---------- request : ChunkRequest This request was loaded. """ LOGGER.info( "on_chunk_loaded: load=%.3fms elapsed=%.3fms location = %s", request.load_ms, request.elapsed_ms, request.location, ) # Pass it to the slice, it will insert the newly loaded data into # the OctreeChunk at the right location. if self._slice.on_chunk_loaded(request): # Redraw with the new chunk. # TODO_OCTREE: Call this at most once per frame? It's a bad # idea to call it for every chunk? LOGGER.debug("on_chunk_loaded calling loaded()") self.events.loaded() @property def remote_messages(self) -> dict: """Messages we should send to remote clients.""" if self._intersection is None: return {} return { "tile_state": self._intersection.tile_state, "tile_config": self._intersection.tile_config, } napari-0.5.0a1/napari/layers/image/experimental/octree_intersection.py000066400000000000000000000201051437041365600261700ustar00rootroot00000000000000"""OctreeView and OctreeIntersection classes. """ from typing import List, NamedTuple, Tuple import numpy as np from napari.layers.image.experimental.octree_chunk import OctreeChunk from napari.layers.image.experimental.octree_level import OctreeLevel from napari.layers.image.experimental.octree_util import ( OctreeDisplayOptions, spiral_index, ) MAX_NUM_CHUNKS = 81 class OctreeView(NamedTuple): """A view into the octree. An OctreeView corresponds to a camera which is viewing the image data, plus options as to how we want to render the data. Attributes ---------- corner : np.ndarray The two (row, col) corners in data coordinates, base image pixels. canvas : np.ndarray The shape of the canvas, the window we are drawing into. display : OctreeDisplayOptions How to display the view. """ corners: np.ndarray canvas: np.ndarray display: OctreeDisplayOptions @property def data_width(self) -> int: """The width between the corners, in data coordinates. Returns ------- int The width in data coordinates. """ return self.corners[1][1] - self.corners[0][1] @property def auto_level(self) -> bool: """True if the octree level should be selected automatically. Returns ------- bool True if the octree level should be selected automatically. """ return not self.display.freeze_level and self.display.track_view def expand(self, expansion_factor: float) -> 'OctreeView': """Return expanded view. We expand the view so that load some tiles around the edge, so if you pan they are more likely to be already loaded. Parameters ---------- expansion_factor : float Expand the view by this much. Contract if less than 1. """ assert expansion_factor > 0 extents = self.corners[1] - self.corners[0] padding = ((extents * expansion_factor) - extents) / 2 new_corners = np.array( (self.corners[0] - padding, self.corners[1] + padding) ) return OctreeView(new_corners, self.canvas, self.display) class OctreeIntersection: """A view's intersection with the octree. Parameters ---------- level : OctreeLevel The octree level that we intersected with. view : OctreeView The view we are intersecting with the octree. """ def __init__(self, level: OctreeLevel, view: OctreeView) -> None: self.level = level self._corners = view.corners level_info = self.level.info # TODO_OCTREE: don't split rows/cols so all these pairs of variables # are just one variable each? Use numpy more. rows, cols = view.corners[:, 0], view.corners[:, 1] base = level_info.meta.base_shape self.normalized_range = np.array( [np.clip(rows / base[0], 0, 1), np.clip(cols / base[1], 0, 1)] ) scaled_rows = rows / level_info.scale scaled_cols = cols / level_info.scale self._row_range = self.row_range(scaled_rows) self._col_range = self.column_range(scaled_cols) def tile_range( self, span: Tuple[float, float], num_tiles_total: int ) -> range: """Return tiles indices needed to draw the span. Parameters ---------- span : Tuple[float, float] The span in image coordinates. num_tiles_total : int The total number of tiles in this direction. """ def _clamp(val, min_val, max_val): return max(min(val, max_val), min_val) tile_size = self.level.info.meta.tile_size span_tiles = [span[0] / tile_size, span[1] / tile_size] clamped = [ _clamp(span_tiles[0], 0, num_tiles_total - 1), _clamp(span_tiles[1], 0, num_tiles_total - 1) + 1, ] # TODO_OCTREE: BUG, range is not empty when it should be? # int() truncates which is what we want span_int = [int(x) for x in clamped] return range(*span_int) def row_range(self, span: Tuple[float, float]) -> range: """Return row range of tiles for this span. Parameters ---------- span : Tuple[float, float] The span in image coordinates, [y0..y1] Returns ------- range The range of tiles across the columns. """ tile_rows = self.level.info.shape_in_tiles[0] return self.tile_range(span, tile_rows) def column_range(self, span: Tuple[float, float]) -> range: """Return column range of tiles for this span. Parameters ---------- span : Tuple[float, float] The span in image coordinates, [x0..x1] Returns ------- range The range of tiles across the columns. """ tile_cols = self.level.info.shape_in_tiles[1] return self.tile_range(span, tile_cols) def is_visible(self, row: int, col: int) -> bool: """Return True if the tile [row, col] is in the intersection. row : int The row of the tile. col : int The col of the tile. """ def _inside(value, value_range): return value_range.start <= value < value_range.stop return _inside(row, self._row_range) and _inside(col, self._col_range) def get_chunks(self, create=False) -> List[OctreeChunk]: """Return all of the chunks in this intersection. Parameters ---------- create : bool If True, create an OctreeChunk at any location that does not already have one. """ chunks = [] # The chunks in the intersection. # Get every chunk that is within the rectangular region. These are # the chunks we want to draw to depict this region of the data. # # If we've accessed the chunk recently the existing OctreeChunk # will be returned, otherwise a new OctreeChunk is created # and returned. # # OctreeChunks can be loaded or unloaded. Unloaded chunks are not # drawn until their data as been loaded in. But here we return # every chunk within the view. # We use spiral indexing to get chunks from the center first for i, (row, col) in enumerate( spiral_index(self._row_range, self._col_range) ): chunk = self.level.get_chunk(row, col, create=create) if chunk is not None: chunks.append(chunk) # We place a limit on the maximum number of chunks that # we'll ever take from a level to deal with the single # level tiled rendering case. if i > MAX_NUM_CHUNKS: break return chunks @property def tile_state(self) -> dict: """Return tile state, for the monitor. Returns ------- dict The tile state. """ x, y = np.mgrid[self._row_range, self._col_range] seen = np.vstack((x.ravel(), y.ravel())).T return { # A list of (row, col) pairs of visible tiles. "seen": seen, # The two corners of the view in data coordinates ((x0, y0), (x1, y1)). "corners": self._corners, } @property def tile_config(self) -> dict: """Return tile config, for the monitor. Returns ------- dict The file config. """ # TODO_OCTREE: Need to cleanup and re-name and organize # OctreeLevelInfo and OctreeMetadata attrbiutes. Messy. level = self.level image_shape = level.info.image_shape shape_in_tiles = level.info.shape_in_tiles meta = level.info.meta base_shape = meta.base_shape tile_size = meta.tile_size return { "base_shape": base_shape, "image_shape": image_shape, "shape_in_tiles": shape_in_tiles, "tile_size": tile_size, "level_index": level.info.level_index, } napari-0.5.0a1/napari/layers/image/experimental/octree_level.py000066400000000000000000000157121437041365600246010ustar00rootroot00000000000000"""OctreeLevelInfo and OctreeLevel classes. """ from __future__ import annotations import logging import math from typing import TYPE_CHECKING, Dict, List, Optional import numpy as np from napari.layers.image.experimental.octree_chunk import ( OctreeChunk, OctreeChunkGeom, ) from napari.layers.image.experimental.octree_util import OctreeMetadata LOGGER = logging.getLogger("napari.octree") if TYPE_CHECKING: from napari.types import ArrayLike class OctreeLevelInfo: """Information about one level of the octree. This should be a NamedTuple. Parameters ---------- meta : OctreeMetadata Information about the entire octree. level_index : int The index of this level within the whole tree. """ def __init__(self, meta: OctreeMetadata, level_index: int) -> None: self.meta = meta self.level_index = level_index self.scale = 2**self.level_index base = meta.base_shape self.image_shape = ( int(base[0] / self.scale), int(base[1] / self.scale), ) tile_size = meta.tile_size scaled_size = tile_size * self.scale self.rows = math.ceil(base[0] / scaled_size) self.cols = math.ceil(base[1] / scaled_size) self.shape_in_tiles = [self.rows, self.cols] self.num_tiles = self.rows * self.cols class OctreeLevel: """One level of the octree. An OctreeLevel is "sparse" in that it only contains a dict of OctreeChunks for the portion of the octree that is currently being rendered. So even if the full level contains hundreds of millions of chunks, this class only contains a few dozens OctreeChunks. This was necessary because even having a null reference for every OctreeChunk in a level would use too much space and be too slow to construct. Parameters ---------- slice_id : int The id of the OctreeSlice we are in. data : ArrayLike The data for this level. meta : OctreeMetadata The base image shape and other details. level_index : int Index of this specific level (0 is full resolution). Attributes ---------- info : OctreeLevelInfo Metadata about this level. _tiles : Dict[tuple, OctreeChunk] Maps (row, col) tuple to the OctreeChunk at that location. """ def __init__( self, slice_id: int, data: ArrayLike, meta: OctreeMetadata, level_index: int, ) -> None: self.slice_id = slice_id self.data = data self.info = OctreeLevelInfo(meta, level_index) self._tiles: Dict[tuple, OctreeChunk] = {} def get_chunk( self, row: int, col: int, create=False ) -> Optional[OctreeChunk]: """Return the OctreeChunk at this location if it exists. If create is True, an OctreeChunk will be created if one does not exist at this location. Parameters ---------- row : int The row in the level. col : int The column in the level. create : bool If True, create the OctreeChunk if it does not exist. Returns ------- Optional[OctreeChunk] The OctreeChunk if one existed or we just created it. """ try: return self._tiles[(row, col)] except KeyError: if not create: return None # It didn't exist so we're done. rows, cols = self.info.shape_in_tiles if row < 0 or row >= rows or col < 0 or col >= cols: # The coordinates are not in the level. Not an exception because # callers might be trying to get children just over the edge # for non-power-of-two base images. return None # Create a chunk at this location and return it. octree_chunk = self._create_chunk(row, col) self._tiles[(row, col)] = octree_chunk return octree_chunk def _create_chunk(self, row: int, col: int) -> OctreeChunk: """Create a new OctreeChunk for this location in the level. Parameters ---------- row : int The row in the level. col : int The column in the level. Returns ------- OctreeChunk The newly created chunk. """ level_index = self.info.level_index meta = self.info.meta layer_ref = meta.layer_ref from napari.components.experimental.chunk._request import ( OctreeLocation, ) location = OctreeLocation( layer_ref, self.slice_id, level_index, row, col ) scale = self.info.scale tile_size = self.info.meta.tile_size scaled_size = tile_size * scale pos = np.array( [col * scaled_size, row * scaled_size], dtype=np.float32 ) data = self._get_data(row, col) # Create OctreeChunkGeom used by the visual for rendering this # chunk. Size it based on the base image pixels, not based on the # data in this level, so it's exact. base = np.array(meta.base_shape[::-1], dtype=np.float) remain = base - pos size = np.minimum(remain, [scaled_size, scaled_size]) geom = OctreeChunkGeom(pos, size) # Return the newly created chunk. return OctreeChunk(data, location, geom) def _get_data(self, row: int, col: int) -> ArrayLike: """Get the chunk's data at this location. Parameters ---------- row : int The row coordinate. col : int The column coordinate. Returns ------- ArrayLike The data at this location. """ tile_size = self.info.meta.tile_size array_slice = ( slice(row * tile_size, (row + 1) * tile_size), slice(col * tile_size, (col + 1) * tile_size), ) if self.data.ndim == 3: array_slice += (slice(None),) # Add the colors. return self.data[array_slice] def log_levels(levels: List[OctreeLevel], start_level: int = 0) -> None: """Log the dimensions of each level nicely. We take start_level so we can log the "extra" levels we created but with their correct level numbers. Parameters ---------- levels : List[OctreeLevel] Print information about these levels. start_level : int Start the indexing at this number, shift the indexes up. """ from napari._vendor.experimental.humanize.src.humanize import intword def _dim_str(dim: tuple) -> None: return f"({dim[0]}, {dim[1]}) = {intword(dim[0] * dim[1])}" for index, level in enumerate(levels): level_index = start_level + index image_str = _dim_str(level.info.image_shape) tiles_str = _dim_str(level.info.shape_in_tiles) LOGGER.info( "Level %d: %s pixels -> %s tiles", level_index, image_str, tiles_str, ) napari-0.5.0a1/napari/layers/image/experimental/octree_tile_builder.py000066400000000000000000000062261437041365600261350ustar00rootroot00000000000000"""create_downsampled_levels() """ import logging import time from typing import List import dask import dask.array as da import numpy as np from scipy import ndimage as ndi from napari.layers.image.experimental.octree_util import NormalNoise from napari.utils.perf import block_timer LOGGER = logging.getLogger("napari.octree") def add_delay(array, delay_ms: NormalNoise): """Add a random delay when this array is first accessed. TODO_OCTREE: unused not but might use again... Parameters ---------- delay_ms : NormalNoise The amount of the random delay in milliseconds. """ @dask.delayed def delayed(array): sleep_ms = max(0, np.random.normal(delay_ms.mean, delay_ms.std_dev)) time.sleep(sleep_ms / 1000) return array return da.from_delayed(delayed(array), array.shape, array.dtype) def create_downsampled_levels( image: np.ndarray, next_level_index: int, tile_size: int ) -> List[np.ndarray]: """Return a list of levels coarser then this own. The first returned level is half the size of the input image, and each additional level is half as small again. The longest size in the last level is equal to or smaller than tile_size. For example if the tile_size is 256, the data in the file level will be smaller than (256, 256). Notes ----- Currently we use create_downsampled_levels() from Octree._create_extra_levels so that the image pyramid extends up to the point where the coarsest level fits within a single tile. This is potentially quite slow and wasteful. A better long term solution might be if our tiled visuals supported larger tiles, and a mix of tile sizes. Then the root level could be a special case that had a larger tiles size than the interior levels. This would mean zero downsampled, it'd probably perform better. Tiling an image that the graphics card can easily display is probably not efficient. Parameters ---------- image : np.ndarray The full image to create levels from. Returns ------- List[np.ndarray] A list of levels where levels[0] is the first downsampled level. """ zoom = [0.5, 0.5] if image.ndim == 3: zoom.append(1) # don't downsample the colors! # ndi.zoom doesn't support float16, so convert to float32 if image.dtype == np.float16: image = image.astype(np.float32) levels = [] previous = image level_index = next_level_index if max(previous.shape) > tile_size: LOGGER.info("Downsampling levels to a single tile...") # Repeat until we have level that will fit in a single tile, that will # be come the root/highest level. while max(previous.shape) > tile_size: with block_timer("downsampling") as timer: next_level = ndi.zoom( previous, zoom, mode='nearest', prefilter=True, order=1 ) LOGGER.info( "Level %d downsampled %s in %.3fms", level_index, previous.shape, timer.duration_ms, ) levels.append(next_level) previous = levels[-1] level_index += 1 return levels napari-0.5.0a1/napari/layers/image/experimental/octree_util.py000066400000000000000000000136221437041365600244450ustar00rootroot00000000000000"""OctreeDisplayOptions, NormalNoise and OctreeMetadata classes. """ from __future__ import annotations from dataclasses import dataclass from typing import TYPE_CHECKING, NamedTuple import numpy as np from napari.utils.config import octree_config if TYPE_CHECKING: from napari.components.experimental.chunk import LayerRef def _get_tile_size() -> int: """Return the default tile size. Returns ------- int The default tile size. """ return octree_config['octree']['tile_size'] if octree_config else 256 @dataclass class OctreeDisplayOptions: """Options for how to display the octree. Attributes ----------- tile_size : int The size of the display tiles, for example 256. freeze_level : bool If True we do not automatically pick the right data level. track_view : bool If True the displayed tiles track the view, the normal mode. show_grid : bool If True draw a grid around the tiles for debugging or demos. """ def __init__(self) -> None: self._show_grid = True # TODO_OCTREE we set this after __init__ which is messy. self.loaded_event = None @property def show_grid(self) -> bool: """True if we are drawing a grid on top of the tiles. Returns ------- bool True if we are drawing a grid on top of the tiles. """ return self._show_grid @show_grid.setter def show_grid(self, show: bool) -> None: """Set whether we should draw a grid on top of the tiles. Parameters ---------- show : bool True if we should draw a grid on top of the tiles. """ if self._show_grid != show: self._show_grid = show self.loaded_event() # redraw tile_size: int = _get_tile_size() freeze_level: bool = False track_view: bool = True class NormalNoise(NamedTuple): """Noise with a normal distribution.""" mean: float = 0 std_dev: float = 0 @property def is_zero(self) -> bool: """Return True if there is no noise at all. Returns ------- bool True if there is no noise at all. """ return self.mean == 0 and self.std_dev == 0 @property def get_value(self) -> float: """Get a random value. Returns ------- float The random value. """ return np.random.normal(self.mean, self.std_dev) class OctreeMetadata(NamedTuple): """Metadata for an Octree. Attributes ---------- base_shape : np.ndarray The base [height, width] shape of the entire full resolution image. num_levels : int The number of octree levels in the image. tile_size : int The default tile size. However each OctreeLevel has its own tile size which can override this. Notes ----- This OctreeMetadata.tile_size will be used by the OctreeLevels in the tree in general. But the highest level OctreeLevel might use a larger size so that it can consist of a single chunk. For example we might be using 256x256 tiles in general. For best performance it might make sense to have octree levels such that the highest level fits inside a single 256x256 tiles. But if we are displaying user provided data, they did not know our tile size. Instead their root level might be something pretty big, like 6000x6000. In that case we use 6000x6000 as the tile size in our root, so the root level consists of a single tile. TODO_OCTREE: we don't actually support larger size tiles yet! However it's still a good idea to assume that each OctreeLevel could have its own tile size. """ layer_ref: LayerRef base_shape: np.ndarray num_levels: int tile_size: int @property def aspect_ratio(self): """Return the width:height aspect ratio of the base image. For example HDTV resolution is 16:9 which has aspect ration 1.77. """ return self.base_shape[1] / self.base_shape[0] def spiral_index(row_range, col_range): """Generate a spiral index from a set of row and column indices. A spiral index starts at the center point and moves out in a spiral Paramters --------- row_range : range Range of rows to be accessed. col_range : range Range of columns to be accessed. Returns ------- generator (row, column) tuples in order of a spiral index. """ # Determine how many rows and columns need to be transvered total_row = row_range.stop - row_range.start total_col = col_range.stop - col_range.start # Get center offset row_center = int(np.ceil((row_range.stop + row_range.start) / 2) - 1) col_center = int(np.ceil((col_range.stop + col_range.start) / 2) - 1) # Let the first move be down x, y = 0, 0 dx, dy = 0, -1 # Loop through the desired number of indices for _ in range(max(total_row, total_col) ** 2): # Check if values are in range if (-total_row // 2 < x <= total_row // 2) and ( -total_col // 2 < y <= total_col // 2 ): # Return desired row, col tuple yield (row_center + x, col_center + y) # Change direction at appropriate points if x == y or (x < 0 and x == -y) or (x > 0 and x == 1 - y): dx, dy = -dy, dx x, y = x + dx, y + dy def linear_index(row_range, col_range): """Generate a linear index from a set of row and column indices. A linear index starts at the top left and procedes in a raster fashion. Parameters ---------- row_range : range Range of rows to be accessed. col_range : range Range of columns to be accessed. Returns ------- generator (row, column) tuples in order of a linear index. """ from itertools import product yield from product(row_range, col_range) napari-0.5.0a1/napari/layers/image/image.py000066400000000000000000001200621437041365600205110ustar00rootroot00000000000000"""Image class. """ from __future__ import annotations import types import warnings from contextlib import nullcontext from typing import TYPE_CHECKING, List, Sequence, Tuple, Union import numpy as np from scipy import ndimage as ndi from napari.layers._data_protocols import LayerDataProtocol from napari.layers._multiscale_data import MultiScaleData from napari.layers.base import Layer from napari.layers.image._image_constants import ( ImageRendering, Interpolation, VolumeDepiction, ) from napari.layers.image._image_mouse_bindings import ( move_plane_along_normal as plane_drag_callback, ) from napari.layers.image._image_mouse_bindings import ( set_plane_position as plane_double_click_callback, ) from napari.layers.image._image_slice import ImageSlice from napari.layers.image._image_slice_data import ImageSliceData from napari.layers.image._image_utils import guess_multiscale, guess_rgb from napari.layers.image._slice import _ImageSliceRequest, _ImageSliceResponse from napari.layers.intensity_mixin import IntensityVisualizationMixin from napari.layers.utils._slice_input import _SliceInput from napari.layers.utils.layer_utils import calc_data_range from napari.layers.utils.plane import SlicingPlane from napari.utils import config from napari.utils._dask_utils import DaskIndexer from napari.utils._dtype import get_dtype_limits, normalize_dtype from napari.utils.colormaps import AVAILABLE_COLORMAPS from napari.utils.events import Event from napari.utils.events.event import WarningEmitter from napari.utils.events.event_utils import connect_no_arg from napari.utils.migrations import rename_argument from napari.utils.misc import reorder_after_dim_reduction from napari.utils.naming import magic_name from napari.utils.translations import trans if TYPE_CHECKING: from napari.components import Dims from napari.components.experimental.chunk import ChunkRequest # It is important to contain at least one abstractmethod to properly exclude this class # in creating NAMES set inside of napari.layers.__init__ # Mixin must come before Layer class _ImageBase(IntensityVisualizationMixin, Layer): """Image layer. Parameters ---------- data : array or list of array Image data. Can be N >= 2 dimensional. If the last dimension has length 3 or 4 can be interpreted as RGB or RGBA if rgb is `True`. If a list and arrays are decreasing in shape then the data is treated as a multiscale image. Please note multiscale rendering is only supported in 2D. In 3D, only the lowest resolution scale is displayed. rgb : bool Whether the image is rgb RGB or RGBA. If not specified by user and the last dimension of the data has length 3 or 4 it will be set as `True`. If `False` the image is interpreted as a luminance image. colormap : str, napari.utils.Colormap, tuple, dict Colormap to use for luminance images. If a string must be the name of a supported colormap from vispy or matplotlib. If a tuple the first value must be a string to assign as a name to a colormap and the second item must be a Colormap. If a dict the key must be a string to assign as a name to a colormap and the value must be a Colormap. contrast_limits : list (2,) Color limits to be used for determining the colormap bounds for luminance images. If not passed is calculated as the min and max of the image. gamma : float Gamma correction for determining colormap linearity. Defaults to 1. interpolation : str Interpolation mode used by vispy. Must be one of our supported modes. rendering : str Rendering mode used by vispy. Must be one of our supported modes. depiction : str 3D Depiction mode. Must be one of {'volume', 'plane'}. The default value is 'volume'. iso_threshold : float Threshold for isosurface. attenuation : float Attenuation rate for attenuated maximum intensity projection. name : str Name of the layer. metadata : dict Layer metadata. scale : tuple of float Scale factors for the layer. translate : tuple of float Translation values for the layer. rotate : float, 3-tuple of float, or n-D array. If a float convert into a 2D rotation matrix using that value as an angle. If 3-tuple convert into a 3D rotation matrix, using a yaw, pitch, roll convention. Otherwise assume an nD rotation. Angles are assumed to be in degrees. They can be converted from radians with np.degrees if needed. shear : 1-D array or n-D array Either a vector of upper triangular values, or an nD shear matrix with ones along the main diagonal. affine : n-D array or napari.utils.transforms.Affine (N+1, N+1) affine transformation matrix in homogeneous coordinates. The first (N, N) entries correspond to a linear transform and the final column is a length N translation vector and a 1 or a napari `Affine` transform object. Applied as an extra transform on top of the provided scale, rotate, and shear values. opacity : float Opacity of the layer visual, between 0.0 and 1.0. blending : str One of a list of preset blending modes that determines how RGB and alpha values of the layer visual get mixed. Allowed values are {'opaque', 'translucent', and 'additive'}. visible : bool Whether the layer visual is currently being displayed. multiscale : bool Whether the data is a multiscale image or not. Multiscale data is represented by a list of array like image data. If not specified by the user and if the data is a list of arrays that decrease in shape then it will be taken to be multiscale. The first image in the list should be the largest. Please note multiscale rendering is only supported in 2D. In 3D, only the lowest resolution scale is displayed. cache : bool Whether slices of out-of-core datasets should be cached upon retrieval. Currently, this only applies to dask arrays. plane : dict or SlicingPlane Properties defining plane rendering in 3D. Properties are defined in data coordinates. Valid dictionary keys are {'position', 'normal', 'thickness', and 'enabled'}. experimental_clipping_planes : list of dicts, list of ClippingPlane, or ClippingPlaneList Each dict defines a clipping plane in 3D in data coordinates. Valid dictionary keys are {'position', 'normal', and 'enabled'}. Values on the negative side of the normal are discarded if the plane is enabled. Attributes ---------- data : array or list of array Image data. Can be N dimensional. If the last dimension has length 3 or 4 can be interpreted as RGB or RGBA if rgb is `True`. If a list and arrays are decreasing in shape then the data is treated as a multiscale image. Please note multiscale rendering is only supported in 2D. In 3D, only the lowest resolution scale is displayed. metadata : dict Image metadata. rgb : bool Whether the image is rgb RGB or RGBA if rgb. If not specified by user and the last dimension of the data has length 3 or 4 it will be set as `True`. If `False` the image is interpreted as a luminance image. multiscale : bool Whether the data is a multiscale image or not. Multiscale data is represented by a list of array like image data. The first image in the list should be the largest. Please note multiscale rendering is only supported in 2D. In 3D, only the lowest resolution scale is displayed. mode : str Interactive mode. The normal, default mode is PAN_ZOOM, which allows for normal interactivity with the canvas. In TRANSFORM mode the image can be transformed interactively. colormap : 2-tuple of str, napari.utils.Colormap The first is the name of the current colormap, and the second value is the colormap. Colormaps are used for luminance images, if the image is rgb the colormap is ignored. colormaps : tuple of str Names of the available colormaps. contrast_limits : list (2,) of float Color limits to be used for determining the colormap bounds for luminance images. If the image is rgb the contrast_limits is ignored. contrast_limits_range : list (2,) of float Range for the color limits for luminance images. If the image is rgb the contrast_limits_range is ignored. gamma : float Gamma correction for determining colormap linearity. interpolation : str Interpolation mode used by vispy. Must be one of our supported modes. rendering : str Rendering mode used by vispy. Must be one of our supported modes. depiction : str 3D Depiction mode used by vispy. Must be one of our supported modes. iso_threshold : float Threshold for isosurface. attenuation : float Attenuation rate for attenuated maximum intensity projection. plane : SlicingPlane or dict Properties defining plane rendering in 3D. Valid dictionary keys are {'position', 'normal', 'thickness'}. experimental_clipping_planes : ClippingPlaneList Clipping planes defined in data coordinates, used to clip the volume. Notes ----- _data_view : array (N, M), (N, M, 3), or (N, M, 4) Image data for the currently viewed slice. Must be 2D image data, but can be multidimensional for RGB or RGBA images if multidimensional is `True`. _colorbar : array Colorbar for current colormap. """ _colormaps = AVAILABLE_COLORMAPS @rename_argument("interpolation", "interpolation2d", "0.6.0") def __init__( self, data, *, rgb=None, colormap='gray', contrast_limits=None, gamma=1, interpolation2d='nearest', interpolation3d='linear', rendering='mip', iso_threshold=None, attenuation=0.05, name=None, metadata=None, scale=None, translate=None, rotate=None, shear=None, affine=None, opacity=1, blending='translucent', visible=True, multiscale=None, cache=True, depiction='volume', plane=None, experimental_clipping_planes=None, ) -> None: if name is None and data is not None: name = magic_name(data) if isinstance(data, types.GeneratorType): data = list(data) if getattr(data, 'ndim', 2) < 2: raise ValueError( trans._('Image data must have at least 2 dimensions.') ) # Determine if data is a multiscale self._data_raw = data if multiscale is None: multiscale, data = guess_multiscale(data) elif multiscale and not isinstance(data, MultiScaleData): data = MultiScaleData(data) # Determine if rgb rgb_guess = guess_rgb(data.shape) if rgb and not rgb_guess: raise ValueError( trans._( "'rgb' was set to True but data does not have suitable dimensions." ) ) elif rgb is None: rgb = rgb_guess self.rgb = rgb # Determine dimensionality of the data ndim = len(data.shape) if rgb: ndim -= 1 super().__init__( data, ndim, name=name, metadata=metadata, scale=scale, translate=translate, rotate=rotate, shear=shear, affine=affine, opacity=opacity, blending=blending, visible=visible, multiscale=multiscale, cache=cache, experimental_clipping_planes=experimental_clipping_planes, ) self.events.add( interpolation=WarningEmitter( trans._( "'layer.events.interpolation' is deprecated please use `interpolation2d` and `interpolation3d`", deferred=True, ), type='select', ), interpolation2d=Event, interpolation3d=Event, rendering=Event, plane=Event, depiction=Event, iso_threshold=Event, attenuation=Event, ) self._array_like = True # Set data self._data = data if self.multiscale: self._data_level = len(self.data) - 1 # Determine which level of the multiscale to use for the thumbnail. # Pick the smallest level with at least one axis >= 64. This is # done to prevent the thumbnail from being from one of the very # low resolution layers and therefore being very blurred. big_enough_levels = [ np.any(np.greater_equal(p.shape, 64)) for p in data ] if np.any(big_enough_levels): self._thumbnail_level = np.where(big_enough_levels)[0][-1] else: self._thumbnail_level = 0 else: self._data_level = 0 self._thumbnail_level = 0 displayed_axes = self._slice_input.displayed self.corner_pixels[1][displayed_axes] = self.level_shapes[ self._data_level ][displayed_axes] self._new_empty_slice() # Set contrast limits, colormaps and plane parameters self._gamma = gamma self._attenuation = attenuation self._plane = SlicingPlane(thickness=1, enabled=False, draggable=True) # Whether to calculate clims on the next set_view_slice self._should_calc_clims = False if contrast_limits is None: if not isinstance(data, np.ndarray): dtype = normalize_dtype(getattr(data, 'dtype', None)) if np.issubdtype(dtype, np.integer): self.contrast_limits_range = get_dtype_limits(dtype) else: self.contrast_limits_range = (0, 1) self._should_calc_clims = dtype != np.uint8 else: self.contrast_limits_range = self._calc_data_range() else: self.contrast_limits_range = contrast_limits self._contrast_limits = tuple(self.contrast_limits_range) if iso_threshold is None: cmin, cmax = self.contrast_limits_range self._iso_threshold = cmin + (cmax - cmin) / 2 else: self._iso_threshold = iso_threshold # using self.colormap = colormap uses the setter in *derived* classes, # where the intention here is to use the base setter, so we use the # _set_colormap method. This is important for Labels layers, because # we don't want to use get_color before set_view_slice has been # triggered (self.refresh(), below). self._set_colormap(colormap) self.contrast_limits = self._contrast_limits self._interpolation2d = Interpolation.NEAREST self._interpolation3d = Interpolation.NEAREST self.interpolation2d = interpolation2d self.interpolation3d = interpolation3d self.rendering = rendering self.depiction = depiction if plane is not None: self.plane = plane connect_no_arg(self.plane.events, self.events, 'plane') # Trigger generation of view slice and thumbnail self.refresh() def _new_empty_slice(self): """Initialize the current slice to an empty image.""" wrapper = _weakref_hide(self) self._slice = ImageSlice( self._get_empty_image(), wrapper._raw_to_displayed, self.rgb ) self._empty = True def _get_empty_image(self): """Get empty image to use as the default before data is loaded.""" if self.rgb: return np.zeros((1,) * self._slice_input.ndisplay + (3,)) else: return np.zeros((1,) * self._slice_input.ndisplay) def _get_order(self) -> Tuple[int]: """Return the ordered displayed dimensions, but reduced to fit in the slice space.""" order = reorder_after_dim_reduction(self._slice_input.displayed) if self.rgb: # if rgb need to keep the final axis fixed during the # transpose. The index of the final axis depends on how many # axes are displayed. return order + (max(order) + 1,) else: return order @property def _data_view(self): """Viewable image for the current slice. (compatibility)""" return self._slice.image.view def _calc_data_range(self, mode='data'): """ Calculate the range of the data values in the currently viewed slice or full data array """ if mode == 'data': input_data = self.data[-1] if self.multiscale else self.data elif mode == 'slice': data = self._slice.image.view # ugh input_data = data[-1] if self.multiscale else data else: raise ValueError( trans._( "mode must be either 'data' or 'slice', got {mode!r}", deferred=True, mode=mode, ) ) return calc_data_range(input_data, rgb=self.rgb) @property def dtype(self): return self._data.dtype @property def data_raw(self): """Data, exactly as provided by the user.""" return self._data_raw @property def data(self) -> LayerDataProtocol: """Data, possibly in multiscale wrapper. Obeys LayerDataProtocol.""" return self._data @data.setter def data( self, data: Union[LayerDataProtocol, Sequence[LayerDataProtocol]] ): self._data_raw = data # note, we don't support changing multiscale in an Image instance self._data = MultiScaleData(data) if self.multiscale else data # type: ignore self._update_dims() self.events.data(value=self.data) if self._keep_auto_contrast: self.reset_contrast_limits() self._reset_editable() def _get_ndim(self): """Determine number of dimensions of the layer.""" return len(self.level_shapes[0]) @property def _extent_data(self) -> np.ndarray: """Extent of layer in data coordinates. Returns ------- extent_data : array, shape (2, D) """ shape = self.level_shapes[0] return np.vstack([np.zeros(len(shape)), shape]) @property def data_level(self): """int: Current level of multiscale, or 0 if image.""" return self._data_level @data_level.setter def data_level(self, level): if self._data_level == level: return self._data_level = level self.refresh() @property def level_shapes(self) -> np.ndarray: """array: Shapes of each level of the multiscale or just of image.""" shapes = self.data.shapes if self.multiscale else [self.data.shape] if self.rgb: shapes = [s[:-1] for s in shapes] return np.array(shapes) @property def downsample_factors(self) -> np.ndarray: """list: Downsample factors for each level of the multiscale.""" return np.divide(self.level_shapes[0], self.level_shapes) @property def iso_threshold(self): """float: threshold for isosurface.""" return self._iso_threshold @iso_threshold.setter def iso_threshold(self, value): self._iso_threshold = value self._update_thumbnail() self.events.iso_threshold() @property def attenuation(self): """float: attenuation rate for attenuated_mip rendering.""" return self._attenuation @attenuation.setter def attenuation(self, value): self._attenuation = value self._update_thumbnail() self.events.attenuation() @property def interpolation(self): """Return current interpolation mode. Selects a preset interpolation mode in vispy that determines how volume is displayed. Makes use of the two Texture2D interpolation methods and the available interpolation methods defined in vispy/gloo/glsl/misc/spatial_filters.frag Options include: 'bessel', 'cubic', 'linear', 'blackman', 'catrom', 'gaussian', 'hamming', 'hanning', 'hermite', 'kaiser', 'lanczos', 'mitchell', 'nearest', 'spline16', 'spline36' Returns ------- str The current interpolation mode """ warnings.warn( trans._( "Interpolation attribute is deprecated since 0.4.17. Please use interpolation2d or interpolation3d", ), category=DeprecationWarning, stacklevel=2, ) return str( self._interpolation2d if self._slice_input.ndisplay == 2 else self._interpolation3d ) @interpolation.setter def interpolation(self, interpolation): """Set current interpolation mode.""" warnings.warn( trans._( "Interpolation setting is deprecated since 0.4.17. Please use interpolation2d or interpolation3d", ), category=DeprecationWarning, stacklevel=2, ) if self._slice_input.ndisplay == 3: self.interpolation3d = interpolation else: if interpolation == 'bilinear': interpolation = 'linear' warnings.warn( trans._( "'bilinear' is invalid for interpolation2d (introduced in napari 0.4.17). " "Please use 'linear' instead, and please set directly the 'interpolation2d' attribute'.", ), category=DeprecationWarning, stacklevel=2, ) self.interpolation2d = interpolation @property def interpolation2d(self): return str(self._interpolation2d) @interpolation2d.setter def interpolation2d(self, value): if value == 'bilinear': raise ValueError( trans._( "'bilinear' interpolation is not valid for interpolation2d. Did you mean 'linear' instead ?", ), ) if value == 'bicubic': value = 'cubic' warnings.warn( trans._("'bicubic' is deprecated. Please use 'cubic' instead"), category=DeprecationWarning, stacklevel=2, ) self._interpolation2d = Interpolation(value) self.events.interpolation2d(value=self._interpolation2d) self.events.interpolation(value=self._interpolation2d) @property def interpolation3d(self): return str(self._interpolation3d) @interpolation3d.setter def interpolation3d(self, value): if value == 'bicubic': value = 'cubic' warnings.warn( trans._("'bicubic' is deprecated. Please use 'cubic' instead"), category=DeprecationWarning, stacklevel=2, ) self._interpolation3d = Interpolation(value) self.events.interpolation3d(value=self._interpolation3d) self.events.interpolation(value=self._interpolation3d) @property def depiction(self): """The current 3D depiction mode. Selects a preset depiction mode in vispy * volume: images are rendered as 3D volumes. * plane: images are rendered as 2D planes embedded in 3D. plane position, normal, and thickness are attributes of layer.plane which can be modified directly. """ return str(self._depiction) @depiction.setter def depiction(self, depiction: Union[str, VolumeDepiction]): """Set the current 3D depiction mode.""" self._depiction = VolumeDepiction(depiction) self._update_plane_callbacks() self.events.depiction() def _reset_plane_parameters(self): """Set plane attributes to something valid.""" self.plane.position = np.array(self.data.shape) / 2 self.plane.normal = (1, 0, 0) def _update_plane_callbacks(self): """Set plane callbacks depending on depiction mode.""" plane_drag_callback_connected = ( plane_drag_callback in self.mouse_drag_callbacks ) double_click_callback_connected = ( plane_double_click_callback in self.mouse_double_click_callbacks ) if self.depiction == VolumeDepiction.VOLUME: if plane_drag_callback_connected: self.mouse_drag_callbacks.remove(plane_drag_callback) if double_click_callback_connected: self.mouse_double_click_callbacks.remove( plane_double_click_callback ) elif self.depiction == VolumeDepiction.PLANE: if not plane_drag_callback_connected: self.mouse_drag_callbacks.append(plane_drag_callback) if not double_click_callback_connected: self.mouse_double_click_callbacks.append( plane_double_click_callback ) @property def plane(self): return self._plane @plane.setter def plane(self, value: Union[dict, SlicingPlane]): self._plane.update(value) self.events.plane() @property def loaded(self): """Has the data for this layer been loaded yet. With asynchronous loading the layer might exist but its data for the current slice has not been loaded. """ return self._slice.loaded def _raw_to_displayed(self, raw): """Determine displayed image from raw image. For normal image layers, just return the actual image. Parameters ---------- raw : array Raw array. Returns ------- image : array Displayed array. """ image = raw return image def _set_view_slice(self) -> None: """Set the slice output based on this layer's current state.""" # Initializes an ImageSlice for the old experimental async code. self._new_empty_slice() # Skip if any non-displayed data indices are out of bounds. # This can happen when slicing layers with different extents. indices = self._slice_indices for d in self._slice_input.not_displayed: if (indices[d] < 0) or (indices[d] >= self._extent_data[1][d]): return # For the old experimental async code. self._empty = False # The new slicing code makes a request from the existing state and # executes the request on the calling thread directly. # For async slicing, the calling thread will not be the main thread. request = self._make_slice_request_internal( slice_input=self._slice_input, indices=indices, lazy=True, dask_indexer=nullcontext, ) response = request() self._update_slice_response(response) def _make_slice_request(self, dims: Dims) -> _ImageSliceRequest: """Make an image slice request based on the given dims and this image.""" slice_input = self._make_slice_input( dims.point, dims.ndisplay, dims.order ) # TODO: for the existing sync slicing, indices is passed through # to avoid some performance issues related to the evaluation of the # data-to-world transform and its inverse. Async slicing currently # absorbs these performance issues here, but we can likely improve # things either by caching the world-to-data transform on the layer # or by lazily evaluating it in the slice task itself. indices = slice_input.data_indices(self._data_to_world.inverse) return self._make_slice_request_internal( slice_input=slice_input, indices=indices, lazy=False, dask_indexer=self.dask_optimized_slicing, ) def _make_slice_request_internal( self, *, slice_input: _SliceInput, indices: Tuple[Union[int, slice], ...], lazy: bool, dask_indexer: DaskIndexer, ) -> _ImageSliceRequest: """Needed to support old-style sync slicing through _slice_dims and _set_view_slice. This is temporary scaffolding that should go away once we have completed the async slicing project: https://github.com/napari/napari/issues/4795 """ return _ImageSliceRequest( dims=slice_input, data=self.data, dask_indexer=dask_indexer, indices=indices, multiscale=self.multiscale, corner_pixels=self.corner_pixels, rgb=self.rgb, data_level=self.data_level, thumbnail_level=self._thumbnail_level, level_shapes=self.level_shapes, downsample_factors=self.downsample_factors, lazy=lazy, ) def _update_slice_response(self, response: _ImageSliceResponse) -> None: """Update the slice output state currently on the layer.""" self._slice_input = response.dims # For the old experimental async code. self._empty = False slice_data = self._SliceDataClass( layer=self, indices=response.indices, image=response.data, thumbnail_source=response.thumbnail, ) self._transforms[0] = response.tile_to_data # For the old experimental async code, where loading might be sync # or async. self._load_slice(slice_data) # Maybe reset the contrast limits based on the new slice. if self._should_calc_clims: self.reset_contrast_limits_range() self.reset_contrast_limits() self._should_calc_clims = False elif self._keep_auto_contrast: self.reset_contrast_limits() @property def _SliceDataClass(self): # Use special ChunkedSlideData for async. if config.async_loading: from napari.layers.image.experimental._chunked_slice_data import ( ChunkedSliceData, ) return ChunkedSliceData return ImageSliceData def _load_slice(self, data: ImageSliceData): """Load the image and maybe thumbnail source. Parameters ---------- data : Slice """ if self._slice.load(data): # The load was synchronous. self._on_data_loaded(data, sync=True) else: # The load will be asynchronous. Signal that our self.loaded # property is now false, since the load is in progress. self.events.loaded() def _on_data_loaded(self, data: ImageSliceData, sync: bool) -> None: """The given data a was loaded, use it now. This routine is called synchronously from _load_async() above, or it is called asynchronously sometime later when the ChunkLoader finishes loading the data in a worker thread or process. Parameters ---------- data : ChunkRequest The request that was satisfied/loaded. sync : bool If True the chunk was loaded synchronously. """ # Transpose after the load. data.transpose(self._get_order()) # Pass the loaded data to the slice. if not self._slice.on_loaded(data): # Slice rejected it, was it for the wrong indices? return # Notify the world. if self.multiscale: self.events.scale() self.events.translate() # Announcing we are in the loaded state will make our node visible # if it was invisible during the load. self.events.loaded() if not sync: # TODO_ASYNC: Avoid calling self.refresh(), because it would # call our _set_view_slice(). Do we need a "refresh without # set_view_slice()" method that we can call? self.events.set_data(value=self._slice) # update vispy self._update_thumbnail() def _update_thumbnail(self): """Update thumbnail with current image data and colormap.""" if not self.loaded: # ASYNC_TODO: Do not compute the thumbnail until we are loaded. # Is there a nicer way to prevent this from getting called? return image = self._slice.thumbnail.view if self._slice_input.ndisplay == 3 and self.ndim > 2: image = np.max(image, axis=0) # float16 not supported by ndi.zoom dtype = np.dtype(image.dtype) if dtype in [np.dtype(np.float16)]: image = image.astype(np.float32) raw_zoom_factor = np.divide( self._thumbnail_shape[:2], image.shape[:2] ).min() new_shape = np.clip( raw_zoom_factor * np.array(image.shape[:2]), 1, # smallest side should be 1 pixel wide self._thumbnail_shape[:2], ) zoom_factor = tuple(new_shape / image.shape[:2]) if self.rgb: # warning filter can be removed with scipy 1.4 with warnings.catch_warnings(): warnings.simplefilter("ignore") downsampled = ndi.zoom( image, zoom_factor + (1,), prefilter=False, order=0 ) if image.shape[2] == 4: # image is RGBA colormapped = np.copy(downsampled) colormapped[..., 3] = downsampled[..., 3] * self.opacity if downsampled.dtype == np.uint8: colormapped = colormapped.astype(np.uint8) else: # image is RGB if downsampled.dtype == np.uint8: alpha = np.full( downsampled.shape[:2] + (1,), int(255 * self.opacity), dtype=np.uint8, ) else: alpha = np.full(downsampled.shape[:2] + (1,), self.opacity) colormapped = np.concatenate([downsampled, alpha], axis=2) else: # warning filter can be removed with scipy 1.4 with warnings.catch_warnings(): warnings.simplefilter("ignore") downsampled = ndi.zoom( image, zoom_factor, prefilter=False, order=0 ) low, high = self.contrast_limits downsampled = np.clip(downsampled, low, high) color_range = high - low if color_range != 0: downsampled = (downsampled - low) / color_range downsampled = downsampled**self.gamma color_array = self.colormap.map(downsampled.ravel()) colormapped = color_array.reshape(downsampled.shape + (4,)) colormapped[..., 3] *= self.opacity self.thumbnail = colormapped def _get_value(self, position): """Value of the data at a position in data coordinates. Parameters ---------- position : tuple Position in data coordinates. Returns ------- value : tuple Value of the data. """ if self.multiscale: # for multiscale data map the coordinate from the data back to # the tile coord = self._transforms['tile2data'].inverse(position) else: coord = position coord = np.round(coord).astype(int) raw = self._slice.image.raw if self.rgb: shape = raw.shape[:-1] else: shape = raw.shape if self.ndim < len(coord): # handle 3D views of 2D data by omitting extra coordinate offset = len(coord) - len(shape) coord = coord[[d + offset for d in self._slice_input.displayed]] else: coord = coord[self._slice_input.displayed] if all(0 <= c < s for c, s in zip(coord, shape)): value = raw[tuple(coord)] else: value = None if self.multiscale: value = (self.data_level, value) return value def _get_offset_data_position(self, position: List[float]) -> List[float]: """Adjust position for offset between viewer and data coordinates. VisPy considers the coordinate system origin to be the canvas corner, while napari considers the origin to be the **center** of the corner pixel. To get the correct value under the mouse cursor, we need to shift the position by 0.5 pixels on each axis. """ return [p + 0.5 for p in position] # For async we add an on_chunk_loaded() method. if config.async_loading: def on_chunk_loaded(self, request: ChunkRequest) -> None: """An asynchronous ChunkRequest was loaded. Parameters ---------- request : ChunkRequest This request was loaded. """ # Convert the ChunkRequest to SliceData and use it. data = self._SliceDataClass.from_request(self, request) self._on_data_loaded(data, sync=False) class Image(_ImageBase): @property def rendering(self): """Return current rendering mode. Selects a preset rendering mode in vispy that determines how volume is displayed. Options include: * ``translucent``: voxel colors are blended along the view ray until the result is opaque. * ``mip``: maximum intensity projection. Cast a ray and display the maximum value that was encountered. * ``minip``: minimum intensity projection. Cast a ray and display the minimum value that was encountered. * ``attenuated_mip``: attenuated maximum intensity projection. Cast a ray and attenuate values based on integral of encountered values, display the maximum value that was encountered after attenuation. This will make nearer objects appear more prominent. * ``additive``: voxel colors are added along the view ray until the result is saturated. * ``iso``: isosurface. Cast a ray until a certain threshold is encountered. At that location, lighning calculations are performed to give the visual appearance of a surface. * ``average``: average intensity projection. Cast a ray and display the average of values that were encountered. Returns ------- str The current rendering mode """ return str(self._rendering) @rendering.setter def rendering(self, rendering): self._rendering = ImageRendering(rendering) self.events.rendering() def _get_state(self): """Get dictionary of layer state. Returns ------- state : dict Dictionary of layer state. """ state = self._get_base_state() state.update( { 'rgb': self.rgb, 'multiscale': self.multiscale, 'colormap': self.colormap.name, 'contrast_limits': self.contrast_limits, 'interpolation2d': self.interpolation2d, 'interpolation3d': self.interpolation3d, 'rendering': self.rendering, 'depiction': self.depiction, 'plane': self.plane.dict(), 'iso_threshold': self.iso_threshold, 'attenuation': self.attenuation, 'gamma': self.gamma, 'data': self.data, } ) return state if config.async_octree: from napari.layers.image.experimental.octree_image import _OctreeImageBase class Image(Image, _OctreeImageBase): pass Image.__doc__ = _ImageBase.__doc__ class _weakref_hide: def __init__(self, obj) -> None: import weakref self.obj = weakref.ref(obj) def _raw_to_displayed(self, *args, **kwarg): return self.obj()._raw_to_displayed(*args, **kwarg) napari-0.5.0a1/napari/layers/intensity_mixin.py000066400000000000000000000126241437041365600216030ustar00rootroot00000000000000from typing import TYPE_CHECKING import numpy as np from napari.utils._dtype import normalize_dtype from napari.utils.colormaps import ensure_colormap from napari.utils.events import Event from napari.utils.status_messages import format_float from napari.utils.validators import _validate_increasing, validate_n_seq validate_2_tuple = validate_n_seq(2) if TYPE_CHECKING: from napari.layers.image.image import Image class IntensityVisualizationMixin: """A mixin that adds gamma, colormap, and contrast limits logic to Layers. When used, this should come before the Layer in the inheritance, e.g.: class Image(IntensityVisualizationMixin, Layer): def __init__(self): ... Note: `contrast_limits_range` is range extent available on the widget, and `contrast_limits` is the visible range (the set values on the widget) """ def __init__(self, *args, **kwargs) -> None: super().__init__(*args, **kwargs) self.events.add( contrast_limits=Event, contrast_limits_range=Event, gamma=Event, colormap=Event, ) self._gamma = 1 self._colormap_name = '' self._contrast_limits_msg = '' self._contrast_limits = [None, None] self._contrast_limits_range = [None, None] self._auto_contrast_source = 'slice' self._keep_auto_contrast = False def reset_contrast_limits(self: 'Image', mode=None): """Scale contrast limits to data range""" mode = mode or self._auto_contrast_source self.contrast_limits = self._calc_data_range(mode) def reset_contrast_limits_range(self, mode=None): """Scale contrast limits range to data type if dtype is an integer, or use the current maximum data range otherwise. """ dtype = normalize_dtype(self.dtype) if np.issubdtype(dtype, np.integer): info = np.iinfo(dtype) self.contrast_limits_range = (info.min, info.max) else: mode = mode or self._auto_contrast_source self.contrast_limits_range = self._calc_data_range(mode) @property def colormap(self): """napari.utils.Colormap: colormap for luminance images.""" return self._colormap def _set_colormap(self, colormap): self._colormap = ensure_colormap(colormap) self._update_thumbnail() self.events.colormap() @colormap.setter def colormap(self, colormap): self._set_colormap(colormap) @property def colormaps(self): """tuple of str: names of available colormaps.""" return tuple(self._colormaps.keys()) @property def contrast_limits(self): """list of float: Limits to use for the colormap.""" return list(self._contrast_limits) @contrast_limits.setter def contrast_limits(self, contrast_limits): validate_2_tuple(contrast_limits) _validate_increasing(contrast_limits) self._contrast_limits_msg = ( format_float(contrast_limits[0]) + ', ' + format_float(contrast_limits[1]) ) self._contrast_limits = contrast_limits # make sure range slider is big enough to fit range newrange = list(self.contrast_limits_range) newrange[0] = min(newrange[0], contrast_limits[0]) newrange[1] = max(newrange[1], contrast_limits[1]) self.contrast_limits_range = newrange self._update_thumbnail() self.events.contrast_limits() @property def contrast_limits_range(self): """The current valid range of the contrast limits.""" return list(self._contrast_limits_range) @contrast_limits_range.setter def contrast_limits_range(self, value): """Set the valid range of the contrast limits. If either value is "None", the current range will be preserved. If the range overlaps the current contrast limits, the range will be set requested and there will be no change the contrast limits. If the requested contrast range limits are completely outside the current contrast limits, the range will be set as requested and the contrast limits will be reset to the new range. """ validate_2_tuple(value) _validate_increasing(value) if list(value) == self.contrast_limits_range: return # if either value is "None", it just preserves the current range current_range = self.contrast_limits_range value = list(value) # make sure it is mutable for i in range(2): value[i] = current_range[i] if value[i] is None else value[i] self._contrast_limits_range = value self.events.contrast_limits_range() # make sure that the contrast limits fit within the new range # this also serves the purpose of emitting events.contrast_limits() # and updating the views/controllers if hasattr(self, '_contrast_limits') and any(self._contrast_limits): clipped_limits = np.clip(self.contrast_limits, *value) if clipped_limits[0] < clipped_limits[1]: self.contrast_limits = tuple(clipped_limits) else: self.contrast_limits = tuple(value) @property def gamma(self): return self._gamma @gamma.setter def gamma(self, value): self._gamma = value self._update_thumbnail() self.events.gamma() napari-0.5.0a1/napari/layers/labels/000077500000000000000000000000001437041365600172345ustar00rootroot00000000000000napari-0.5.0a1/napari/layers/labels/__init__.py000066400000000000000000000005331437041365600213460ustar00rootroot00000000000000from napari.layers.labels import _labels_key_bindings from napari.layers.labels.labels import Labels # Note that importing _labels_key_bindings is needed as the Labels layer gets # decorated with keybindings during that process, but it is not directly needed # by our users and so is deleted below del _labels_key_bindings __all__ = ['Labels'] napari-0.5.0a1/napari/layers/labels/_labels_constants.py000066400000000000000000000050611437041365600233050ustar00rootroot00000000000000import sys from collections import OrderedDict from enum import auto from napari.utils.misc import StringEnum from napari.utils.translations import trans class Mode(StringEnum): """MODE: Interactive mode. The normal, default mode is PAN_ZOOM, which allows for normal interactivity with the canvas. In PICK mode the cursor functions like a color picker, setting the clicked on label to be the current label. If the background is picked it will select the background label `0`. In PAINT mode the cursor functions like a paint brush changing any pixels it brushes over to the current label. If the background label `0` is selected than any pixels will be changed to background and this tool functions like an eraser. The size and shape of the cursor can be adjusted in the properties widget. In FILL mode the cursor functions like a fill bucket replacing pixels of the label clicked on with the current label. It can either replace all pixels of that label or just those that are contiguous with the clicked on pixel. If the background label `0` is selected than any pixels will be changed to background and this tool functions like an eraser. In ERASE mode the cursor functions similarly to PAINT mode, but to paint with background label, which effectively removes the label. """ PAN_ZOOM = auto() TRANSFORM = auto() PICK = auto() PAINT = auto() FILL = auto() ERASE = auto() class LabelColorMode(StringEnum): """ LabelColorMode: Labelling Color setting mode. AUTO (default) allows color to be set via a hash function with a seed. DIRECT allows color of each label to be set directly by a color dictionary. SELECTED allows only selected labels to be visible """ AUTO = auto() DIRECT = auto() BACKSPACE = 'delete' if sys.platform == 'darwin' else 'backspace' LABEL_COLOR_MODE_TRANSLATIONS = OrderedDict( [ (LabelColorMode.AUTO, trans._("auto")), (LabelColorMode.DIRECT, trans._("direct")), ] ) class LabelsRendering(StringEnum): """Rendering: Rendering mode for the Labels layer. Selects a preset rendering mode in vispy * translucent: voxel colors are blended along the view ray until the result is opaque. * iso_categorical: isosurface for categorical data. Cast a ray until a non-background value is encountered. At that location, lighning calculations are performed to give the visual appearance of a surface. """ TRANSLUCENT = auto() ISO_CATEGORICAL = auto() napari-0.5.0a1/napari/layers/labels/_labels_key_bindings.py000066400000000000000000000070471437041365600237440ustar00rootroot00000000000000import numpy as np from app_model.types import KeyCode, KeyMod from napari.layers.labels._labels_constants import Mode from napari.layers.labels.labels import Labels from napari.layers.utils.layer_utils import ( register_layer_action, register_layer_attr_action, ) from napari.utils.translations import trans MIN_BRUSH_SIZE = 1 MAX_BRUSH_SIZE = 40 def register_label_action(description: str, repeatable: bool = False): return register_layer_action(Labels, description, repeatable) def register_label_mode_action(description): return register_layer_attr_action(Labels, description, 'mode') @register_label_mode_action(trans._('Transform')) def activate_labels_transform_mode(layer): layer.mode = Mode.TRANSFORM @register_label_mode_action(trans._('Pan/zoom')) def activate_labels_pan_zoom_mode(layer): layer.mode = Mode.PAN_ZOOM @register_label_mode_action(trans._("Activate the paint brush")) def activate_labels_paint_mode(layer: Labels): layer.mode = Mode.PAINT @register_label_mode_action(trans._("Activate the fill bucket")) def activate_labels_fill_mode(layer: Labels): layer.mode = Mode.FILL @register_label_mode_action(trans._('Pick mode')) def activate_labels_picker_mode(layer: Labels): """Activate the label picker.""" layer.mode = Mode.PICK @register_label_mode_action(trans._("Activate the label eraser")) def activate_labels_erase_mode(layer: Labels): layer.mode = Mode.ERASE labels_fun_to_mode = [ (activate_labels_pan_zoom_mode, Mode.PAN_ZOOM), (activate_labels_transform_mode, Mode.TRANSFORM), (activate_labels_erase_mode, Mode.ERASE), (activate_labels_paint_mode, Mode.PAINT), (activate_labels_fill_mode, Mode.FILL), (activate_labels_picker_mode, Mode.PICK), ] @register_label_action( trans._( "Set the currently selected label to the largest used label plus one." ), ) def new_label(layer: Labels): """Set the currently selected label to the largest used label plus one.""" layer.selected_label = np.max(layer.data) + 1 @register_label_action( trans._("Decrease the currently selected label by one."), ) def decrease_label_id(layer: Labels): layer.selected_label -= 1 @register_label_action( trans._("Increase the currently selected label by one."), ) def increase_label_id(layer: Labels): layer.selected_label += 1 @register_label_action( trans._("Decrease the paint brush size by one."), repeatable=True, ) def decrease_brush_size(layer: Labels): """Decrease the brush size""" if ( layer.brush_size > MIN_BRUSH_SIZE ): # here we should probably add a non-hard-coded # reference to the limit values of brush size? layer.brush_size -= 1 @register_label_action( trans._("Increase the paint brush size by one."), repeatable=True, ) def increase_brush_size(layer: Labels): """Increase the brush size""" if ( layer.brush_size < MAX_BRUSH_SIZE ): # here we should probably add a non-hard-coded # reference to the limit values of brush size? layer.brush_size += 1 @register_layer_attr_action( Labels, trans._("Toggle preserve labels"), "preserve_labels" ) def toggle_preserve_labels(layer: Labels): layer.preserve_labels = not layer.preserve_labels @Labels.bind_key(KeyMod.CtrlCmd | KeyCode.KeyZ) def undo(layer: Labels): """Undo the last paint or fill action since the view slice has changed.""" layer.undo() @Labels.bind_key(KeyMod.CtrlCmd | KeyMod.Shift | KeyCode.KeyZ) def redo(layer: Labels): """Redo any previously undone actions.""" layer.redo() napari-0.5.0a1/napari/layers/labels/_labels_mouse_bindings.py000066400000000000000000000040431437041365600242750ustar00rootroot00000000000000from napari.layers.labels._labels_constants import Mode from napari.layers.labels._labels_utils import mouse_event_to_labels_coordinate def draw(layer, event): """Draw with the currently selected label to a coordinate. This method have different behavior when draw is called with different labeling layer mode. In PAINT mode the cursor functions like a paint brush changing any pixels it brushes over to the current label. If the background label `0` is selected than any pixels will be changed to background and this tool functions like an eraser. The size and shape of the cursor can be adjusted in the properties widget. In FILL mode the cursor functions like a fill bucket replacing pixels of the label clicked on with the current label. It can either replace all pixels of that label or just those that are contiguous with the clicked on pixel. If the background label `0` is selected than any pixels will be changed to background and this tool functions like an eraser """ coordinates = mouse_event_to_labels_coordinate(layer, event) if layer._mode == Mode.ERASE: new_label = layer._background_label else: new_label = layer.selected_label # on press with layer.block_history(): layer._draw(new_label, coordinates, coordinates) yield last_cursor_coord = coordinates # on move while event.type == 'mouse_move': coordinates = mouse_event_to_labels_coordinate(layer, event) if coordinates is not None or last_cursor_coord is not None: layer._draw(new_label, last_cursor_coord, coordinates) last_cursor_coord = coordinates yield def pick(layer, event): """Change the selected label to the same as the region clicked.""" # on press layer.selected_label = ( layer.get_value( event.position, view_direction=event.view_direction, dims_displayed=event.dims_displayed, world=True, ) or 0 ) napari-0.5.0a1/napari/layers/labels/_labels_utils.py000066400000000000000000000137371437041365600224420ustar00rootroot00000000000000from functools import lru_cache import numpy as np def interpolate_coordinates(old_coord, new_coord, brush_size): """Interpolates coordinates depending on brush size. Useful for ensuring painting is continuous in labels layer. Parameters ---------- old_coord : np.ndarray, 1x2 Last position of cursor. new_coord : np.ndarray, 1x2 Current position of cursor. brush_size : float Size of brush, which determines spacing of interpolation. Returns ------- coords : np.array, Nx2 List of coordinates to ensure painting is continuous """ if old_coord is None: old_coord = new_coord if new_coord is None: new_coord = old_coord num_step = round( max(abs(np.array(new_coord) - np.array(old_coord))) / brush_size * 4 ) coords = [ np.linspace(old_coord[i], new_coord[i], num=int(num_step + 1)) for i in range(len(new_coord)) ] coords = np.stack(coords).T if len(coords) > 1: coords = coords[1:] return coords @lru_cache(maxsize=64) def sphere_indices(radius, scale): """Generate centered indices within circle or n-dim ellipsoid. Parameters ------- radius : float Radius of circle/sphere scale : tuple of float The scaling to apply to the sphere along each axis Returns ------- mask_indices : array Centered indices within circle/sphere """ ndim = len(scale) abs_scale = np.abs(scale) scale_normalized = np.asarray(abs_scale, dtype=float) / np.min(abs_scale) # Create multi-dimensional grid to check for # circle/membership around center r_normalized = radius / scale_normalized + 0.5 slices = [ slice(-int(np.ceil(r)), int(np.floor(r)) + 1) for r in r_normalized ] indices = np.mgrid[slices].T.reshape(-1, ndim) distances_sq = np.sum((indices * scale_normalized) ** 2, axis=1) # Use distances within desired radius to mask indices in grid mask_indices = indices[distances_sq <= radius**2].astype(int) return mask_indices def indices_in_shape(idxs, shape): """Return idxs after filtering out indices that are not in given shape. Parameters ---------- idxs : tuple of array of int, or 2D array of int The input coordinates. These should be in one of two formats: - a tuple of 1D arrays, as for NumPy fancy indexing, or - a 2D array of shape (ncoords, ndim), as a list of coordinates shape : tuple of int The shape in which all indices must fit. Returns ------- idxs_filtered : tuple of array of int, or 2D array of int The subset of the input idxs that falls within shape. Examples -------- >>> idxs0 = (np.array([5, 45, 2]), np.array([6, 5, -5])) >>> indices_in_shape(idxs0, (10, 10)) (array([5]), array([6])) >>> idxs1 = np.transpose(idxs0) >>> indices_in_shape(idxs1, (10, 10)) array([[5, 6]]) """ np_index = isinstance(idxs, tuple) if np_index: # normalize to 2D coords array idxs = np.transpose(idxs) keep_coords = np.logical_and( np.all(idxs >= 0, axis=1), np.all(idxs < np.array(shape), axis=1) ) filtered = idxs[keep_coords] if np_index: # convert back to original format filtered = tuple(filtered.T) return filtered def get_dtype(layer): """Returns dtype of layer data Parameters ---------- layer : Labels Labels layer (may be multiscale) Returns ------- dtype dtype of Layer data """ layer_data = layer.data if not isinstance(layer_data, list): layer_data = [layer_data] layer_data_level = layer_data[0] if hasattr(layer_data_level, 'dtype'): layer_dtype = layer_data_level[0].dtype else: layer_dtype = type(layer_data_level) return layer_dtype def first_nonzero_coordinate(data, start_point, end_point): """Coordinate of the first nonzero element between start and end points. Parameters ---------- data : nD array, shape (N1, N2, ..., ND) A data volume. start_point : array, shape (D,) The start coordinate to check. end_point : array, shape (D,) The end coordinate to check. Returns ------- coordinates : array of int, shape (D,) The coordinates of the first nonzero element along the ray, or None. """ shape = np.asarray(data.shape) length = np.linalg.norm(end_point - start_point) length_int = np.round(length).astype(int) coords = np.linspace(start_point, end_point, length_int + 1, endpoint=True) clipped_coords = np.clip(np.round(coords), 0, shape - 1).astype(int) nonzero = np.flatnonzero(data[tuple(clipped_coords.T)]) return None if len(nonzero) == 0 else clipped_coords[nonzero[0]] def mouse_event_to_labels_coordinate(layer, event): """Return the data coordinate of a Labels layer mouse event in 2D or 3D. In 2D, this is just the event's position transformed by the layer's world_to_data transform. In 3D, a ray is cast in data coordinates, and the coordinate of the first nonzero value along that ray is returned. If the ray only contains zeros, None is returned. Parameters ---------- layer : napari.layers.Labels The Labels layer. event : vispy MouseEvent The mouse event, containing position and view direction attributes. Returns ------- coordinates : array of int or None The data coordinates for the mouse event. """ ndim = len(layer._slice_input.displayed) if ndim == 2: coordinates = layer.world_to_data(event.position) else: # 3d start, end = layer.get_ray_intersections( position=event.position, view_direction=event.view_direction, dims_displayed=layer._slice_input.displayed, world=True, ) if start is None and end is None: return None coordinates = first_nonzero_coordinate(layer.data, start, end) return coordinates napari-0.5.0a1/napari/layers/labels/_tests/000077500000000000000000000000001437041365600205355ustar00rootroot00000000000000napari-0.5.0a1/napari/layers/labels/_tests/test_labels.py000066400000000000000000001325451437041365600234220ustar00rootroot00000000000000import itertools import time from dataclasses import dataclass from tempfile import TemporaryDirectory from typing import List import numpy as np import pandas as pd import pytest import xarray as xr import zarr from numpy.core.numerictypes import issubdtype from numpy.testing import assert_array_almost_equal, assert_raises from skimage import data from vispy.color import Colormap as VispyColormap from napari._tests.utils import check_layer_world_data_extent from napari.components import ViewerModel from napari.layers import Labels from napari.layers.labels._labels_constants import LabelsRendering from napari.utils import Colormap from napari.utils.colormaps import label_colormap, low_discrepancy_image def test_random_labels(): """Test instantiating Labels layer with random 2D data.""" shape = (10, 15) np.random.seed(0) data = np.random.randint(20, size=shape) layer = Labels(data) assert np.all(layer.data == data) assert layer.ndim == len(shape) np.testing.assert_array_equal(layer.extent.data[1], shape) assert layer._data_view.shape == shape[-2:] assert layer.editable is True def test_all_zeros_labels(): """Test instantiating Labels layer with all zeros data.""" shape = (10, 15) data = np.zeros(shape, dtype=int) layer = Labels(data) assert np.all(layer.data == data) assert layer.ndim == len(shape) np.testing.assert_array_equal(layer.extent.data[1], shape) assert layer._data_view.shape == shape[-2:] def test_3D_labels(): """Test instantiating Labels layer with random 3D data.""" shape = (6, 10, 15) np.random.seed(0) data = np.random.randint(20, size=shape) layer = Labels(data) assert np.all(layer.data == data) assert layer.ndim == len(shape) np.testing.assert_array_equal(layer.extent.data[1], shape) assert layer._data_view.shape == shape[-2:] assert layer.editable is True layer._slice_dims(ndisplay=3) assert layer._slice_input.ndisplay == 3 assert layer.editable is True assert layer.mode == 'pan_zoom' def test_float_labels(): """Test instantiating labels layer with floats""" np.random.seed(0) data = np.random.uniform(0, 20, size=(10, 10)) with pytest.raises(TypeError): Labels(data) data0 = np.random.uniform(20, size=(20, 20)) data1 = data0[::2, ::2].astype(np.int32) data = [data0, data1] with pytest.raises(TypeError): Labels(data) def test_bool_labels(): """Test instantiating labels layer with bools""" data = np.zeros((10, 10), dtype=bool) layer = Labels(data) assert issubdtype(layer.data.dtype, np.integer) data0 = np.zeros((20, 20), dtype=bool) data1 = data0[::2, ::2].astype(np.int32) data = [data0, data1] layer = Labels(data) assert all(issubdtype(d.dtype, np.integer) for d in layer.data) def test_changing_labels(): """Test changing Labels data.""" shape_a = (10, 15) shape_b = (20, 12) shape_c = (10, 10) np.random.seed(0) data_a = np.random.randint(20, size=shape_a) data_b = np.random.randint(20, size=shape_b) layer = Labels(data_a) layer.data = data_b assert np.all(layer.data == data_b) assert layer.ndim == len(shape_b) np.testing.assert_array_equal(layer.extent.data[1], shape_b) assert layer._data_view.shape == shape_b[-2:] data_c = np.zeros(shape_c, dtype=bool) layer.data = data_c assert np.issubdtype(layer.data.dtype, np.integer) data_c = data_c.astype(np.float32) with pytest.raises(TypeError): layer.data = data_c def test_changing_labels_dims(): """Test changing Labels data including dimensionality.""" shape_a = (10, 15) shape_b = (20, 12, 6) np.random.seed(0) data_a = np.random.randint(20, size=shape_a) data_b = np.random.randint(20, size=shape_b) layer = Labels(data_a) layer.data = data_b assert np.all(layer.data == data_b) assert layer.ndim == len(shape_b) np.testing.assert_array_equal(layer.extent.data[1], shape_b) assert layer._data_view.shape == shape_b[-2:] def test_changing_modes(): """Test changing modes.""" np.random.seed(0) data = np.random.randint(20, size=(10, 15)) layer = Labels(data) assert layer.mode == 'pan_zoom' assert layer.interactive is True layer.mode = 'fill' assert layer.mode == 'fill' assert layer.interactive is False layer.mode = 'paint' assert layer.mode == 'paint' assert layer.interactive is False layer.mode = 'pick' assert layer.mode == 'pick' assert layer.interactive is False layer.mode = 'pan_zoom' assert layer.mode == 'pan_zoom' assert layer.interactive is True layer.mode = 'paint' assert layer.mode == 'paint' layer.editable = False assert layer.mode == 'pan_zoom' assert layer.editable is False def test_name(): """Test setting layer name.""" np.random.seed(0) data = np.random.randint(20, size=(10, 15)) layer = Labels(data) assert layer.name == 'Labels' layer = Labels(data, name='random') assert layer.name == 'random' layer.name = 'lbls' assert layer.name == 'lbls' def test_visiblity(): """Test setting layer visibility.""" np.random.seed(0) data = np.random.randint(20, size=(10, 15)) layer = Labels(data) assert layer.visible is True layer.visible = False assert layer.visible is False layer = Labels(data, visible=False) assert layer.visible is False layer.visible = True assert layer.visible is True def test_opacity(): """Test setting layer opacity.""" np.random.seed(0) data = np.random.randint(20, size=(10, 15)) layer = Labels(data) assert layer.opacity == 0.7 layer.opacity = 0.5 assert layer.opacity == 0.5 layer = Labels(data, opacity=0.6) assert layer.opacity == 0.6 layer.opacity = 0.3 assert layer.opacity == 0.3 def test_blending(): """Test setting layer blending.""" np.random.seed(0) data = np.random.randint(20, size=(10, 15)) layer = Labels(data) assert layer.blending == 'translucent' layer.blending = 'additive' assert layer.blending == 'additive' layer = Labels(data, blending='additive') assert layer.blending == 'additive' layer.blending = 'opaque' assert layer.blending == 'opaque' def test_seed(): """Test setting seed.""" np.random.seed(0) data = np.random.randint(20, size=(10, 15)) layer = Labels(data) assert layer.seed == 0.5 layer.seed = 0.9 assert layer.seed == 0.9 layer = Labels(data, seed=0.7) assert layer.seed == 0.7 # ensure setting seed triggers # recalculation of _all_vals _all_vals_07 = layer._all_vals.copy() layer.seed = 0.4 _all_vals_04 = layer._all_vals.copy() assert_raises( AssertionError, assert_array_almost_equal, _all_vals_04, _all_vals_07 ) def test_num_colors(): """Test setting number of colors in colormap.""" np.random.seed(0) data = np.random.randint(20, size=(10, 15)) layer = Labels(data) assert layer.num_colors == 50 layer.num_colors = 80 assert layer.num_colors == 80 layer = Labels(data, num_colors=60) assert layer.num_colors == 60 def test_properties(): """Test adding labels with properties.""" np.random.seed(0) data = np.random.randint(20, size=(10, 15)) layer = Labels(data) assert isinstance(layer.properties, dict) assert len(layer.properties) == 0 properties = { 'class': np.array(['Background'] + [f'Class {i}' for i in range(20)]) } label_index = {i: i for i in range(len(properties['class']))} layer = Labels(data, properties=properties) assert isinstance(layer.properties, dict) np.testing.assert_equal(layer.properties, properties) assert layer._label_index == label_index layer = Labels(data) layer.properties = properties assert isinstance(layer.properties, dict) np.testing.assert_equal(layer.properties, properties) assert layer._label_index == label_index current_label = layer.get_value((0, 0)) layer_message = layer.get_status((0, 0)) assert layer_message['coordinates'].endswith(f'Class {current_label - 1}') properties = {'class': ['Background']} layer = Labels(data, properties=properties) layer_message = layer.get_status((0, 0)) assert layer_message['coordinates'].endswith("[No Properties]") properties = {'class': ['Background', 'Class 12'], 'index': [0, 12]} label_index = {0: 0, 12: 1} layer = Labels(data, properties=properties) layer_message = layer.get_status((0, 0)) assert layer._label_index == label_index assert layer_message['coordinates'].endswith('Class 12') layer = Labels(data) layer.properties = properties layer_message = layer.get_status((0, 0)) assert layer._label_index == label_index assert layer_message['coordinates'].endswith('Class 12') layer = Labels(data) layer.properties = pd.DataFrame(properties) layer_message = layer.get_status((0, 0)) assert layer._label_index == label_index assert layer_message['coordinates'].endswith('Class 12') def test_default_properties_assignment(): """Test that the default properties value can be assigned to properties see https://github.com/napari/napari/issues/2477 """ np.random.seed(0) data = np.random.randint(20, size=(10, 15)) layer = Labels(data) layer.properties = {} assert layer.properties == {} def test_multiscale_properties(): """Test adding labels with multiscale properties.""" np.random.seed(0) data0 = np.random.randint(20, size=(10, 15)) data1 = data0[::2, ::2] data = [data0, data1] layer = Labels(data) assert isinstance(layer.properties, dict) assert len(layer.properties) == 0 properties = { 'class': np.array(['Background'] + [f'Class {i}' for i in range(20)]) } label_index = {i: i for i in range(len(properties['class']))} layer = Labels(data, properties=properties) assert isinstance(layer.properties, dict) np.testing.assert_equal(layer.properties, properties) assert layer._label_index == label_index current_label = layer.get_value((0, 0))[1] layer_message = layer.get_status((0, 0)) assert layer_message['coordinates'].endswith(f'Class {current_label - 1}') properties = {'class': ['Background']} layer = Labels(data, properties=properties) layer_message = layer.get_status((0, 0)) assert layer_message['coordinates'].endswith("[No Properties]") properties = {'class': ['Background', 'Class 12'], 'index': [0, 12]} label_index = {0: 0, 12: 1} layer = Labels(data, properties=properties) layer_message = layer.get_status((0, 0)) assert layer._label_index == label_index assert layer_message['coordinates'].endswith('Class 12') def test_colormap(): """Test colormap.""" np.random.seed(0) data = np.random.randint(20, size=(10, 15)) layer = Labels(data) assert isinstance(layer.colormap, Colormap) assert layer.colormap.name == 'label_colormap' layer.new_colormap() assert isinstance(layer.colormap, Colormap) assert layer.colormap.name == 'label_colormap' def test_label_colormap(): """Test a label colormap.""" colormap = label_colormap(num_colors=4) # Make sure color 0 is transparent assert not np.any(colormap.map([0.0])) # Test that out-of-range values map to last value assert np.all(colormap.map([1.0, 1.1, 2.0]) == colormap.colors[-1]) def test_custom_color_dict(): """Test custom color dict.""" np.random.seed(0) data = np.random.randint(20, size=(10, 15)) layer = Labels( data, color={2: 'white', 4: 'red', 8: 'blue', 16: 'red', 32: 'blue'} ) # test with custom color dict assert type(layer.get_color(2)) == np.ndarray assert type(layer.get_color(1)) == np.ndarray assert (layer.get_color(2) == np.array([1.0, 1.0, 1.0, 1.0])).all() assert (layer.get_color(4) == layer.get_color(16)).all() assert (layer.get_color(8) == layer.get_color(32)).all() # Test to see if our label mapped control points map to those in the colormap # with an extra half step. local_controls = np.array( sorted(np.unique(list(layer._label_color_index.values()) + [1.0])) ) colormap_controls = np.array(layer._colormap.controls) assert np.max(np.abs(local_controls - colormap_controls)) == pytest.approx( 0.5 / (len(colormap_controls) - 1) ) # test disable custom color dict # should not initialize as white since we are using random.seed layer.color_mode = 'auto' assert not (layer.get_color(1) == np.array([1.0, 1.0, 1.0, 1.0])).all() def test_large_custom_color_dict(): """Confirm that the napari & vispy colormaps behave the same.""" label_count = 897 colors = { color: (0, (color / 256.0) / 256.0, (color % 256) / 256.0) for color in range(label_count) } data, _ = np.meshgrid(range(label_count), range(5)) layer = Labels(data, color=colors) # Get color list using layer interface & napari.utils.colormap.ColorMap label_color = layer.get_color(list(range(label_count))) # Get the color by converting to control points with the layer and passing # that to a vispy.color.colormap.Colormap vispy_colormap = VispyColormap( colors=layer.colormap.colors, controls=layer.colormap.controls, interpolation='zero', ) label_color_controls = [ layer._label_color_index[x] for x in range(label_count) ] vispy_colors = vispy_colormap.map( np.array([x for x in label_color_controls]) ) assert (label_color == vispy_colors).all() def test_warning_too_many_colors(): label_count = 1500 colors = { color: (0, (color / 256.0) / 256.0, (color % 256) / 256.0) for color in range(label_count) } data, _ = np.meshgrid(range(label_count), range(5)) with pytest.warns(UserWarning): # Expect a warning for 1500 colors > 1024 in LUT Labels(data, color=colors) def test_add_colors(): """Test adding new colors""" data = np.random.randint(20, size=(40, 40)) layer = Labels(data) assert len(layer._all_vals) == np.max(data) + 1 layer.selected_label = 51 assert len(layer._all_vals) == 52 layer.show_selected_label = True layer.selected_label = 53 assert len(layer._all_vals) == 54 def test_metadata(): """Test setting labels metadata.""" np.random.seed(0) data = np.random.randint(20, size=(10, 15)) layer = Labels(data) assert layer.metadata == {} layer = Labels(data, metadata={'unit': 'cm'}) assert layer.metadata == {'unit': 'cm'} def test_brush_size(): """Test changing brush size.""" np.random.seed(0) data = np.random.randint(20, size=(10, 15)) layer = Labels(data) assert layer.brush_size == 10 layer.brush_size = 20 assert layer.brush_size == 20 def test_contiguous(): """Test changing contiguous.""" np.random.seed(0) data = np.random.randint(20, size=(10, 15)) layer = Labels(data) assert layer.contiguous is True layer.contiguous = False assert layer.contiguous is False def test_n_edit_dimensions(): """Test changing the number of editable dimensions.""" np.random.seed(0) data = np.random.randint(20, size=(5, 10, 15)) layer = Labels(data) layer.n_edit_dimensions = 2 layer.n_edit_dimensions = 3 @pytest.mark.parametrize( "input_data, expected_data_view", [ ( np.array( [ [0, 0, 0, 0, 0, 0, 0, 0, 0, 0], [0, 0, 0, 0, 0, 0, 0, 0, 0, 0], [0, 0, 0, 0, 0, 5, 5, 5, 0, 0], [0, 0, 1, 1, 1, 5, 5, 5, 0, 0], [0, 0, 1, 1, 1, 5, 5, 5, 0, 0], [0, 0, 1, 1, 1, 5, 5, 5, 0, 0], [0, 0, 0, 0, 0, 5, 5, 5, 0, 0], [0, 0, 0, 0, 0, 0, 0, 0, 0, 0], [0, 0, 0, 0, 0, 0, 0, 0, 0, 0], ], dtype=np.int_, ), np.array( [ [0, 0, 0, 0, 0, 0, 0, 0, 0, 0], [0, 0, 0, 0, 0, 0, 0, 0, 0, 0], [0, 0, 0, 0, 0, 5, 5, 5, 0, 0], [0, 0, 1, 1, 1, 5, 0, 5, 0, 0], [0, 0, 1, 0, 1, 5, 0, 5, 0, 0], [0, 0, 1, 1, 1, 5, 0, 5, 0, 0], [0, 0, 0, 0, 0, 5, 5, 5, 0, 0], [0, 0, 0, 0, 0, 0, 0, 0, 0, 0], [0, 0, 0, 0, 0, 0, 0, 0, 0, 0], ], dtype=np.int_, ), ), ( np.array( [ [1, 1, 0, 0, 0, 0, 0, 2, 2, 2], [1, 1, 0, 0, 0, 0, 0, 2, 2, 2], [0, 0, 0, 0, 0, 0, 0, 0, 0, 0], [0, 0, 0, 0, 0, 0, 0, 0, 0, 0], [0, 0, 0, 0, 0, 0, 0, 0, 0, 0], [0, 0, 0, 0, 0, 0, 4, 4, 4, 4], [3, 3, 3, 0, 0, 0, 4, 4, 4, 4], [3, 3, 3, 0, 0, 0, 4, 4, 4, 4], [3, 3, 3, 0, 0, 0, 4, 4, 4, 4], ], dtype=np.int_, ), np.array( [ [0, 1, 0, 0, 0, 0, 0, 2, 0, 0], [1, 1, 0, 0, 0, 0, 0, 2, 2, 2], [0, 0, 0, 0, 0, 0, 0, 0, 0, 0], [0, 0, 0, 0, 0, 0, 0, 0, 0, 0], [0, 0, 0, 0, 0, 0, 0, 0, 0, 0], [0, 0, 0, 0, 0, 0, 4, 4, 4, 4], [3, 3, 3, 0, 0, 0, 4, 0, 0, 0], [0, 0, 3, 0, 0, 0, 4, 0, 0, 0], [0, 0, 3, 0, 0, 0, 4, 0, 0, 0], ], dtype=np.int_, ), ), ( 5 * np.ones((9, 10), dtype=np.uint32), np.zeros((9, 10), dtype=np.uint32), ), ], ) def test_contour(input_data, expected_data_view): """Test changing contour.""" layer = Labels(input_data) assert layer.contour == 0 np.testing.assert_array_equal(layer.data, input_data) np.testing.assert_array_equal( layer._raw_to_displayed(input_data), layer._data_view ) data_view_before_contour = layer._data_view.copy() layer.contour = 1 assert layer.contour == 1 # Check `layer.data` didn't change np.testing.assert_array_equal(layer.data, input_data) # Check what is returned in the view of the data np.testing.assert_array_equal( layer._data_view, np.where( expected_data_view > 0, low_discrepancy_image(expected_data_view), 0, ), ) # Check the view of the data changed after setting the contour with np.testing.assert_raises(AssertionError): np.testing.assert_array_equal( data_view_before_contour, layer._data_view ) layer.contour = 0 assert layer.contour == 0 # Check it's in the same state as before setting the contour np.testing.assert_array_equal( layer._raw_to_displayed(input_data), layer._data_view ) def test_contour_large_new_labels(): """Check that new labels larger than the lookup table work in contour mode. References ---------- [1]: https://forum.image.sc/t/data-specific-reason-for-indexerror-in-raw-to-displayed/60808 [2]: https://github.com/napari/napari/pull/3697 """ viewer = ViewerModel() labels = np.zeros((5, 10, 10), dtype=int) labels[0, 4:6, 4:6] = 1 labels[4, 4:6, 4:6] = 1000 labels_layer = viewer.add_labels(labels) labels_layer.contour = 1 # This used to fail with IndexError viewer.dims.set_point(axis=0, value=4) def test_selecting_label(): """Test selecting label.""" np.random.seed(0) data = np.random.randint(20, size=(10, 15)) layer = Labels(data) assert layer.selected_label == 1 assert (layer._selected_color == layer.get_color(1)).all layer.selected_label = 1 assert layer.selected_label == 1 assert len(layer._selected_color) == 4 def test_label_color(): """Test getting label color.""" np.random.seed(0) data = np.random.randint(20, size=(10, 15)) layer = Labels(data) col = layer.get_color(0) assert col is None col = layer.get_color(1) assert len(col) == 4 def test_show_selected_label(): """Test color of labels when filtering to selected labels""" np.random.seed(0) data = np.random.randint(20, size=(10, 15)) layer = Labels(data) original_color = layer.get_color(1) layer.show_selected_label = True original_background_color = layer.get_color(layer._background_label) none_color = layer.get_color(None) layer.selected_label = 1 # color of selected label has not changed assert np.allclose(layer.get_color(layer.selected_label), original_color) current_background_color = layer.get_color(layer._background_label) # color of background is background color assert current_background_color == original_background_color # color of all others is none color other_labels = np.unique(layer.data)[2:] other_colors = np.array( list(map(lambda x: layer.get_color(x), other_labels)) ) assert np.allclose(other_colors, none_color) def test_paint(): """Test painting labels with different circle brush sizes.""" np.random.seed(0) data = np.random.randint(20, size=(10, 15)) data[:10, :10] = 1 layer = Labels(data) assert np.unique(layer.data[:5, :5]) == 1 assert np.unique(layer.data[5:10, 5:10]) == 1 layer.brush_size = 9 layer.paint([0, 0], 2) assert np.unique(layer.data[:4, :4]) == 2 assert np.unique(layer.data[5:10, 5:10]) == 1 layer.brush_size = 10 layer.paint([0, 0], 2) assert np.unique(layer.data[0:6, 0:3]) == 2 assert np.unique(layer.data[0:3, 0:6]) == 2 assert np.unique(layer.data[6:10, 6:10]) == 1 layer.brush_size = 19 layer.paint([0, 0], 2) assert np.unique(layer.data[0:4, 0:10]) == 2 assert np.unique(layer.data[0:10, 0:4]) == 2 assert np.unique(layer.data[3:7, 3:7]) == 2 assert np.unique(layer.data[7:10, 7:10]) == 1 def test_paint_with_preserve_labels(): """Test painting labels with square brush while preserving existing labels.""" data = np.zeros((15, 10), dtype=np.uint32) data[:3, :3] = 1 layer = Labels(data) layer.preserve_labels = True assert np.unique(layer.data[:3, :3]) == 1 layer.brush_size = 9 layer.paint([0, 0], 2) assert np.unique(layer.data[3:5, 0:3]) == 2 assert np.unique(layer.data[0:3, 3:5]) == 2 assert np.unique(layer.data[:3, :3]) == 1 def test_paint_2d(): """Test painting labels with circle brush.""" data = np.zeros((40, 40), dtype=np.uint32) layer = Labels(data) layer.brush_size = 12 layer.mode = 'paint' layer.paint((0, 0), 3) layer.brush_size = 12 layer.paint((15, 8), 4) layer.brush_size = 13 layer.paint((30.2, 7.8), 5) layer.brush_size = 12 layer.paint((39, 39), 6) layer.brush_size = 20 layer.paint((15, 27), 7) assert np.sum(layer.data[:8, :8] == 3) == 41 assert np.sum(layer.data[9:22, 2:15] == 4) == 137 assert np.sum(layer.data[24:37, 2:15] == 5) == 137 assert np.sum(layer.data[33:, 33:] == 6) == 41 assert np.sum(layer.data[5:26, 17:38] == 7) == 349 def test_paint_2d_xarray(): """Test the memory usage of painting an xarray indirectly via timeout.""" now = time.monotonic() data = xr.DataArray(np.zeros((3, 3, 1024, 1024), dtype=np.uint32)) layer = Labels(data) layer.brush_size = 12 layer.mode = 'paint' layer.paint((1, 1, 512, 512), 3) assert isinstance(layer.data, xr.DataArray) assert layer.data.sum() == 411 elapsed = time.monotonic() - now assert elapsed < 1, "test was too slow, computation was likely not lazy" def test_paint_3d(): """Test painting labels with circle brush on 3D image.""" data = np.zeros((30, 40, 40), dtype=np.uint32) layer = Labels(data) layer.brush_size = 12 layer.mode = 'paint' # Paint in 2D layer.paint((10, 10, 10), 3) # Paint in 3D layer.n_edit_dimensions = 3 layer.paint((10, 25, 10), 4) # Paint in 3D, preserve labels layer.n_edit_dimensions = 3 layer.preserve_labels = True layer.paint((10, 15, 15), 5) assert np.sum(layer.data[4:17, 4:17, 4:17] == 3) == 137 assert np.sum(layer.data[4:17, 19:32, 4:17] == 4) == 1189 assert np.sum(layer.data[4:17, 9:32, 9:32] == 5) == 1103 def test_fill(): """Test filling labels with different brush sizes.""" np.random.seed(0) data = np.random.randint(20, size=(10, 15)) data[:10, :10] = 2 data[:5, :5] = 1 layer = Labels(data) assert np.unique(layer.data[:5, :5]) == 1 assert np.unique(layer.data[5:10, 5:10]) == 2 layer.fill([0, 0], 3) assert np.unique(layer.data[:5, :5]) == 3 assert np.unique(layer.data[5:10, 5:10]) == 2 def test_value(): """Test getting the value of the data at the current coordinates.""" np.random.seed(0) data = np.random.randint(20, size=(10, 15)) layer = Labels(data) value = layer.get_value((0, 0)) assert value == data[0, 0] @pytest.mark.parametrize( 'position,view_direction,dims_displayed,world', [ ([10, 5, 5], [1, 0, 0], [0, 1, 2], False), ([10, 5, 5], [1, 0, 0], [0, 1, 2], True), ([0, 10, 5, 5], [0, 1, 0, 0], [1, 2, 3], True), ], ) def test_value_3d(position, view_direction, dims_displayed, world): """get_value should return label value in 3D""" data = np.zeros((20, 20, 20), dtype=int) data[0:10, 0:10, 0:10] = 1 layer = Labels(data) layer._slice_dims([0, 0, 0], ndisplay=3) value = layer.get_value( position, view_direction=view_direction, dims_displayed=dims_displayed, world=world, ) assert value == 1 def test_message(): """Test converting value and coords to message.""" np.random.seed(0) data = np.random.randint(20, size=(10, 15)) layer = Labels(data) msg = layer.get_status((0, 0)) assert type(msg) == dict def test_thumbnail(): """Test the image thumbnail for square data.""" np.random.seed(0) data = np.random.randint(20, size=(30, 30)) layer = Labels(data) layer._update_thumbnail() assert layer.thumbnail.shape == layer._thumbnail_shape def test_world_data_extent(): """Test extent after applying transforms.""" np.random.seed(0) shape = (6, 10, 15) data = np.random.randint(20, size=(shape)) layer = Labels(data) extent = np.array(((0,) * 3, shape)) check_layer_world_data_extent(layer, extent, (3, 1, 1), (10, 20, 5), True) @pytest.mark.parametrize( 'brush_size, mode, selected_label, preserve_labels, n_dimensional', list( itertools.product( list(range(1, 22, 5)), ['fill', 'erase', 'paint'], [1, 20, 100], [True, False], [True, False], ) ), ) def test_undo_redo( brush_size, mode, selected_label, preserve_labels, n_dimensional, ): blobs = data.binary_blobs(length=64, volume_fraction=0.3, n_dim=3) layer = Labels(blobs) data_history = [blobs.copy()] layer.brush_size = brush_size layer.mode = mode layer.selected_label = selected_label layer.preserve_labels = preserve_labels layer.n_edit_dimensions = 3 if n_dimensional else 2 coord = np.random.random((3,)) * (np.array(blobs.shape) - 1) while layer.data[tuple(coord.astype(int))] == 0 and np.any(layer.data): coord = np.random.random((3,)) * (np.array(blobs.shape) - 1) if layer.mode == 'fill': layer.fill(coord, layer.selected_label) if layer.mode == 'erase': layer.paint(coord, 0) if layer.mode == 'paint': layer.paint(coord, layer.selected_label) data_history.append(np.copy(layer.data)) layer.undo() np.testing.assert_array_equal(layer.data, data_history[0]) layer.redo() np.testing.assert_array_equal(layer.data, data_history[1]) def test_ndim_fill(): test_array = np.zeros((5, 5, 5, 5), dtype=int) test_array[:, 1:3, 1:3, 1:3] = 1 layer = Labels(test_array) layer.n_edit_dimensions = 3 layer.fill((0, 1, 1, 1), 2) np.testing.assert_equal(layer.data[0, 1:3, 1:3, 1:3], 2) np.testing.assert_equal(layer.data[1, 1:3, 1:3, 1:3], 1) layer.n_edit_dimensions = 4 layer.fill((1, 1, 1, 1), 3) np.testing.assert_equal(layer.data[0, 1:3, 1:3, 1:3], 2) np.testing.assert_equal(layer.data[1:, 1:3, 1:3, 1:3], 3) def test_ndim_paint(): test_array = np.zeros((5, 6, 7, 8), dtype=int) layer = Labels(test_array) layer.n_edit_dimensions = 3 layer.brush_size = 2 # equivalent to 18-connected 3D neighborhood layer.paint((1, 1, 1, 1), 1) assert np.sum(layer.data) == 19 # 18 + center assert not np.any(layer.data[0]) and not np.any(layer.data[2:]) layer.n_edit_dimensions = 2 # 3x3 square layer._slice_dims(order=[1, 2, 0, 3]) layer.paint((4, 5, 6, 7), 8) assert len(np.flatnonzero(layer.data == 8)) == 4 # 2D square is in corner np.testing.assert_array_equal( test_array[:, 5, 6, :], np.array( [ [0, 0, 0, 0, 0, 0, 0, 0], [0, 0, 0, 0, 0, 0, 0, 0], [0, 0, 0, 0, 0, 0, 0, 0], [0, 0, 0, 0, 0, 0, 8, 8], [0, 0, 0, 0, 0, 0, 8, 8], ] ), ) def test_switching_display_func(): label_data = np.random.randint(2**25, 2**25 + 5, size=(50, 50)) layer = Labels(label_data) assert layer._color_lookup_func == layer._lookup_with_low_discrepancy_image label_data = np.random.randint(0, 5, size=(50, 50)) layer = Labels(label_data) assert layer._color_lookup_func == layer._lookup_with_index def test_cursor_size_with_negative_scale(): layer = Labels(np.zeros((5, 5), dtype=int), scale=[-1, -1]) layer.mode = 'paint' assert layer.cursor_size > 0 def test_switching_display_func_during_slicing(): label_array = (5e6 * np.ones((2, 2, 2))).astype(np.uint64) label_array[0, :, :] = [[0, 1], [2, 3]] layer = Labels(label_array) layer._slice_dims(point=(1, 0, 0)) assert layer._color_lookup_func == layer._lookup_with_low_discrepancy_image assert layer._all_vals.size < 1026 def test_add_large_colors(): label_array = (5e6 * np.ones((2, 2, 2))).astype(np.uint64) label_array[0, :, :] = [[0, 1], [2, 3]] layer = Labels(label_array) assert len(layer._all_vals) == 4 layer.show_selected_label = True layer.selected_label = int(5e6) assert layer._all_vals.size < 1026 def test_fill_tensorstore(): ts = pytest.importorskip('tensorstore') labels = np.zeros((5, 7, 8, 9), dtype=int) labels[1, 2:4, 4:6, 4:6] = 1 labels[1, 3:5, 5:7, 6:8] = 2 labels[2, 3:5, 5:7, 6:8] = 3 with TemporaryDirectory(suffix='.zarr') as fout: labels_temp = zarr.open( fout, mode='w', shape=labels.shape, dtype=np.uint32, chunks=(1, 1, 8, 9), ) labels_temp[:] = labels labels_ts_spec = { 'driver': 'zarr', 'kvstore': {'driver': 'file', 'path': fout}, 'path': '', 'metadata': { 'dtype': labels_temp.dtype.str, 'order': labels_temp.order, 'shape': labels.shape, }, } data = ts.open(labels_ts_spec, create=False, open=True).result() layer = Labels(data) layer.n_edit_dimensions = 3 layer.fill((1, 4, 6, 7), 4) modified_labels = np.where(labels == 2, 4, labels) np.testing.assert_array_equal(modified_labels, np.asarray(data)) def test_fill_with_xarray(): """See https://github.com/napari/napari/issues/2374""" data = xr.DataArray(np.zeros((5, 4, 4), dtype=int)) layer = Labels(data) layer.fill((0, 2, 2), 1) np.testing.assert_array_equal(layer.data[0, :, :], np.ones((4, 4))) np.testing.assert_array_equal(layer.data[1:, :, :], np.zeros((4, 4, 4))) # In the associated issue, using xarray.DataArray caused memory allocation # problems due to different read indexing rules, so check that the data # saved for undo has the expected vectorized shape and values. undo_data = layer._undo_history[0][0][1] np.testing.assert_array_equal(undo_data, np.zeros((16,))) @pytest.mark.parametrize( 'scale', list(itertools.product([-2, 2], [-0.5, 0.5], [-0.5, 0.5])) ) def test_paint_3d_negative_scale(scale): labels = np.zeros((3, 5, 11, 11), dtype=int) labels_layer = Labels( labels, scale=(1,) + scale, translate=(-200, 100, 100) ) labels_layer.n_edit_dimensions = 3 labels_layer.brush_size = 8 labels_layer.paint((1, 2, 5, 5), 1) np.testing.assert_array_equal( np.sum(labels_layer.data, axis=(1, 2, 3)), [0, 95, 0] ) def test_rendering_init(): shape = (6, 10, 15) np.random.seed(0) data = np.random.randint(20, size=shape) layer = Labels(data, rendering='iso_categorical') assert layer.rendering == LabelsRendering.ISO_CATEGORICAL.value def test_3d_video_and_3d_scale_translate_then_scale_translate_padded(): # See the GitHub issue for more details: # https://github.com/napari/napari/issues/2967 data = np.zeros((3, 5, 11, 11), dtype=int) labels = Labels(data, scale=(2, 1, 1), translate=(5, 5, 5)) np.testing.assert_array_equal(labels.scale, (1, 2, 1, 1)) np.testing.assert_array_equal(labels.translate, (0, 5, 5, 5)) @dataclass class MouseEvent: # mock mouse event class pos: List[int] position: List[int] dims_point: List[int] dims_displayed: List[int] view_direction: List[int] def test_get_value_ray_3d(): """Test using _get_value_ray to interrogate labels in 3D""" # make a mock mouse event mouse_event = MouseEvent( pos=[25, 25], position=[10, 5, 5], dims_point=[1, 0, 0, 0], dims_displayed=[1, 2, 3], view_direction=[1, 0, 0], ) data = np.zeros((5, 20, 20, 20), dtype=int) data[1, 0:10, 0:10, 0:10] = 1 labels = Labels(data, scale=(1, 2, 1, 1), translate=(5, 5, 5)) # set the dims to the slice with labels labels._slice_dims([1, 0, 0, 0], ndisplay=3) value = labels._get_value_ray( start_point=np.array([1, 0, 5, 5]), end_point=np.array([1, 20, 5, 5]), dims_displayed=mouse_event.dims_displayed, ) assert value == 1 # check with a ray that only goes through background value = labels._get_value_ray( start_point=np.array([1, 0, 15, 15]), end_point=np.array([1, 20, 15, 15]), dims_displayed=mouse_event.dims_displayed, ) assert value is None # set the dims to a slice without labels labels._slice_dims([0, 0, 0, 0], ndisplay=3) value = labels._get_value_ray( start_point=np.array([0, 0, 5, 5]), end_point=np.array([0, 20, 5, 5]), dims_displayed=mouse_event.dims_displayed, ) assert value is None def test_get_value_ray_3d_rolled(): """Test using _get_value_ray to interrogate labels in 3D with the dimensions rolled. """ # make a mock mouse event mouse_event = MouseEvent( pos=[25, 25], position=[10, 5, 5, 1], dims_point=[0, 0, 0, 1], dims_displayed=[0, 1, 2], view_direction=[1, 0, 0, 0], ) data = np.zeros((20, 20, 20, 5), dtype=int) data[0:10, 0:10, 0:10, 1] = 1 labels = Labels(data, scale=(1, 2, 1, 1), translate=(5, 5, 5, 0)) # set the dims to the slice with labels labels._slice_dims((0, 0, 0, 1), ndisplay=3, order=(3, 0, 1, 2)) labels.set_view_slice() value = labels._get_value_ray( start_point=np.array([0, 5, 5, 1]), end_point=np.array([20, 5, 5, 1]), dims_displayed=mouse_event.dims_displayed, ) assert value == 1 def test_get_value_ray_3d_transposed(): """Test using _get_value_ray to interrogate labels in 3D with the dimensions transposed. """ # make a mock mouse event mouse_event = MouseEvent( pos=[25, 25], position=[10, 5, 5, 1], dims_point=[0, 0, 0, 1], dims_displayed=[1, 3, 2], view_direction=[1, 0, 0, 0], ) data = np.zeros((5, 20, 20, 20), dtype=int) data[1, 0:10, 0:10, 0:10] = 1 labels = Labels(data, scale=(1, 2, 1, 1), translate=(0, 5, 5, 5)) # set the dims to the slice with labels labels._slice_dims((1, 0, 0, 0), ndisplay=3, order=(0, 1, 3, 2)) labels.set_view_slice() value = labels._get_value_ray( start_point=np.array([1, 0, 5, 5]), end_point=np.array([1, 20, 5, 5]), dims_displayed=mouse_event.dims_displayed, ) assert value == 1 def test_get_value_ray_2d(): """_get_value_ray currently only returns None in 2D (i.e., it shouldn't be used for 2D). """ # make a mock mouse event mouse_event = MouseEvent( pos=[25, 25], position=[5, 5], dims_point=[1, 10, 0, 0], dims_displayed=[2, 3], view_direction=[1, 0, 0], ) data = np.zeros((5, 20, 20, 20), dtype=int) data[1, 0:10, 0:10, 0:10] = 1 labels = Labels(data, scale=(1, 2, 1, 1), translate=(5, 5, 5)) # set the dims to the slice with labels, but 2D labels._slice_dims([1, 10, 0, 0], ndisplay=2) value = labels._get_value_ray( start_point=np.empty([]), end_point=np.empty([]), dims_displayed=mouse_event.dims_displayed, ) assert value is None def test_cursor_ray_3d(): # make a mock mouse event mouse_event_1 = MouseEvent( pos=[25, 25], position=[1, 10, 27, 10], dims_point=[1, 0, 0, 0], dims_displayed=[1, 2, 3], view_direction=[0, 1, 0, 0], ) data = np.zeros((5, 20, 20, 20), dtype=int) data[1, 0:10, 0:10, 0:10] = 1 labels = Labels(data, scale=(1, 1, 2, 1), translate=(5, 5, 5)) # set the slice to one with data and the view to 3D labels._slice_dims([1, 0, 0, 0], ndisplay=3) # axis 0 : [0, 20], bounding box extents along view axis, [1, 0, 0] # click is transformed: (value - translation) / scale # axis 1: click at 27 in world coords -> (27 - 5) / 2 = 11 # axis 2: click at 10 in world coords -> (10 - 5) / 1 = 5 start_point, end_point = labels.get_ray_intersections( mouse_event_1.position, mouse_event_1.view_direction, mouse_event_1.dims_displayed, ) np.testing.assert_allclose(start_point, [1, 0, 11, 5]) np.testing.assert_allclose(end_point, [1, 20, 11, 5]) # click in the background mouse_event_2 = MouseEvent( pos=[25, 25], position=[1, 10, 65, 10], dims_point=[1, 0, 0, 0], dims_displayed=[1, 2, 3], view_direction=[0, 1, 0, 0], ) start_point, end_point = labels.get_ray_intersections( mouse_event_2.position, mouse_event_2.view_direction, mouse_event_2.dims_displayed, ) assert start_point is None assert end_point is None # click in a slice with no labels mouse_event_3 = MouseEvent( pos=[25, 25], position=[0, 10, 27, 10], dims_point=[0, 0, 0, 0], dims_displayed=[1, 2, 3], view_direction=[0, 1, 0, 0], ) labels._slice_dims([0, 0, 0, 0], ndisplay=3) start_point, end_point = labels.get_ray_intersections( mouse_event_3.position, mouse_event_3.view_direction, mouse_event_3.dims_displayed, ) np.testing.assert_allclose(start_point, [0, 0, 11, 5]) np.testing.assert_allclose(end_point, [0, 20, 11, 5]) def test_cursor_ray_3d_rolled(): """Test that the cursor works when the displayed viewer axes have been rolled """ # make a mock mouse event mouse_event_1 = MouseEvent( pos=[25, 25], position=[10, 27, 10, 1], dims_point=[0, 0, 0, 1], dims_displayed=[0, 1, 2], view_direction=[1, 0, 0, 0], ) data = np.zeros((20, 20, 20, 5), dtype=int) data[0:10, 0:10, 0:10, 1] = 1 labels = Labels(data, scale=(1, 2, 1, 1), translate=(5, 5, 5, 0)) # set the slice to one with data and the view to 3D labels._slice_dims([0, 0, 0, 1], ndisplay=3) start_point, end_point = labels.get_ray_intersections( mouse_event_1.position, mouse_event_1.view_direction, mouse_event_1.dims_displayed, ) np.testing.assert_allclose(start_point, [0, 11, 5, 1]) np.testing.assert_allclose(end_point, [20, 11, 5, 1]) def test_cursor_ray_3d_transposed(): """Test that the cursor works when the displayed viewer axes have been transposed """ # make a mock mouse event mouse_event_1 = MouseEvent( pos=[25, 25], position=[10, 27, 10, 1], dims_point=[0, 0, 0, 1], dims_displayed=[0, 2, 1], view_direction=[1, 0, 0, 0], ) data = np.zeros((20, 20, 20, 5), dtype=int) data[0:10, 0:10, 0:10, 1] = 1 labels = Labels(data, scale=(1, 2, 1, 1), translate=(5, 5, 5, 0)) # set the slice to one with data and the view to 3D labels._slice_dims([0, 0, 0, 1], ndisplay=3) start_point, end_point = labels.get_ray_intersections( mouse_event_1.position, mouse_event_1.view_direction, mouse_event_1.dims_displayed, ) np.testing.assert_allclose(start_point, [0, 11, 5, 1]) np.testing.assert_allclose(end_point, [20, 11, 5, 1]) def test_labels_state_update(): """Test that a labels layer can be updated from the output of its _get_state() method """ data = np.random.randint(20, size=(10, 15)) layer = Labels(data) state = layer._get_state() for k, v in state.items(): setattr(layer, k, v) def test_is_default_color(): """Test labels layer default color for None and background Previously, setting color to just default values would change color mode to DIRECT and display a black layer. This test ensures `is_default_color` is correctly checking against layer defaults, and `color_mode` is only changed when appropriate. See - https://github.com/napari/napari/issues/2479 - https://github.com/napari/napari/issues/2953 """ data = np.random.randint(20, size=(10, 15)) layer = Labels(data) # layer gets instantiated with defaults current_color = layer.color assert layer._is_default_colors(current_color) # setting color to default colors doesn't update color mode layer.color = current_color assert layer.color_mode == 'auto' # new colors are not default new_color = {0: 'white', 1: 'red', 3: 'green'} assert not layer._is_default_colors(new_color) # setting the color with non-default colors updates color mode layer.color = new_color assert layer.color_mode == 'direct' def test_negative_label(): """Test negative label values are supported.""" data = np.random.randint(low=-1, high=20, size=(10, 10)) original_data = np.copy(data) layer = Labels(data) layer.selected_label = -1 layer.brush_size = 3 layer.paint((5, 5), -1) assert np.count_nonzero(layer.data == -1) > np.count_nonzero( original_data == -1 ) def test_negative_label_slicing(): """Test negative label color doesn't change during slicing.""" data = np.array([[[0, 1], [-1, -1]], [[100, 100], [-1, -2]]]) layer = Labels(data) assert tuple(layer.get_color(1)) != tuple(layer.get_color(-1)) layer._slice_dims(point=(1, 0, 0)) assert tuple(layer.get_color(-1)) != tuple(layer.get_color(100)) assert tuple(layer.get_color(-2)) != tuple(layer.get_color(100)) @pytest.mark.xfail( reason='This is a known bug with the current label color implementation' ) def test_negative_label_doesnt_flicker(): data = np.array( [ [[0, 5], [0, 5]], [[-1, 5], [-1, 5]], [[-1, 6], [-1, 6]], ] ) layer = Labels(data) layer._slice_dims(point=(1, 0, 0)) # this is expected to fail: -1 doesn't trigger an index error in # layer._all_vals, it instead just wraps to 5, the previous max label. assert tuple(layer.get_color(-1)) != tuple(layer.get_color(5)) minus_one_color_original = tuple(layer.get_color(-1)) layer.dims_point = (2, 0, 0) layer._set_view_slice() # this is also expected to fail: when we switch layers, we see the 6 # label, which causes an index error, which triggers a recalculation of # the label colors. Now -1 is seen so it is taken into account in the # indexing calculation, and changes color assert tuple(layer.get_color(-1)) == minus_one_color_original def test_get_status_with_custom_index(): """See https://github.com/napari/napari/issues/3811""" data = np.zeros((10, 10), dtype=np.uint8) data[2:5, 2:-2] = 1 data[5:-2, 2:-2] = 2 layer = Labels(data) df = pd.DataFrame( {'text1': [1, 3], 'text2': [7, -2], 'index': [1, 2]}, index=[1, 2] ) layer.properties = df assert ( layer.get_status((0, 0))['coordinates'] == ' [0 0]: 0; [No Properties]' ) assert ( layer.get_status((3, 3))['coordinates'] == ' [3 3]: 1; text1: 1, text2: 7' ) assert ( layer.get_status((6, 6))['coordinates'] == ' [6 6]: 2; text1: 3, text2: -2' ) def test_labels_features_event(): event_emitted = False def on_event(): nonlocal event_emitted event_emitted = True layer = Labels(np.zeros((4, 5), dtype=np.uint8)) layer.events.features.connect(on_event) layer.features = {'some_feature': []} assert event_emitted class TestLabels: @staticmethod def get_objects(): return [(Labels(np.zeros((10, 10), dtype=np.uint8)))] def test_events_defined(self, event_define_check, obj): event_define_check( obj, {"seed", "num_colors", "show_selected_label", "color"}, ) napari-0.5.0a1/napari/layers/labels/_tests/test_labels_key_bindings.py000066400000000000000000000025571437041365600261460ustar00rootroot00000000000000from tempfile import TemporaryDirectory import numpy as np import pytest import zarr from napari.layers import Labels from napari.layers.labels._labels_key_bindings import new_label @pytest.fixture def labels_data_4d(): labels = np.zeros((5, 7, 8, 9), dtype=int) labels[1, 2:4, 4:6, 4:6] = 1 labels[1, 3:5, 5:7, 6:8] = 2 labels[2, 3:5, 5:7, 6:8] = 3 return labels def test_max_label(labels_data_4d): labels = Labels(labels_data_4d) new_label(labels) assert labels.selected_label == 4 def test_max_label_tensorstore(labels_data_4d): ts = pytest.importorskip('tensorstore') with TemporaryDirectory(suffix='.zarr') as fout: labels_temp = zarr.open( fout, mode='w', shape=labels_data_4d.shape, dtype=np.uint32, chunks=(1, 1, 8, 9), ) labels_temp[:] = labels_data_4d labels_ts_spec = { 'driver': 'zarr', 'kvstore': {'driver': 'file', 'path': fout}, 'path': '', 'metadata': { 'dtype': labels_temp.dtype.str, 'order': labels_temp.order, 'shape': labels_data_4d.shape, }, } data = ts.open(labels_ts_spec, create=False, open=True).result() layer = Labels(data) new_label(layer) assert layer.selected_label == 4 napari-0.5.0a1/napari/layers/labels/_tests/test_labels_mouse_bindings.py000066400000000000000000000365241437041365600265070ustar00rootroot00000000000000import numpy as np from scipy import ndimage as ndi from napari.layers import Labels from napari.utils._proxies import ReadOnlyWrapper from napari.utils.interactions import ( mouse_move_callbacks, mouse_press_callbacks, mouse_release_callbacks, ) def test_paint(MouseEvent): """Test painting labels with circle brush.""" data = np.ones((20, 20), dtype=np.int32) layer = Labels(data) layer.brush_size = 10 assert layer.cursor_size == 10 layer.mode = 'paint' layer.selected_label = 3 # Simulate click event = ReadOnlyWrapper( MouseEvent( type='mouse_press', is_dragging=False, position=(0, 0), view_direction=None, dims_displayed=(0, 1), dims_point=(0, 0), ) ) mouse_press_callbacks(layer, event) # Simulate drag event = ReadOnlyWrapper( MouseEvent( type='mouse_move', is_dragging=True, position=(19, 19), view_direction=None, dims_displayed=(0, 1), dims_point=(0, 0), ) ) mouse_move_callbacks(layer, event) # Simulate release event = ReadOnlyWrapper( MouseEvent( type='mouse_release', is_dragging=False, position=(19, 19), view_direction=None, dims_displayed=(0, 1), dims_point=(0, 0), ) ) mouse_release_callbacks(layer, event) # Painting goes from (0, 0) to (19, 19) with a brush size of 10, changing # all pixels along that path, but none outside it. assert np.unique(layer.data[:8, :8]) == 3 assert np.unique(layer.data[-8:, -8:]) == 3 assert np.unique(layer.data[:5, -5:]) == 1 assert np.unique(layer.data[-5:, :5]) == 1 assert np.sum(layer.data == 3) == 244 def test_paint_scale(MouseEvent): """Test painting labels with circle brush when scaled.""" data = np.ones((20, 20), dtype=np.int32) layer = Labels(data, scale=(2, 2)) layer.brush_size = 10 layer.mode = 'paint' layer.selected_label = 3 # Simulate click event = ReadOnlyWrapper( MouseEvent( type='mouse_press', is_dragging=False, position=(0, 0), view_direction=None, dims_displayed=(0, 1), dims_point=(0, 0), ) ) mouse_press_callbacks(layer, event) # Simulate drag event = ReadOnlyWrapper( MouseEvent( type='mouse_move', is_dragging=True, position=(39, 39), view_direction=None, dims_displayed=(0, 1), dims_point=(0, 0), ) ) mouse_move_callbacks(layer, event) # Simulate release event = ReadOnlyWrapper( MouseEvent( type='mouse_release', is_dragging=False, position=(39, 39), view_direction=None, dims_displayed=(0, 1), dims_point=(0, 0), ) ) mouse_release_callbacks(layer, event) # Painting goes from (0, 0) to (19, 19) with a brush size of 10, changing # all pixels along that path, but none outside it. assert np.unique(layer.data[:8, :8]) == 3 assert np.unique(layer.data[-8:, -8:]) == 3 assert np.unique(layer.data[:5, -5:]) == 1 assert np.unique(layer.data[-5:, :5]) == 1 assert np.sum(layer.data == 3) == 244 def test_erase(MouseEvent): """Test erasing labels with different brush shapes.""" data = np.ones((20, 20), dtype=np.int32) layer = Labels(data) layer.brush_size = 10 layer.mode = 'erase' layer.selected_label = 3 # Simulate click event = ReadOnlyWrapper( MouseEvent( type='mouse_press', is_dragging=False, position=(0, 0), view_direction=None, dims_displayed=(0, 1), dims_point=(0, 0), ) ) mouse_press_callbacks(layer, event) # Simulate drag event = ReadOnlyWrapper( MouseEvent( type='mouse_move', is_dragging=True, position=(19, 19), view_direction=None, dims_displayed=(0, 1), dims_point=(0, 0), ) ) mouse_move_callbacks(layer, event) # Simulate release event = ReadOnlyWrapper( MouseEvent( type='mouse_release', is_dragging=False, position=(19, 19), view_direction=None, dims_displayed=(0, 1), dims_point=(0, 0), ) ) mouse_release_callbacks(layer, event) # Painting goes from (0, 0) to (19, 19) with a brush size of 10, changing # all pixels along that path, but non outside it. assert np.unique(layer.data[:8, :8]) == 0 assert np.unique(layer.data[-8:, -8:]) == 0 assert np.unique(layer.data[:5, -5:]) == 1 assert np.unique(layer.data[-5:, :5]) == 1 assert np.sum(layer.data == 1) == 156 def test_pick(MouseEvent): """Test picking label.""" data = np.ones((20, 20), dtype=np.int32) data[:5, :5] = 2 data[-5:, -5:] = 3 layer = Labels(data) assert layer.selected_label == 1 layer.mode = 'pick' # Simulate click event = ReadOnlyWrapper( MouseEvent( type='mouse_press', is_dragging=False, position=(0, 0), view_direction=None, dims_displayed=(0, 1), dims_point=(0, 0), ) ) mouse_press_callbacks(layer, event) assert layer.selected_label == 2 # Simulate click event = ReadOnlyWrapper( MouseEvent( type='mouse_press', is_dragging=False, position=(19, 19), view_direction=None, dims_displayed=(0, 1), dims_point=(0, 0), ) ) mouse_press_callbacks(layer, event) assert layer.selected_label == 3 def test_fill(MouseEvent): """Test filling label.""" data = np.ones((20, 20), dtype=np.int32) data[:5, :5] = 2 data[-5:, -5:] = 3 layer = Labels(data) assert np.unique(layer.data[:5, :5]) == 2 assert np.unique(layer.data[-5:, -5:]) == 3 assert np.unique(layer.data[:5, -5:]) == 1 assert np.unique(layer.data[-5:, :5]) == 1 layer.mode = 'fill' layer.selected_label = 4 # Simulate click event = ReadOnlyWrapper( MouseEvent( type='mouse_press', is_dragging=False, position=(0, 0), view_direction=None, dims_displayed=(0, 1), dims_point=(0, 0), ) ) mouse_press_callbacks(layer, event) assert np.unique(layer.data[:5, :5]) == 4 assert np.unique(layer.data[-5:, -5:]) == 3 assert np.unique(layer.data[:5, -5:]) == 1 assert np.unique(layer.data[-5:, :5]) == 1 layer.selected_label = 5 # Simulate click event = ReadOnlyWrapper( MouseEvent( type='mouse_press', is_dragging=False, position=(19, 19), view_direction=None, dims_displayed=(0, 1), dims_point=(0, 0), ) ) mouse_press_callbacks(layer, event) assert np.unique(layer.data[:5, :5]) == 4 assert np.unique(layer.data[-5:, -5:]) == 5 assert np.unique(layer.data[:5, -5:]) == 1 assert np.unique(layer.data[-5:, :5]) == 1 def test_fill_nD_plane(MouseEvent): """Test filling label nD plane.""" data = np.ones((20, 20, 20), dtype=np.int32) data[:5, :5, :5] = 2 data[0, 8:10, 8:10] = 2 data[-5:, -5:, -5:] = 3 layer = Labels(data) assert np.unique(layer.data[:5, :5, :5]) == 2 assert np.unique(layer.data[-5:, -5:, -5:]) == 3 assert np.unique(layer.data[:5, -5:, -5:]) == 1 assert np.unique(layer.data[-5:, :5, -5:]) == 1 assert np.unique(layer.data[0, 8:10, 8:10]) == 2 layer.mode = 'fill' layer.selected_label = 4 # Simulate click event = ReadOnlyWrapper( MouseEvent( type='mouse_press', is_dragging=False, position=(0, 0, 0), view_direction=(1, 0, 0), dims_displayed=(0, 1), dims_point=(0, 0), ) ) mouse_press_callbacks(layer, event) assert np.unique(layer.data[0, :5, :5]) == 4 assert np.unique(layer.data[1:5, :5, :5]) == 2 assert np.unique(layer.data[-5:, -5:, -5:]) == 3 assert np.unique(layer.data[:5, -5:, -5:]) == 1 assert np.unique(layer.data[-5:, :5, -5:]) == 1 assert np.unique(layer.data[0, 8:10, 8:10]) == 2 layer.selected_label = 5 # Simulate click event = ReadOnlyWrapper( MouseEvent( type='mouse_press', is_dragging=False, position=(0, 19, 19), view_direction=(1, 0, 0), dims_displayed=(0, 1), dims_point=(0, 0, 0), ) ) mouse_press_callbacks(layer, event) assert np.unique(layer.data[0, :5, :5]) == 4 assert np.unique(layer.data[1:5, :5, :5]) == 2 assert np.unique(layer.data[-5:, -5:, -5:]) == 3 assert np.unique(layer.data[1:5, -5:, -5:]) == 1 assert np.unique(layer.data[-5:, :5, -5:]) == 1 assert np.unique(layer.data[0, -5:, -5:]) == 5 assert np.unique(layer.data[0, :5, -5:]) == 5 assert np.unique(layer.data[0, 8:10, 8:10]) == 2 def test_fill_nD_all(MouseEvent): """Test filling label nD.""" data = np.ones((20, 20, 20), dtype=np.int32) data[:5, :5, :5] = 2 data[0, 8:10, 8:10] = 2 data[-5:, -5:, -5:] = 3 layer = Labels(data) assert np.unique(layer.data[:5, :5, :5]) == 2 assert np.unique(layer.data[-5:, -5:, -5:]) == 3 assert np.unique(layer.data[:5, -5:, -5:]) == 1 assert np.unique(layer.data[-5:, :5, -5:]) == 1 assert np.unique(layer.data[0, 8:10, 8:10]) == 2 layer.n_edit_dimensions = 3 layer.mode = 'fill' layer.selected_label = 4 # Simulate click event = ReadOnlyWrapper( MouseEvent( type='mouse_press', is_dragging=False, position=(0, 0, 0), view_direction=(1, 0, 0), dims_displayed=(0, 1), dims_point=(0, 0), ) ) mouse_press_callbacks(layer, event) assert np.unique(layer.data[:5, :5, :5]) == 4 assert np.unique(layer.data[-5:, -5:, -5:]) == 3 assert np.unique(layer.data[:5, -5:, -5:]) == 1 assert np.unique(layer.data[-5:, :5, -5:]) == 1 assert np.unique(layer.data[0, 8:10, 8:10]) == 2 layer.selected_label = 5 # Simulate click event = ReadOnlyWrapper( MouseEvent( type='mouse_press', is_dragging=False, position=(0, 19, 19), view_direction=(1, 0, 0), dims_displayed=(0, 1), dims_point=(0, 0, 0), ) ) mouse_press_callbacks(layer, event) assert np.unique(layer.data[:5, :5, :5]) == 4 assert np.unique(layer.data[-5:, -5:, -5:]) == 3 assert np.unique(layer.data[:5, -5:, -5:]) == 5 assert np.unique(layer.data[-5:, :5, -5:]) == 5 assert np.unique(layer.data[0, 8:10, 8:10]) == 2 def test_paint_3d(MouseEvent): """Test filling label nD.""" data = np.zeros((21, 21, 21), dtype=np.int32) data[10, 10, 10] = 1 layer = Labels(data) layer._slice_dims(point=(0, 0, 0), ndisplay=3) layer.n_edit_dimensions = 3 layer.mode = 'paint' layer.selected_label = 4 layer.brush_size = 3 # Simulate click event = ReadOnlyWrapper( MouseEvent( type='mouse_press', is_dragging=False, position=(0.1, 0, 0), view_direction=np.full(3, np.sqrt(3)), dims_displayed=(0, 1, 2), dims_point=(0, 0, 0), ) ) mouse_press_callbacks(layer, event) np.testing.assert_array_equal(np.unique(layer.data), [0, 4]) num_filled = np.bincount(layer.data.ravel())[4] assert num_filled > 1 layer.mode = 'erase' # Simulate click event = ReadOnlyWrapper( MouseEvent( type='mouse_press', is_dragging=False, position=(0, 10, 10), view_direction=(1, 0, 0), dims_displayed=(0, 1, 2), dims_point=(0, 0, 0), ) ) mouse_press_callbacks(layer, event) new_num_filled = np.bincount(layer.data.ravel())[4] assert new_num_filled < num_filled def test_erase_3d_undo(MouseEvent): """Test erasing labels in 3D then undoing the erase. Specifically, this test checks that undo is correctly filled even when a click and drag starts outside of the data volume. """ data = np.zeros((20, 20, 20), dtype=np.int32) data[10, :, :] = 1 layer = Labels(data) layer.brush_size = 5 layer.mode = 'erase' layer._slice_dims(point=(0, 0, 0), ndisplay=3) layer.n_edit_dimensions = 3 # Simulate click event = ReadOnlyWrapper( MouseEvent( type='mouse_press', is_dragging=False, position=(-1, -1, -1), view_direction=(1, 0, 0), dims_displayed=(0, 1, 2), dims_point=(0, 0, 0), ) ) mouse_press_callbacks(layer, event) # Simulate drag. Note: we need to include top left and bottom right in the # drag or there are no coordinates to interpolate event = ReadOnlyWrapper( MouseEvent( type='mouse_move', is_dragging=True, position=(-1, 0.1, 0.1), view_direction=(1, 0, 0), dims_displayed=(0, 1, 2), dims_point=(0, 0, 0), ) ) mouse_move_callbacks(layer, event) event = ReadOnlyWrapper( MouseEvent( type='mouse_move', is_dragging=True, position=(-1, 18.9, 18.9), view_direction=(1, 0, 0), dims_displayed=(0, 1, 2), dims_point=(0, 0, 0), ) ) mouse_move_callbacks(layer, event) # Simulate release event = ReadOnlyWrapper( MouseEvent( type='mouse_release', is_dragging=False, position=(-1, 21, 21), view_direction=(1, 0, 0), dims_displayed=(0, 1, 2), dims_point=(0, 0, 0), ) ) mouse_release_callbacks(layer, event) # Erasing goes from (-1, -1, -1) to (-1, 21, 21), should split the labels # into two sections. Undoing should work and reunite the labels to one # square assert ndi.label(layer.data)[1] == 2 layer.undo() assert ndi.label(layer.data)[1] == 1 def test_erase_3d_undo_empty(MouseEvent): """Nothing should be added to undo queue when clicks fall outside data.""" data = np.zeros((20, 20, 20), dtype=np.int32) data[10, :, :] = 1 layer = Labels(data) layer.brush_size = 5 layer.mode = 'erase' layer._slice_dims(point=(0, 0, 0), ndisplay=3) layer.n_edit_dimensions = 3 # Simulate click, outside data event = ReadOnlyWrapper( MouseEvent( type='mouse_press', is_dragging=False, position=(-1, -1, -1), view_direction=(1, 0, 0), dims_displayed=(0, 1, 2), dims_point=(0, 0, 0), ) ) mouse_press_callbacks(layer, event) # Simulate release event = ReadOnlyWrapper( MouseEvent( type='mouse_release', is_dragging=False, position=(-1, -1, -1), view_direction=(1, 0, 0), dims_displayed=(0, 1, 2), dims_point=(0, 0, 0), ) ) mouse_release_callbacks(layer, event) # Undo queue should be empty assert len(layer._undo_history) == 0 napari-0.5.0a1/napari/layers/labels/_tests/test_labels_pyramid.py000066400000000000000000000032021437041365600251320ustar00rootroot00000000000000import numpy as np from napari.layers import Labels def test_random_multiscale(): """Test instantiating Labels layer with random 2D multiscale data.""" shapes = [(40, 20), (20, 10), (10, 5)] np.random.seed(0) data = [np.random.randint(20, size=s) for s in shapes] layer = Labels(data, multiscale=True) assert layer.data == data assert layer.multiscale is True assert layer.editable is False assert layer.ndim == len(shapes[0]) np.testing.assert_array_equal(layer.extent.data[1], shapes[0]) assert layer.rgb is False assert layer._data_view.ndim == 2 def test_infer_multiscale(): """Test instantiating Labels layer with random 2D multiscale data.""" shapes = [(40, 20), (20, 10), (10, 5)] np.random.seed(0) data = [np.random.randint(20, size=s) for s in shapes] layer = Labels(data) assert layer.data == data assert layer.multiscale is True assert layer.editable is False assert layer.ndim == len(shapes[0]) np.testing.assert_array_equal(layer.extent.data[1], shapes[0]) assert layer.rgb is False assert layer._data_view.ndim == 2 def test_3D_multiscale(): """Test instantiating Labels layer with 3D data.""" shapes = [(8, 40, 20), (4, 20, 10), (2, 10, 5)] np.random.seed(0) data = [np.random.randint(20, size=s) for s in shapes] layer = Labels(data, multiscale=True) assert layer.data == data assert layer.multiscale is True assert layer.editable is False assert layer.ndim == len(shapes[0]) np.testing.assert_array_equal(layer.extent.data[1], shapes[0]) assert layer.rgb is False assert layer._data_view.ndim == 2 napari-0.5.0a1/napari/layers/labels/_tests/test_labels_utils.py000066400000000000000000000105201437041365600246260ustar00rootroot00000000000000import numpy as np from napari.layers.labels import Labels from napari.layers.labels._labels_utils import ( first_nonzero_coordinate, get_dtype, interpolate_coordinates, mouse_event_to_labels_coordinate, ) from napari.utils._proxies import ReadOnlyWrapper def test_interpolate_coordinates(): # test when number of interpolated points > 1 old_coord = np.array([0, 1]) new_coord = np.array([0, 10]) coords = interpolate_coordinates(old_coord, new_coord, brush_size=3) expected_coords = np.array( [ [0, 1.75], [0, 2.5], [0, 3.25], [0, 4], [0, 4.75], [0, 5.5], [0, 6.25], [0, 7], [0, 7.75], [0, 8.5], [0, 9.25], [0, 10], ] ) assert np.all(coords == expected_coords) def test_interpolate_with_none(): """Test that interpolating with one None coordinate returns original.""" coord = np.array([5, 5]) expected = coord[np.newaxis, :] actual = interpolate_coordinates(coord, None, brush_size=1) np.testing.assert_array_equal(actual, expected) actual2 = interpolate_coordinates(None, coord, brush_size=5) np.testing.assert_array_equal(actual2, expected) def test_get_dtype(): np.random.seed(0) data = np.random.randint(20, size=(50, 50)) layer = Labels(data) assert get_dtype(layer) == data.dtype data2 = data[::2, ::2] layer_data = [data, data2] multiscale_layer = Labels(layer_data) assert get_dtype(multiscale_layer) == layer_data[0].dtype data = data.astype(int) int_layer = Labels(data) assert get_dtype(int_layer) == int def test_first_nonzero_coordinate(): data = np.zeros((11, 11, 11)) data[4:7, 4:7, 4:7] = 1 np.testing.assert_array_equal( first_nonzero_coordinate(data, np.zeros(3), np.full(3, 10)), [4, 4, 4], ) np.testing.assert_array_equal( first_nonzero_coordinate(data, np.full(3, 10), np.zeros(3)), [6, 6, 6], ) assert ( first_nonzero_coordinate(data, np.zeros(3), np.array([0, 1, 1])) is None ) np.testing.assert_array_equal( first_nonzero_coordinate( data, np.array([0, 6, 6]), np.array([10, 5, 5]) ), [4, 6, 6], ) def test_mouse_event_to_labels_coordinate_2d(MouseEvent): data = np.zeros((11, 11), dtype=int) data[4:7, 4:7] = 1 layer = Labels(data, scale=(2, 2)) event = ReadOnlyWrapper( MouseEvent( type='mouse_press', is_dragging=False, position=(10, 10), view_direction=None, dims_displayed=(1, 2), dims_point=(0, 0), ) ) coord = mouse_event_to_labels_coordinate(layer, event) np.testing.assert_array_equal(coord, [5, 5]) def test_mouse_event_to_labels_coordinate_3d(MouseEvent): data = np.zeros((11, 11, 11), dtype=int) data[4:7, 4:7, 4:7] = 1 layer = Labels(data, scale=(2, 2, 2)) layer._slice_dims(point=(0, 0, 0), ndisplay=3) # click straight down from the top # (note the scale on the layer!) event = ReadOnlyWrapper( MouseEvent( type='mouse_press', is_dragging=False, position=(0, 10, 10), view_direction=(1, 0, 0), dims_displayed=(0, 1, 2), dims_point=(10, 10, 10), ) ) coord = mouse_event_to_labels_coordinate(layer, event) np.testing.assert_array_equal(coord, [4, 5, 5]) # click diagonally from the top left corner event = ReadOnlyWrapper( MouseEvent( type='mouse_press', is_dragging=False, position=(0.1, 0, 0), view_direction=np.full(3, 1 / np.sqrt(3)), dims_displayed=(0, 1, 2), dims_point=(10, 10, 10), ) ) coord = mouse_event_to_labels_coordinate(layer, event) np.testing.assert_array_equal(coord, [4, 4, 4]) # drag starts inside volume but ends up outside volume event = ReadOnlyWrapper( MouseEvent( type='mouse_press', is_dragging=True, position=(-100, -100, -100), view_direction=(1, 0, 0), dims_displayed=(0, 1, 2), dims_point=(10, 10, 10), ) ) coord = mouse_event_to_labels_coordinate(layer, event) assert coord is None napari-0.5.0a1/napari/layers/labels/labels.py000066400000000000000000001522111437041365600210520ustar00rootroot00000000000000import warnings from collections import deque from contextlib import contextmanager from typing import Dict, List, Optional, Tuple, Union import numpy as np import pandas as pd from scipy import ndimage as ndi from napari.layers.base import Layer, no_op from napari.layers.base._base_mouse_bindings import ( highlight_box_handles, transform_with_box, ) from napari.layers.image._image_utils import guess_multiscale from napari.layers.image.image import _ImageBase from napari.layers.labels._labels_constants import ( LabelColorMode, LabelsRendering, Mode, ) from napari.layers.labels._labels_mouse_bindings import draw, pick from napari.layers.labels._labels_utils import ( indices_in_shape, interpolate_coordinates, sphere_indices, ) from napari.layers.utils.color_transformations import transform_color from napari.layers.utils.layer_utils import _FeatureTable from napari.utils import config from napari.utils._dtype import normalize_dtype from napari.utils.colormaps import ( color_dict_to_colormap, label_colormap, low_discrepancy_image, ) from napari.utils.events import Event from napari.utils.events.custom_types import Array from napari.utils.geometry import clamp_point_to_bounding_box from napari.utils.misc import _is_array_type from napari.utils.naming import magic_name from napari.utils.status_messages import generate_layer_coords_status from napari.utils.translations import trans class Labels(_ImageBase): """Labels (or segmentation) layer. An image-like layer where every pixel contains an integer ID corresponding to the region it belongs to. Parameters ---------- data : array or list of array Labels data as an array or multiscale. Must be integer type or bools. Please note multiscale rendering is only supported in 2D. In 3D, only the lowest resolution scale is displayed. num_colors : int Number of unique colors to use in colormap. features : dict[str, array-like] or DataFrame Features table where each row corresponds to a label and each column is a feature. The first row corresponds to the background label. properties : dict {str: array (N,)} or DataFrame Properties for each label. Each property should be an array of length N, where N is the number of labels, and the first property corresponds to background. color : dict of int to str or array Custom label to color mapping. Values must be valid color names or RGBA arrays. seed : float Seed for colormap random generator. name : str Name of the layer. metadata : dict Layer metadata. scale : tuple of float Scale factors for the layer. translate : tuple of float Translation values for the layer. rotate : float, 3-tuple of float, or n-D array. If a float convert into a 2D rotation matrix using that value as an angle. If 3-tuple convert into a 3D rotation matrix, using a yaw, pitch, roll convention. Otherwise assume an nD rotation. Angles are assumed to be in degrees. They can be converted from radians with np.degrees if needed. shear : 1-D array or n-D array Either a vector of upper triangular values, or an nD shear matrix with ones along the main diagonal. affine : n-D array or napari.utils.transforms.Affine (N+1, N+1) affine transformation matrix in homogeneous coordinates. The first (N, N) entries correspond to a linear transform and the final column is a length N translation vector and a 1 or a napari `Affine` transform object. Applied as an extra transform on top of the provided scale, rotate, and shear values. opacity : float Opacity of the layer visual, between 0.0 and 1.0. blending : str One of a list of preset blending modes that determines how RGB and alpha values of the layer visual get mixed. Allowed values are {'opaque', 'translucent', and 'additive'}. rendering : str 3D Rendering mode used by vispy. Must be one {'translucent', 'iso_categorical'}. 'translucent' renders without lighting. 'iso_categorical' uses isosurface rendering to calculate lighting effects on labeled surfaces. The default value is 'iso_categorical'. depiction : str 3D Depiction mode. Must be one of {'volume', 'plane'}. The default value is 'volume'. visible : bool Whether the layer visual is currently being displayed. multiscale : bool Whether the data is a multiscale image or not. Multiscale data is represented by a list of array like image data. If not specified by the user and if the data is a list of arrays that decrease in shape then it will be taken to be multiscale. The first image in the list should be the largest. Please note multiscale rendering is only supported in 2D. In 3D, only the lowest resolution scale is displayed. cache : bool Whether slices of out-of-core datasets should be cached upon retrieval. Currently, this only applies to dask arrays. plane : dict or SlicingPlane Properties defining plane rendering in 3D. Properties are defined in data coordinates. Valid dictionary keys are {'position', 'normal', 'thickness', and 'enabled'}. experimental_clipping_planes : list of dicts, list of ClippingPlane, or ClippingPlaneList Each dict defines a clipping plane in 3D in data coordinates. Valid dictionary keys are {'position', 'normal', and 'enabled'}. Values on the negative side of the normal are discarded if the plane is enabled. Attributes ---------- data : array or list of array Integer label data as an array or multiscale. Can be N dimensional. Every pixel contains an integer ID corresponding to the region it belongs to. The label 0 is rendered as transparent. Please note multiscale rendering is only supported in 2D. In 3D, only the lowest resolution scale is displayed. multiscale : bool Whether the data is a multiscale image or not. Multiscale data is represented by a list of array like image data. The first image in the list should be the largest. Please note multiscale rendering is only supported in 2D. In 3D, only the lowest resolution scale is displayed. metadata : dict Labels metadata. num_colors : int Number of unique colors to use in colormap. features : Dataframe-like Features table where each row corresponds to a label and each column is a feature. The first row corresponds to the background label. properties : dict {str: array (N,)}, DataFrame Properties for each label. Each property should be an array of length N, where N is the number of labels, and the first property corresponds to background. color : dict of int to str or array Custom label to color mapping. Values must be valid color names or RGBA arrays. While there is no limit to the number of custom labels, the the layer will render incorrectly if they map to more than 1024 distinct colors. seed : float Seed for colormap random generator. opacity : float Opacity of the labels, must be between 0 and 1. contiguous : bool If `True`, the fill bucket changes only connected pixels of same label. n_edit_dimensions : int The number of dimensions across which labels will be edited. contour : int If greater than 0, displays contours of labels instead of shaded regions with a thickness equal to its value. brush_size : float Size of the paint brush in data coordinates. selected_label : int Index of selected label. Can be greater than the current maximum label. mode : str Interactive mode. The normal, default mode is PAN_ZOOM, which allows for normal interactivity with the canvas. In PICK mode the cursor functions like a color picker, setting the clicked on label to be the current label. If the background is picked it will select the background label `0`. In PAINT mode the cursor functions like a paint brush changing any pixels it brushes over to the current label. If the background label `0` is selected than any pixels will be changed to background and this tool functions like an eraser. The size and shape of the cursor can be adjusted in the properties widget. In FILL mode the cursor functions like a fill bucket replacing pixels of the label clicked on with the current label. It can either replace all pixels of that label or just those that are contiguous with the clicked on pixel. If the background label `0` is selected than any pixels will be changed to background and this tool functions like an eraser. In ERASE mode the cursor functions similarly to PAINT mode, but to paint with background label, which effectively removes the label. plane : SlicingPlane Properties defining plane rendering in 3D. experimental_clipping_planes : ClippingPlaneList Clipping planes defined in data coordinates, used to clip the volume. Notes ----- _selected_color : 4-tuple or None RGBA tuple of the color of the selected label, or None if the background label `0` is selected. """ _modeclass = Mode _drag_modes = { Mode.PAN_ZOOM: no_op, Mode.TRANSFORM: transform_with_box, Mode.PICK: pick, Mode.PAINT: draw, Mode.FILL: draw, Mode.ERASE: draw, } _move_modes = { Mode.PAN_ZOOM: no_op, Mode.TRANSFORM: highlight_box_handles, Mode.PICK: no_op, Mode.PAINT: no_op, Mode.FILL: no_op, Mode.ERASE: no_op, } _cursor_modes = { Mode.PAN_ZOOM: 'standard', Mode.TRANSFORM: 'standard', Mode.PICK: 'cross', Mode.PAINT: 'circle', Mode.FILL: 'cross', Mode.ERASE: 'circle', } _history_limit = 100 def __init__( self, data, *, num_colors=50, features=None, properties=None, color=None, seed=0.5, name=None, metadata=None, scale=None, translate=None, rotate=None, shear=None, affine=None, opacity=0.7, blending='translucent', rendering='iso_categorical', depiction='volume', visible=True, multiscale=None, cache=True, plane=None, experimental_clipping_planes=None, ) -> None: if name is None and data is not None: name = magic_name(data) self._seed = seed self._background_label = 0 self._num_colors = num_colors self._random_colormap = label_colormap(self.num_colors) self._all_vals = np.array([], dtype=float) self._color_mode = LabelColorMode.AUTO self._show_selected_label = False self._contour = 0 data = self._ensure_int_labels(data) self._color_lookup_func = None super().__init__( data, rgb=False, colormap=self._random_colormap, contrast_limits=[0.0, 1.0], interpolation2d='nearest', interpolation3d='nearest', rendering=rendering, depiction=depiction, iso_threshold=0, name=name, metadata=metadata, scale=scale, translate=translate, rotate=rotate, shear=shear, affine=affine, opacity=opacity, blending=blending, visible=visible, multiscale=multiscale, cache=cache, plane=plane, experimental_clipping_planes=experimental_clipping_planes, ) self.events.add( preserve_labels=Event, properties=Event, n_edit_dimensions=Event, contiguous=Event, brush_size=Event, selected_label=Event, color_mode=Event, brush_shape=Event, contour=Event, features=Event, paint=Event, ) self._feature_table = _FeatureTable.from_layer( features=features, properties=properties ) self._label_index = self._make_label_index() self._n_edit_dimensions = 2 self._contiguous = True self._brush_size = 10 self._selected_label = 1 self._selected_color = self.get_color(self._selected_label) self.color = color self._status = self.mode self._preserve_labels = False self._reset_history() # Trigger generation of view slice and thumbnail self.refresh() self._reset_editable() @property def rendering(self): """Return current rendering mode. Selects a preset rendering mode in vispy that determines how lablels are displayed. Options include: * ``translucent``: voxel colors are blended along the view ray until the result is opaque. * ``iso_categorical``: isosurface for categorical data. Cast a ray until a non-background value is encountered. At that location, lighning calculations are performed to give the visual appearance of a surface. Returns ------- str The current rendering mode """ return str(self._rendering) @rendering.setter def rendering(self, rendering): self._rendering = LabelsRendering(rendering) self.events.rendering() @property def contiguous(self): """bool: fill bucket changes only connected pixels of same label.""" return self._contiguous @contiguous.setter def contiguous(self, contiguous): self._contiguous = contiguous self.events.contiguous() @property def n_edit_dimensions(self): return self._n_edit_dimensions @n_edit_dimensions.setter def n_edit_dimensions(self, n_edit_dimensions): self._n_edit_dimensions = n_edit_dimensions self.events.n_edit_dimensions() @property def contour(self): """int: displays contours of labels instead of shaded regions.""" return self._contour @contour.setter def contour(self, contour): self._contour = contour self.events.contour() self.refresh() @property def brush_size(self): """float: Size of the paint in world coordinates.""" return self._brush_size @brush_size.setter def brush_size(self, brush_size): self._brush_size = int(brush_size) self.cursor_size = self._calculate_cursor_size() self.events.brush_size() def _calculate_cursor_size(self): # Convert from brush size in data coordinates to # cursor size in world coordinates scale = self._data_to_world.scale min_scale = np.min( [abs(scale[d]) for d in self._slice_input.displayed] ) return abs(self.brush_size * min_scale) @property def seed(self): """float: Seed for colormap random generator.""" return self._seed @seed.setter def seed(self, seed): self._seed = seed # invalidate _all_vals to trigger re-generation # in _raw_to_displayed self._all_vals = np.array([]) self._selected_color = self.get_color(self.selected_label) self.refresh() self.events.selected_label() @_ImageBase.colormap.setter def colormap(self, colormap): super()._set_colormap(colormap) self._selected_color = self.get_color(self.selected_label) @property def num_colors(self): """int: Number of unique colors to use in colormap.""" return self._num_colors @num_colors.setter def num_colors(self, num_colors): self._num_colors = num_colors self.colormap = label_colormap(num_colors) self.refresh() self._selected_color = self.get_color(self.selected_label) self.events.selected_label() @property def data(self): """array: Image data.""" return self._data @data.setter def data(self, data): data = self._ensure_int_labels(data) self._data = data self._update_dims() self.events.data(value=self.data) self._reset_editable() @property def features(self): """Dataframe-like features table. It is an implementation detail that this is a `pandas.DataFrame`. In the future, we will target the currently-in-development Data API dataframe protocol [1]. This will enable us to use alternate libraries such as xarray or cuDF for additional features without breaking existing usage of this. If you need to specifically rely on the pandas API, please coerce this to a `pandas.DataFrame` using `features_to_pandas_dataframe`. References ---------- .. [1]: https://data-apis.org/dataframe-protocol/latest/API.html """ return self._feature_table.values @features.setter def features( self, features: Union[Dict[str, np.ndarray], pd.DataFrame], ) -> None: self._feature_table.set_values(features) self._label_index = self._make_label_index() self.events.properties() self.events.features() @property def properties(self) -> Dict[str, np.ndarray]: """dict {str: array (N,)}, DataFrame: Properties for each label.""" return self._feature_table.properties() @properties.setter def properties(self, properties: Dict[str, Array]): self.features = properties def _make_label_index(self) -> Dict[int, int]: features = self._feature_table.values label_index = {} if 'index' in features: label_index = {i: k for k, i in enumerate(features['index'])} elif features.shape[1] > 0: label_index = {i: i for i in range(features.shape[0])} return label_index @property def color(self): """dict: custom color dict for label coloring""" return self._color @color.setter def color(self, color): if not color: color = {} if self._background_label not in color: color[self._background_label] = 'transparent' if None not in color: color[None] = 'black' colors = { label: transform_color(color_str)[0] for label, color_str in color.items() } self._color = colors # `colors` may contain just the default None and background label # colors, in which case we need to be in AUTO color mode. Otherwise, # `colors` contains colors for all labels, and we should be in DIRECT # mode. # For more information # - https://github.com/napari/napari/issues/2479 # - https://github.com/napari/napari/issues/2953 if self._is_default_colors(colors): color_mode = LabelColorMode.AUTO else: color_mode = LabelColorMode.DIRECT self.color_mode = color_mode def _is_default_colors(self, color): """Returns True if color contains only default colors, otherwise False. Default colors are black for `None` and transparent for `self._background_label`. Parameters ---------- color : Dict Dictionary of label value to color array Returns ------- bool True if color contains only default colors, otherwise False. """ if len(color) != 2: return False if not hasattr(self, '_color'): return False default_keys = [None, self._background_label] if set(default_keys) != set(color.keys()): return False for key in default_keys: if not np.allclose(self._color[key], color[key]): return False return True def _ensure_int_labels(self, data): """Ensure data is integer by converting from bool if required, raising an error otherwise.""" looks_multiscale, data = guess_multiscale(data) if not looks_multiscale: data = [data] int_data = [] for data_level in data: # normalize_dtype turns e.g. tensorstore or torch dtypes into # numpy dtypes if np.issubdtype(normalize_dtype(data_level.dtype), np.floating): raise TypeError( trans._( "Only integer types are supported for Labels layers, but data contains {data_level_type}.", data_level_type=data_level.dtype, ) ) if data_level.dtype == bool: int_data.append(data_level.astype(np.int8)) else: int_data.append(data_level) data = int_data if not looks_multiscale: data = data[0] return data def _get_state(self): """Get dictionary of layer state. Returns ------- state : dict Dictionary of layer state. """ state = self._get_base_state() state.update( { 'multiscale': self.multiscale, 'num_colors': self.num_colors, 'properties': self.properties, 'rendering': self.rendering, 'depiction': self.depiction, 'plane': self.plane.dict(), 'experimental_clipping_planes': [ plane.dict() for plane in self.experimental_clipping_planes ], 'seed': self.seed, 'data': self.data, 'color': self.color, 'features': self.features, } ) return state @property def selected_label(self): """int: Index of selected label.""" return self._selected_label @selected_label.setter def selected_label(self, selected_label): if selected_label == self.selected_label: return self._selected_label = selected_label self._selected_color = self.get_color(selected_label) self.events.selected_label() # note: self.color_mode returns a string and this comparison fails, # so use self._color_mode if self.show_selected_label: self.refresh() @property def color_mode(self): """Color mode to change how color is represented. AUTO (default) allows color to be set via a hash function with a seed. DIRECT allows color of each label to be set directly by a color dict. """ return str(self._color_mode) @color_mode.setter def color_mode(self, color_mode: Union[str, LabelColorMode]): color_mode = LabelColorMode(color_mode) if color_mode == LabelColorMode.DIRECT: custom_colormap, label_color_index = color_dict_to_colormap( self.color ) super()._set_colormap(custom_colormap) self._label_color_index = label_color_index elif color_mode == LabelColorMode.AUTO: self._label_color_index = {} super()._set_colormap(self._random_colormap) else: raise ValueError(trans._("Unsupported Color Mode")) self._color_mode = color_mode self._selected_color = self.get_color(self.selected_label) self.events.color_mode() self.events.colormap() self.events.selected_label() self.refresh() @property def show_selected_label(self): """Whether to filter displayed labels to only the selected label or not""" return self._show_selected_label @show_selected_label.setter def show_selected_label(self, filter): self._show_selected_label = filter self.refresh() @Layer.mode.getter def mode(self): """MODE: Interactive mode. The normal, default mode is PAN_ZOOM, which allows for normal interactivity with the canvas. In PICK mode the cursor functions like a color picker, setting the clicked on label to be the current label. If the background is picked it will select the background label `0`. In PAINT mode the cursor functions like a paint brush changing any pixels it brushes over to the current label. If the background label `0` is selected than any pixels will be changed to background and this tool functions like an eraser. The size and shape of the cursor can be adjusted in the properties widget. In FILL mode the cursor functions like a fill bucket replacing pixels of the label clicked on with the current label. It can either replace all pixels of that label or just those that are contiguous with the clicked on pixel. If the background label `0` is selected than any pixels will be changed to background and this tool functions like an eraser. In ERASE mode the cursor functions similarly to PAINT mode, but to paint with background label, which effectively removes the label. """ return str(self._mode) def _mode_setter_helper(self, mode): mode = super()._mode_setter_helper(mode) if mode == self._mode: return mode if mode in {Mode.PAINT, Mode.ERASE}: self.cursor_size = self._calculate_cursor_size() return mode @property def preserve_labels(self): """Defines if painting should preserve existing labels. Default to false to allow paint on existing labels. When set to true, existing labels will be preserved during painting. """ return self._preserve_labels @preserve_labels.setter def preserve_labels(self, preserve_labels: bool): self._preserve_labels = preserve_labels self.events.preserve_labels(preserve_labels=preserve_labels) @property def contrast_limits(self): return self._contrast_limits @contrast_limits.setter def contrast_limits(self, value): # Setting contrast_limits of labels layers leads to wrong visualization of the layer if tuple(value) != (0, 1): raise AttributeError( trans._( "Setting contrast_limits on labels layers is not allowed.", deferred=True, ) ) self._contrast_limits = (0, 1) def _reset_editable(self) -> None: self.editable = not self.multiscale def _on_editable_changed(self) -> None: if not self.editable: self.mode = Mode.PAN_ZOOM self._reset_history() def _lookup_with_low_discrepancy_image(self, im, selected_label=None): """Returns display version of im using low_discrepancy_image. Passes the image through low_discrepancy_image, only coloring selected_label if it's not None. Parameters ---------- im : array or int Raw integer input image. selected_label : int, optional Value of selected label to color, by default None """ if selected_label: image = np.where( im == selected_label, low_discrepancy_image(selected_label, self._seed), 0, ) else: image = np.where(im != 0, low_discrepancy_image(im, self._seed), 0) return image def _lookup_with_index(self, im, selected_label=None): """Returns display version of im using color lookup array by index Parameters ---------- im : array or int Raw integer input image. selected_label : int, optional Value of selected label to color, by default None """ if selected_label: if selected_label > len(self._all_vals): self._color_lookup_func = self._get_color_lookup_func( im, min(np.min(im), selected_label), max(np.max(im), selected_label), ) if ( self._color_lookup_func == self._lookup_with_low_discrepancy_image ): image = self._color_lookup_func(im, selected_label) else: colors = np.zeros_like(self._all_vals) colors[selected_label] = low_discrepancy_image( selected_label, self._seed ) image = colors[im] else: try: image = self._all_vals[im] except IndexError: self._color_lookup_func = self._get_color_lookup_func( im, np.min(im), np.max(im) ) if ( self._color_lookup_func == self._lookup_with_low_discrepancy_image ): # revert to "classic" mode converting all pixels since we # encountered a large value in the raw labels image image = self._color_lookup_func(im, selected_label) else: image = self._all_vals[im] return image def _get_color_lookup_func(self, data, min_label_val, max_label_val): """Returns function used for mapping label values to colors If array of [0..max(data)] would be larger than data, returns lookup_with_low_discrepancy_image, otherwise returns lookup_with_index Parameters ---------- data : array labels data min_label_val : int minimum label value in data max_label_val : int maximum label value in data Returns ------- lookup_func : function function to use for mapping label values to colors """ # low_discrepancy_image is slow for large images, but large labels can # blow up memory usage of an index array of colors. If the index array # would be larger than the image, we go back to computing the low # discrepancy image on the whole input image. (Up to a minimum value of # 1kB.) min_label_val0 = min(min_label_val, 0) # +1 to allow indexing with max_label_val data_range = max_label_val - min_label_val0 + 1 nbytes_low_discrepancy = low_discrepancy_image(np.array([0])).nbytes max_nbytes = max(data.nbytes, 1024) if data_range * nbytes_low_discrepancy > max_nbytes: return self._lookup_with_low_discrepancy_image else: if self._all_vals.size < data_range: new_all_vals = low_discrepancy_image( np.arange(min_label_val0, max_label_val + 1), self._seed ) self._all_vals = np.roll(new_all_vals, min_label_val0) self._all_vals[0] = 0 return self._lookup_with_index def _raw_to_displayed(self, raw): """Determine displayed image from a saved raw image and a saved seed. This function ensures that the 0 label gets mapped to the 0 displayed pixel. Parameters ---------- raw : array or int Raw integer input image. Returns ------- image : array Image mapped between 0 and 1 to be displayed. """ raw_modified = raw if self.contour > 0: if raw.ndim == 2: raw_modified = np.zeros_like(raw) struct_elem = ndi.generate_binary_structure(raw.ndim, 1) thickness = self.contour thick_struct_elem = ndi.iterate_structure( struct_elem, thickness ).astype(bool) boundaries = ndi.grey_dilation( raw, footprint=struct_elem ) != ndi.grey_erosion(raw, footprint=thick_struct_elem) raw_modified[boundaries] = raw[boundaries] elif raw.ndim > 2: warnings.warn( trans._( "Contours are not displayed during 3D rendering", deferred=True, ) ) if self._color_lookup_func is None: self._color_lookup_func = self._get_color_lookup_func( raw_modified, np.min(raw_modified), np.max(raw_modified) ) if ( not self.show_selected_label and self._color_mode == LabelColorMode.DIRECT ): u, inv = np.unique(raw_modified, return_inverse=True) image = np.array( [ self._label_color_index[x] if x in self._label_color_index else self._label_color_index[None] for x in u ] )[inv].reshape(raw_modified.shape) elif ( not self.show_selected_label and self._color_mode == LabelColorMode.AUTO ): image = self._color_lookup_func(raw_modified) elif ( self.show_selected_label and self._color_mode == LabelColorMode.AUTO ): image = self._color_lookup_func(raw_modified, self._selected_label) elif ( self.show_selected_label and self._color_mode == LabelColorMode.DIRECT ): selected = self._selected_label if selected not in self._label_color_index: selected = None index = self._label_color_index image = np.where( raw_modified == selected, index[selected], np.where( raw_modified != self._background_label, index[None], index[self._background_label], ), ) else: raise ValueError("Unsupported Color Mode") return image def new_colormap(self): self.seed = np.random.rand() def get_color(self, label): """Return the color corresponding to a specific label.""" if label == 0: col = None elif label is None: col = self.colormap.map([0, 0, 0, 0])[0] else: val = self._raw_to_displayed(np.array([label])) col = self.colormap.map(val)[0] return col def _get_value_ray( self, start_point: np.ndarray, end_point: np.ndarray, dims_displayed: List[int], ) -> Optional[int]: """Get the first non-background value encountered along a ray. Parameters ---------- start_point : np.ndarray (n,) array containing the start point of the ray in data coordinates. end_point : np.ndarray (n,) array containing the end point of the ray in data coordinates. dims_displayed : List[int] The indices of the dimensions currently displayed in the viewer. Returns ------- value : Optional[int] The first non-zero value encountered along the ray. If none was encountered or the viewer is in 2D mode, None is returned. """ if start_point is None or end_point is None: return None if len(dims_displayed) == 3: # only use get_value_ray on 3D for now # we use dims_displayed because the image slice # has its dimensions in th same order as the vispy # Volume start_point = start_point[dims_displayed] end_point = end_point[dims_displayed] sample_ray = end_point - start_point length_sample_vector = np.linalg.norm(sample_ray) n_points = int(2 * length_sample_vector) sample_points = np.linspace( start_point, end_point, n_points, endpoint=True ) im_slice = self._slice.image.raw clamped = clamp_point_to_bounding_box( sample_points, self._display_bounding_box(dims_displayed) ).astype(int) values = im_slice[tuple(clamped.T)] nonzero_indices = np.flatnonzero(values) if len(nonzero_indices > 0): # if a nonzer0 value was found, return the first one return values[nonzero_indices[0]] return None def _get_value_3d( self, start_point: np.ndarray, end_point: np.ndarray, dims_displayed: List[int], ) -> Optional[int]: """Get the first non-background value encountered along a ray. Parameters ---------- start_point : np.ndarray (n,) array containing the start point of the ray in data coordinates. end_point : np.ndarray (n,) array containing the end point of the ray in data coordinates. dims_displayed : List[int] The indices of the dimensions currently displayed in the viewer. Returns ------- value : int The first non-zero value encountered along the ray. If a non-zero value is not encountered, returns 0 (the background value). """ return ( self._get_value_ray( start_point=start_point, end_point=end_point, dims_displayed=dims_displayed, ) or 0 ) def _reset_history(self, event=None): self._undo_history = deque(maxlen=self._history_limit) self._redo_history = deque(maxlen=self._history_limit) self._staged_history = [] self._block_history = False @contextmanager def block_history(self): """Context manager to group history-editing operations together. While in the context, history atoms are grouped together into a "staged" history. When exiting the context, that staged history is committed to the undo history queue, and an event is emitted containing the change. """ prev = self._block_history self._block_history = True try: yield self._commit_staged_history() finally: self._block_history = prev def _commit_staged_history(self): """Save staged history to undo history and clear it.""" if self._staged_history: self._append_to_undo_history(self._staged_history) self._staged_history = [] def _append_to_undo_history(self, item): """Append item to history and emit paint event. Parameters ---------- item : List[Tuple[ndarray, ndarray, int]] list of history atoms to append to undo history. """ self._undo_history.append(item) self.events.paint(value=item) def _save_history(self, value): """Save a history "atom" to the undo history. A history "atom" is a single change operation to the array. A history *item* is a collection of atoms that were applied together to make a single change. For example, when dragging and painting, at each mouse callback we create a history "atom", but we save all those atoms in a single history item, since we would want to undo one drag in one undo operation. Parameters ---------- value : 3-tuple of arrays The value is a 3-tuple containing: - a numpy multi-index, pointing to the array elements that were changed - the values corresponding to those elements before the change - the value(s) after the change """ self._redo_history.clear() if self._block_history: self._staged_history.append(value) else: self._append_to_undo_history([value]) def _load_history(self, before, after, undoing=True): """Load a history item and apply it to the array. Parameters ---------- before : list of history items The list of elements from which we want to load. after : list of history items The list of element to which to append the loaded element. In the case of an undo operation, this is the redo queue, and vice versa. undoing : bool Whether we are undoing (default) or redoing. In the case of redoing, we apply the "after change" element of a history element (the third element of the history "atom"). See Also -------- Labels._save_history """ if len(before) == 0: return history_item = before.pop() after.append(list(reversed(history_item))) for prev_indices, prev_values, next_values in reversed(history_item): values = prev_values if undoing else next_values self.data[prev_indices] = values self.refresh() def undo(self): self._load_history( self._undo_history, self._redo_history, undoing=True ) def redo(self): self._load_history( self._redo_history, self._undo_history, undoing=False ) def fill(self, coord, new_label, refresh=True): """Replace an existing label with a new label, either just at the connected component if the `contiguous` flag is `True` or everywhere if it is `False`, working in the number of dimensions specified by the `n_edit_dimensions` flag. Parameters ---------- coord : sequence of float Position of mouse cursor in image coordinates. new_label : int Value of the new label to be filled in. refresh : bool Whether to refresh view slice or not. Set to False to batch paint calls. """ int_coord = tuple(np.round(coord).astype(int)) # If requested fill location is outside data shape then return if np.any(np.less(int_coord, 0)) or np.any( np.greater_equal(int_coord, self.data.shape) ): return # If requested new label doesn't change old label then return old_label = np.asarray(self.data[int_coord]).item() if old_label == new_label or ( self.preserve_labels and old_label != self._background_label ): return dims_to_fill = sorted( self._slice_input.order[-self.n_edit_dimensions :] ) data_slice_list = list(int_coord) for dim in dims_to_fill: data_slice_list[dim] = slice(None) data_slice = tuple(data_slice_list) labels = np.asarray(self.data[data_slice]) slice_coord = tuple(int_coord[d] for d in dims_to_fill) matches = labels == old_label if self.contiguous: # if contiguous replace only selected connected component labeled_matches, num_features = ndi.label(matches) if num_features != 1: match_label = labeled_matches[slice_coord] matches = np.logical_and( matches, labeled_matches == match_label ) match_indices_local = np.nonzero(matches) if self.ndim not in {2, self.n_edit_dimensions}: n_idx = len(match_indices_local[0]) match_indices = [] j = 0 for d in data_slice: if isinstance(d, slice): match_indices.append(match_indices_local[j]) j += 1 else: match_indices.append(np.full(n_idx, d, dtype=np.intp)) else: match_indices = match_indices_local match_indices = _coerce_indices_for_vectorization( self.data, match_indices ) self.data_setitem(match_indices, new_label, refresh) def _draw(self, new_label, last_cursor_coord, coordinates): """Paint into coordinates, accounting for mode and cursor movement. The draw operation depends on the current mode of the layer. Parameters ---------- new_label : int value of label to paint last_cursor_coord : sequence last painted cursor coordinates coordinates : sequence new cursor coordinates """ if coordinates is None: return interp_coord = interpolate_coordinates( last_cursor_coord, coordinates, self.brush_size ) for c in interp_coord: if ( self._slice_input.ndisplay == 3 and self.data[tuple(np.round(c).astype(int))] == 0 ): continue if self._mode in [Mode.PAINT, Mode.ERASE]: self.paint(c, new_label, refresh=False) elif self._mode == Mode.FILL: self.fill(c, new_label, refresh=False) self.refresh() def paint(self, coord, new_label, refresh=True): """Paint over existing labels with a new label, using the selected brush shape and size, either only on the visible slice or in all n dimensions. Parameters ---------- coord : sequence of int Position of mouse cursor in image coordinates. new_label : int Value of the new label to be filled in. refresh : bool Whether to refresh view slice or not. Set to False to batch paint calls. """ shape = self.data.shape dims_to_paint = sorted( self._slice_input.order[-self.n_edit_dimensions :] ) dims_not_painted = sorted( self._slice_input.order[: -self.n_edit_dimensions] ) paint_scale = np.array( [self.scale[i] for i in dims_to_paint], dtype=float ) slice_coord = [int(np.round(c)) for c in coord] if self.n_edit_dimensions < self.ndim: coord_paint = [coord[i] for i in dims_to_paint] shape = [shape[i] for i in dims_to_paint] else: coord_paint = coord # Ensure circle doesn't have spurious point # on edge by keeping radius as ##.5 radius = np.floor(self.brush_size / 2) + 0.5 mask_indices = sphere_indices(radius, tuple(paint_scale)) mask_indices = mask_indices + np.round(np.array(coord_paint)).astype( int ) # discard candidate coordinates that are out of bounds mask_indices = indices_in_shape(mask_indices, shape) # Transfer valid coordinates to slice_coord, # or expand coordinate if 3rd dim in 2D image slice_coord_temp = [m for m in mask_indices.T] if self.n_edit_dimensions < self.ndim: for j, i in enumerate(dims_to_paint): slice_coord[i] = slice_coord_temp[j] for i in dims_not_painted: slice_coord[i] = slice_coord[i] * np.ones( mask_indices.shape[0], dtype=int ) else: slice_coord = slice_coord_temp slice_coord = _coerce_indices_for_vectorization(self.data, slice_coord) # slice coord is a tuple of coordinate arrays per dimension # subset it if we want to only paint into background/only erase # current label if self.preserve_labels: if new_label == self._background_label: keep_coords = self.data[slice_coord] == self.selected_label else: keep_coords = self.data[slice_coord] == self._background_label slice_coord = tuple(sc[keep_coords] for sc in slice_coord) self.data_setitem(slice_coord, new_label, refresh) def data_setitem(self, indices, value, refresh=True): """Set `indices` in `data` to `value`, while writing to edit history. Parameters ---------- indices : tuple of int, slice, or sequence of int Indices in data to overwrite. Can be any valid NumPy indexing expression [1]_. value : int or array of int New label value(s). If more than one value, must match or broadcast with the given indices. refresh : bool, default True whether to refresh the view, by default True References ---------- ..[1] https://numpy.org/doc/stable/user/basics.indexing.html """ self._save_history( ( indices, np.array(self.data[indices], copy=True), value, ) ) # update the labels image self.data[indices] = value if refresh is True: self.refresh() def get_status( self, position: Optional[Tuple] = None, *, view_direction: Optional[np.ndarray] = None, dims_displayed: Optional[List[int]] = None, world: bool = False, ) -> dict: """Status message information of the data at a coordinate position. Parameters ---------- position : tuple Position in either data or world coordinates. view_direction : Optional[np.ndarray] A unit vector giving the direction of the ray in nD world coordinates. The default value is None. dims_displayed : Optional[List[int]] A list of the dimensions currently being displayed in the viewer. The default value is None. world : bool If True the position is taken to be in world coordinates and converted into data coordinates. False by default. Returns ------- source_info : dict Dict containing a information that can be used in a status update. """ if position is not None: value = self.get_value( position, view_direction=view_direction, dims_displayed=dims_displayed, world=world, ) else: value = None source_info = self._get_source_info() source_info['coordinates'] = generate_layer_coords_status( position[-self.ndim :], value ) # if this labels layer has properties properties = self._get_properties( position, view_direction=view_direction, dims_displayed=dims_displayed, world=world, ) if properties: source_info['coordinates'] += "; " + ", ".join(properties) return source_info def _get_tooltip_text( self, position, *, view_direction: Optional[np.ndarray] = None, dims_displayed: Optional[List[int]] = None, world: bool = False, ): """ tooltip message of the data at a coordinate position. Parameters ---------- position : tuple Position in either data or world coordinates. view_direction : Optional[np.ndarray] A unit vector giving the direction of the ray in nD world coordinates. The default value is None. dims_displayed : Optional[List[int]] A list of the dimensions currently being displayed in the viewer. The default value is None. world : bool If True the position is taken to be in world coordinates and converted into data coordinates. False by default. Returns ------- msg : string String containing a message that can be used as a tooltip. """ return "\n".join( self._get_properties( position, view_direction=view_direction, dims_displayed=dims_displayed, world=world, ) ) def _get_properties( self, position, *, view_direction: Optional[np.ndarray] = None, dims_displayed: Optional[List[int]] = None, world: bool = False, ) -> list: if len(self._label_index) == 0 or self.features.shape[1] == 0: return [] value = self.get_value( position, view_direction=view_direction, dims_displayed=dims_displayed, world=world, ) # if the cursor is not outside the image or on the background if value is None: return [] label_value = value[1] if self.multiscale else value if label_value not in self._label_index: return [trans._('[No Properties]')] idx = self._label_index[label_value] return [ f'{k}: {v[idx]}' for k, v in self.features.items() if k != 'index' and len(v) > idx and v[idx] is not None and not (isinstance(v[idx], float) and np.isnan(v[idx])) ] if config.async_octree: from napari.layers.image.experimental.octree_image import _OctreeImageBase class Labels(Labels, _OctreeImageBase): pass def _coerce_indices_for_vectorization(array, indices: list) -> tuple: """Coerces indices so that they can be used for vectorized indexing in the given data array.""" if _is_array_type(array, 'xarray.DataArray'): # Fix indexing for xarray if necessary # See http://xarray.pydata.org/en/stable/indexing.html#vectorized-indexing # for difference from indexing numpy try: import xarray as xr return tuple(xr.DataArray(i) for i in indices) except ModuleNotFoundError: pass return tuple(indices) napari-0.5.0a1/napari/layers/points/000077500000000000000000000000001437041365600173065ustar00rootroot00000000000000napari-0.5.0a1/napari/layers/points/__init__.py000066400000000000000000000005331437041365600214200ustar00rootroot00000000000000from napari.layers.points import _points_key_bindings from napari.layers.points.points import Points # Note that importing _points_key_bindings is needed as the Points layer gets # decorated with keybindings during that process, but it is not directly needed # by our users and so is deleted below del _points_key_bindings __all__ = ['Points'] napari-0.5.0a1/napari/layers/points/_points_constants.py000066400000000000000000000051521437041365600234320ustar00rootroot00000000000000from collections import OrderedDict from enum import auto from napari.utils.misc import StringEnum from napari.utils.translations import trans class ColorMode(StringEnum): """ ColorMode: Color setting mode. DIRECT (default mode) allows each point to be set arbitrarily CYCLE allows the color to be set via a color cycle over an attribute COLORMAP allows color to be set via a color map over an attribute """ DIRECT = auto() CYCLE = auto() COLORMAP = auto() class Mode(StringEnum): """ Mode: Interactive mode. The normal, default mode is PAN_ZOOM, which allows for normal interactivity with the canvas. ADD allows points to be added by clicking SELECT allows the user to select points by clicking on them """ PAN_ZOOM = auto() TRANSFORM = auto() ADD = auto() SELECT = auto() class Symbol(StringEnum): """Valid symbol/marker types for the Points layer.""" ARROW = auto() CLOBBER = auto() CROSS = auto() DIAMOND = auto() DISC = auto() HBAR = auto() RING = auto() SQUARE = auto() STAR = auto() TAILED_ARROW = auto() TRIANGLE_DOWN = auto() TRIANGLE_UP = auto() VBAR = auto() X = auto() # Mapping of symbol alias names to the deduplicated name SYMBOL_ALIAS = { 'o': Symbol.DISC, '*': Symbol.STAR, '+': Symbol.CROSS, '-': Symbol.HBAR, '->': Symbol.TAILED_ARROW, '>': Symbol.ARROW, '^': Symbol.TRIANGLE_UP, 'v': Symbol.TRIANGLE_DOWN, 's': Symbol.SQUARE, '|': Symbol.VBAR, } SYMBOL_TRANSLATION = OrderedDict( [ (Symbol.ARROW, trans._('arrow')), (Symbol.CLOBBER, trans._('clobber')), (Symbol.CROSS, trans._('cross')), (Symbol.DIAMOND, trans._('diamond')), (Symbol.DISC, trans._('disc')), (Symbol.HBAR, trans._('hbar')), (Symbol.RING, trans._('ring')), (Symbol.SQUARE, trans._('square')), (Symbol.STAR, trans._('star')), (Symbol.TAILED_ARROW, trans._('tailed arrow')), (Symbol.TRIANGLE_DOWN, trans._('triangle down')), (Symbol.TRIANGLE_UP, trans._('triangle up')), (Symbol.VBAR, trans._('vbar')), (Symbol.X, trans._('x')), ] ) SYMBOL_TRANSLATION_INVERTED = {v: k for k, v in SYMBOL_TRANSLATION.items()} class Shading(StringEnum): """Shading: Shading mode for the points. NONE no shading is applied. SPHERICAL shading and depth buffer are modified to mimic a 3D object with spherical shape """ NONE = auto() SPHERICAL = auto() SHADING_TRANSLATION = { trans._("none"): Shading.NONE, trans._("spherical"): Shading.SPHERICAL, } napari-0.5.0a1/napari/layers/points/_points_key_bindings.py000066400000000000000000000076001437041365600240630ustar00rootroot00000000000000from __future__ import annotations from app_model.types import KeyCode, KeyMod from napari.layers.points._points_constants import Mode from napari.layers.points.points import Points from napari.layers.utils.layer_utils import ( register_layer_action, register_layer_attr_action, ) from napari.utils.notifications import show_info from napari.utils.translations import trans def register_points_action(description: str, repeatable: bool = False): return register_layer_action(Points, description, repeatable) def register_points_mode_action(description): return register_layer_attr_action(Points, description, 'mode') @register_points_mode_action(trans._('Transform')) def activate_points_transform_mode(layer): layer.mode = Mode.TRANSFORM @register_points_mode_action(trans._('Pan/zoom')) def activate_points_pan_zoom_mode(layer): layer.mode = Mode.PAN_ZOOM @register_points_mode_action(trans._('Add points')) def activate_points_add_mode(layer: Points): layer.mode = Mode.ADD @register_points_mode_action(trans._('Select points')) def activate_points_select_mode(layer: Points): layer.mode = Mode.SELECT points_fun_to_mode = [ (activate_points_pan_zoom_mode, Mode.PAN_ZOOM), (activate_points_transform_mode, Mode.TRANSFORM), (activate_points_add_mode, Mode.ADD), (activate_points_select_mode, Mode.SELECT), ] @Points.bind_key(KeyMod.CtrlCmd | KeyCode.KeyC) def copy(layer: Points): """Copy any selected points.""" layer._copy_data() @Points.bind_key(KeyMod.CtrlCmd | KeyCode.KeyV) def paste(layer: Points): """Paste any copied points.""" layer._paste_data() @register_points_action( trans._("Select all points in the current view slice."), ) def select_all_in_slice(layer: Points): new_selected = set(layer._indices_view[: len(layer._view_data)]) # If all visible points are already selected, deselect the visible points if new_selected & layer.selected_data == new_selected: layer.selected_data = layer.selected_data - new_selected show_info( trans._( "Deselected all points in this slice, use Shift-A to deselect all points on the layer. ({n_total} selected)", n_total=len(layer.selected_data), deferred=True, ) ) # If not all visible points are already selected, additionally select the visible points else: layer.selected_data = layer.selected_data | new_selected show_info( trans._( "Selected {n_new} points in this slice, use Shift-A to select all points on the layer. ({n_total} selected)", n_new=len(new_selected), n_total=len(layer.selected_data), deferred=True, ) ) layer._set_highlight() @register_points_action( trans._("Select all points in the layer."), ) def select_all_data(layer: Points): # If all points are already selected, deselect all points if len(layer.selected_data) == len(layer.data): layer.selected_data = set() show_info(trans._("Cleared all selections.", deferred=True)) # Select all points else: new_selected = set(range(layer.data.shape[0])) # Needed for the notification view_selected = set(layer._indices_view[: len(layer._view_data)]) layer.selected_data = new_selected show_info( trans._( "Selected {n_new} points across all slices, including {n_invis} points not currently visible. ({n_total})", n_new=len(new_selected), n_invis=len(new_selected - view_selected), n_total=len(layer.selected_data), deferred=True, ) ) layer._set_highlight() @register_points_action(trans._('Delete selected points')) def delete_selected_points(layer: Points): """Delete all selected points.""" layer.remove_selected() napari-0.5.0a1/napari/layers/points/_points_mouse_bindings.py000066400000000000000000000164641437041365600244330ustar00rootroot00000000000000import numpy as np from napari.layers.points._points_utils import _points_in_box_3d, points_in_box def select(layer, event): """Select points. Clicking on a point will select that point. If holding shift while clicking that point will be added to or removed from the existing selection depending on whether it is selected or not. Clicking and dragging a point that is already selected will drag all the currently selected points. Clicking and dragging on an empty part of the canvas (i.e. not on a point) will create a drag box that will select all points inside it when finished. Holding shift throughout the entirety of this process will add those points to any existing selection, otherwise these will become the only selected points. """ # on press modify_selection = ( 'Shift' in event.modifiers or 'Control' in event.modifiers ) # Get value under the cursor, for points, this is the index of the highlighted # if any, or None. value = layer.get_value( position=event.position, view_direction=event.view_direction, dims_displayed=event.dims_displayed, world=True, ) # if modifying selection add / remove any from existing selection if modify_selection: if value is not None: layer.selected_data = _toggle_selected(layer.selected_data, value) else: if value is not None: # If the current index is not in the current list make it the only # index selected, otherwise don't change the selection so that # the current selection can be dragged together. if value not in layer.selected_data: layer.selected_data = {value} else: layer.selected_data = set() layer._set_highlight() # Set _drag_start value here to prevent an offset when mouse_move happens # https://github.com/napari/napari/pull/4999 layer._set_drag_start( layer.selected_data, layer.world_to_data(event.position), center_by_data=not modify_selection, ) yield # Undo the toggle selected in case of a mouse move with modifiers if modify_selection and value is not None and event.type == 'mouse_move': layer.selected_data = _toggle_selected(layer.selected_data, value) is_moving = False # on move while event.type == 'mouse_move': coordinates = layer.world_to_data(event.position) # If not holding modifying selection and points selected then drag them if not modify_selection and len(layer.selected_data) > 0: is_moving = True with layer.events.data.blocker(): layer._move(layer.selected_data, coordinates) else: # while dragging, update the drag box coord = [coordinates[i] for i in layer._slice_input.displayed] layer._is_selecting = True layer._drag_box = np.array([layer._drag_start, coord]) # update the drag up and normal vectors on the layer _update_drag_vectors_from_event(layer=layer, event=event) layer._set_highlight() yield # only emit data once dragging has finished if is_moving: layer._move([], coordinates) is_moving = False # on release layer._drag_start = None if layer._is_selecting: # if drag selection was being performed, select points # using the drag box layer._is_selecting = False n_display = len(event.dims_displayed) _select_points_from_drag( layer=layer, modify_selection=modify_selection, n_display=n_display ) # reset the selection box data and highlights layer._drag_box = None layer._drag_normal = None layer._drag_up = None layer._set_highlight(force=True) DRAG_DIST_THRESHOLD = 5 def add(layer, event): """Add a new point at the clicked position.""" if event.type == 'mouse_press': start_pos = event.pos while event.type != 'mouse_release': yield dist = np.linalg.norm(start_pos - event.pos) if dist < DRAG_DIST_THRESHOLD: coordinates = layer.world_to_data(event.position) layer.add(coordinates) def highlight(layer, event): """Highlight hovered points.""" layer._set_highlight() def _toggle_selected(selected_data, value): """Add or remove value from the selected data set. Parameters ---------- selected_data : set Set of selected data points to be modified. value : int Index of point to add or remove from selected data set. Returns ------- set Modified selected_data set. """ if value in selected_data: selected_data.remove(value) else: selected_data.add(value) return selected_data def _update_drag_vectors_from_event(layer, event): """Update the drag normal and up vectors on layer from a mouse event. Note that in 2D mode, the layer._drag_normal and layer._drag_up are set to None. Parameters ---------- layer : "napari.layers.Points" The Points layer to update. event The mouse event object. """ n_display = len(event.dims_displayed) if n_display == 3: # if in 3D, set the drag normal and up directions # get the indices of the displayed dimensions ndim_world = len(event.position) layer_dims_displayed = layer._world_to_layer_dims( world_dims=event.dims_displayed, ndim_world=ndim_world ) # get the view direction in displayed data coordinates layer._drag_normal = layer._world_to_displayed_data_ray( event.view_direction, layer_dims_displayed ) # get the up direction of the camera in displayed data coordinates layer._drag_up = layer._world_to_displayed_data_ray( event.up_direction, layer_dims_displayed ) else: # if in 2D, set the drag normal and up to None layer._drag_normal = None layer._drag_up = None def _select_points_from_drag(layer, modify_selection: bool, n_display: int): """Select points on a Points layer after a drag event. Parameters ---------- layer : napari.layers.Points The points layer to select points on. modify_selection : bool Set to true if the selection should modify the current selected data in layer.selected_data. n_display : int The number of dimensions current being displayed """ if len(layer._view_data) == 0: # if no data in view, there isn't any data to select layer.selected_data = set() # if there is data in view, find the points in the drag box if n_display == 2: selection = points_in_box( layer._drag_box, layer._view_data, layer._view_size ) else: selection = _points_in_box_3d( layer._drag_box, layer._view_data, layer._view_size, layer._drag_normal, layer._drag_up, ) # If shift combine drag selection with existing selected ones if modify_selection: new_selected = layer._indices_view[selection] target = set(layer.selected_data).symmetric_difference( set(new_selected) ) layer.selected_data = list(target) else: layer.selected_data = layer._indices_view[selection] napari-0.5.0a1/napari/layers/points/_points_utils.py000066400000000000000000000210421437041365600225520ustar00rootroot00000000000000from typing import List, Optional, Tuple import numpy as np from napari.layers.points._points_constants import SYMBOL_ALIAS, Symbol from napari.utils.geometry import project_points_onto_plane from napari.utils.translations import trans def _create_box_from_corners_3d( box_corners: np.ndarray, box_normal: np.ndarray, up_vector: np.ndarray ) -> np.ndarray: """Get front corners for 3D box from opposing corners and edge directions. The resulting box will include the two corners passed in as box_corners, lie in a plane with normal box_normal, and have one of its axes aligned with the up_vector. Parameters ---------- box_corners : np.ndarray The (2 x 3) array containing the two 3D points that are opposing corners of the bounding box. box_normal : np.ndarray The (3,) array containing the normal vector for the plane in which the box lies in. up_vector : np.ndarray The (3,) array containing the vector describing the up direction of the box. Returns ------- box : np.ndarray The (4, 3) array containing the 3D coordinate of each corner of the box. """ horizontal_vector = np.cross(box_normal, up_vector) diagonal_vector = box_corners[1] - box_corners[0] up_displacement = np.dot(diagonal_vector, up_vector) * up_vector horizontal_displacement = ( np.dot(diagonal_vector, horizontal_vector) * horizontal_vector ) corner_1 = box_corners[0] + horizontal_displacement corner_3 = box_corners[0] + up_displacement box = np.array([box_corners[0], corner_1, box_corners[1], corner_3]) return box def create_box(data): """Create the axis aligned interaction box of a list of points Parameters ---------- data : (N, 2) array Points around which the interaction box is created Returns ------- box : (4, 2) array Vertices of the interaction box """ min_val = data.min(axis=0) max_val = data.max(axis=0) tl = np.array([min_val[0], min_val[1]]) tr = np.array([max_val[0], min_val[1]]) br = np.array([max_val[0], max_val[1]]) bl = np.array([min_val[0], max_val[1]]) box = np.array([tl, tr, br, bl]) return box def points_to_squares(points, sizes): """Expand points to squares defined by their size Parameters ---------- points : (N, 2) array Points to be turned into squares sizes : (N,) array Size of each point Returns ------- rect : (4N, 2) array Vertices of the expanded points """ rect = np.concatenate( [ points + 0.5 * np.array([sizes, sizes]).T, points + 0.5 * np.array([sizes, -sizes]).T, points + 0.5 * np.array([-sizes, sizes]).T, points + 0.5 * np.array([-sizes, -sizes]).T, ], axis=0, ) return rect def _points_in_box_3d( box_corners: np.ndarray, points: np.ndarray, sizes: np.ndarray, box_normal: np.ndarray, up_direction: np.ndarray, ) -> List[int]: """Determine which points are inside of 2D bounding box. The 2D bounding box extends infinitely in both directions along its normal vector. Point selection algorithm: 1. Project the points in view and drag box corners on to a plane parallel to the canvas (i.e., normal direction is the view direction). 2. Rotate the points/bbox corners to a new basis comprising the bbox normal, the camera up direction, and the "horizontal direction" (i.e., vector orthogonal to bbox normal and camera up direction). This makes it such that the bounding box is axis aligned (i.e., the major and minor axes of the bounding box are aligned with the new 0 and 1 axes). 3. Determine which points are in the bounding box in 2D. We can simplify to 2D since the points and bounding box now lie in the same plane. Parameters ---------- box_corners : np.ndarray The (2 x 3) array containing the two 3D points that are opposing corners of the bounding box. points : np.ndarray The (n x3) array containing the n 3D points that are to be tested for being inside of the bounding box. sizes : np.ndarray The (n,) array containing the diameters of the points. box_normal : np.ndarray The (3,) array containing the normal vector for the plane in which the box lies in. up_direction : np.ndarray The (3,) array containing the vector describing the up direction of the box. Returns ------- inside : list Indices of points inside the box. """ # the the corners for a bounding box that is has one axis aligned # with the camera up direction and is normal to the view direction. bbox_corners = _create_box_from_corners_3d( box_corners, box_normal, up_direction ) # project points onto the same plane as the box projected_points, _ = project_points_onto_plane( points=points, plane_point=bbox_corners[0], plane_normal=box_normal, ) # create a new basis in which the bounding box is # axis aligned horz_direction = np.cross(box_normal, up_direction) plane_basis = np.column_stack([up_direction, horz_direction, box_normal]) # transform the points and bounding box into a new basis # such that tha boudning box is axis aligned bbox_corners_axis_aligned = bbox_corners @ plane_basis bbox_corners_axis_aligned = bbox_corners_axis_aligned[:, :2] points_axis_aligned = projected_points @ plane_basis points_axis_aligned = points_axis_aligned[:, :2] # determine which points are in the box using the # axis-aligned basis return points_in_box(bbox_corners_axis_aligned, points_axis_aligned, sizes) def points_in_box( corners: np.ndarray, points: np.ndarray, sizes: np.ndarray ) -> List[int]: """Find which points are in an axis aligned box defined by its corners. Parameters ---------- corners : (2, 2) array The top-left and bottom-right corners of the box. points : (N, 2) array Points to be checked sizes : (N,) array Size of each point Returns ------- inside : list Indices of points inside the box """ box = create_box(corners)[[0, 2]] # Check all four corners in a square around a given point. If any corner # is inside the box, then that point is considered inside point_corners = points_to_squares(points, sizes) below_top = np.all(box[1] >= point_corners, axis=1) above_bottom = np.all(point_corners >= box[0], axis=1) point_corners_in_box = np.where(np.logical_and(below_top, above_bottom))[0] # Determine indices of points which have at least one corner inside box inside = np.unique(point_corners_in_box % len(points)) return list(inside) def fix_data_points( points: Optional[np.ndarray], ndim: Optional[int] ) -> Tuple[np.ndarray, int]: """ Ensure that points array is 2d and have second dimension of size ndim (default 2 for empty arrays) Parameters ---------- points : (N, M) array or None Points to be checked ndim : int or None number of expected dimensions Returns ------- points : (N, M) array Points array ndim : int number of dimensions Raises ------ ValueError if ndim does not match with second dimensions of points """ if points is None or len(points) == 0: if ndim is None: ndim = 2 points = np.empty((0, ndim)) else: points = np.atleast_2d(points) data_ndim = points.shape[1] if ndim is not None and ndim != data_ndim: raise ValueError( trans._( "Points dimensions must be equal to ndim", deferred=True, ) ) ndim = data_ndim return points, ndim def coerce_symbols(array: np.ndarray) -> np.ndarray: """ Parse an array of symbols and convert it to the correct strings. Ensures that all strings are valid symbols and converts aliases. Parameters ---------- array : np.ndarray Array of strings matching Symbol values. """ # dtype has to be object, otherwise np.vectorize will cut it down to `U(N)`, # where N is the biggest string currently in the array. array = array.astype(object, copy=True) for k, v in SYMBOL_ALIAS.items(): array[(array == k) | (array == k.upper())] = v # otypes necessary for empty arrays return np.vectorize(Symbol, otypes=[object])(array) napari-0.5.0a1/napari/layers/points/_slice.py000066400000000000000000000103221437041365600211140ustar00rootroot00000000000000from dataclasses import dataclass, field from typing import Any import numpy as np from napari.layers.utils._slice_input import _SliceInput @dataclass(frozen=True) class _PointSliceResponse: """Contains all the output data of slicing an image layer. Attributes ---------- indices : array like Indices of the sliced Points data. scale: array like or none Used to scale the sliced points for visualization. Should be broadcastable to indices. dims : _SliceInput Describes the slicing plane or bounding box in the layer's dimensions. """ indices: np.ndarray = field(repr=False) scale: Any = field(repr=False) dims: _SliceInput @dataclass(frozen=True) class _PointSliceRequest: """A callable that stores all the input data needed to slice a Points layer. This should be treated a deeply immutable structure, even though some fields can be modified in place. It is like a function that has captured all its inputs already. In general, the calling an instance of this may take a long time, so you may want to run it off the main thread. Attributes ---------- dims : _SliceInput Describes the slicing plane or bounding box in the layer's dimensions. data : Any The layer's data field, which is the main input to slicing. dims_indices : tuple of ints or slices The slice indices in the layer's data space. size : array like Size of each point. This is used in calculating visibility. others See the corresponding attributes in `Layer` and `Image`. """ dims: _SliceInput data: Any = field(repr=False) dims_indices: Any = field(repr=False) size: Any = field(repr=False) out_of_slice_display: bool = field(repr=False) def __call__(self) -> _PointSliceResponse: # Return early if no data if len(self.data) == 0: return _PointSliceResponse( indices=[], scale=np.empty(0), dims=self.dims ) not_disp = list(self.dims.not_displayed) if not not_disp: # If we want to display everything, then use all indices. # scale is only impacted by not displayed data, therefore 1 return _PointSliceResponse( indices=np.arange(len(self.data), dtype=int), scale=1, dims=self.dims, ) # We want a numpy array so we can use fancy indexing with the non-displayed # indices, but as self.dims_indices can (and often/always does) contain slice # objects, the array has dtype=object which is then very slow for the # arithmetic below. As Points._round_index is always False, we can safely # convert to float to get a major performance improvement. not_disp_indices = np.array(self.dims_indices)[not_disp].astype(float) if self.out_of_slice_display and self.dims.ndim > 2: slice_indices, scale = self._get_out_of_display_slice_data( not_disp, not_disp_indices ) else: slice_indices, scale = self._get_slice_data( not_disp, not_disp_indices ) return _PointSliceResponse( indices=slice_indices, scale=scale, dims=self.dims ) def _get_out_of_display_slice_data(self, not_disp, not_disp_indices): """This method slices in the out-of-display case.""" distances = abs(self.data[:, not_disp] - not_disp_indices) sizes = self.size[:, not_disp] / 2 matches = np.all(distances <= sizes, axis=1) size_match = sizes[matches] size_match[size_match == 0] = 1 scale_per_dim = (size_match - distances[matches]) / size_match scale_per_dim[size_match == 0] = 1 scale = np.prod(scale_per_dim, axis=1) slice_indices = np.where(matches)[0].astype(int) return slice_indices, scale def _get_slice_data(self, not_disp, not_disp_indices): """This method slices in the simpler case.""" data = self.data[:, not_disp] distances = np.abs(data - not_disp_indices) matches = np.all(distances <= 0.5, axis=1) slice_indices = np.where(matches)[0].astype(int) return slice_indices, 1 napari-0.5.0a1/napari/layers/points/_tests/000077500000000000000000000000001437041365600206075ustar00rootroot00000000000000napari-0.5.0a1/napari/layers/points/_tests/test_points.py000066400000000000000000002346021437041365600235430ustar00rootroot00000000000000from copy import copy from itertools import cycle, islice from unittest.mock import Mock import numpy as np import pandas as pd import pytest from pydantic import ValidationError from vispy.color import get_colormap from napari._tests.utils import ( assert_layer_state_equal, check_layer_world_data_extent, ) from napari.layers import Points from napari.layers.points._points_constants import Mode from napari.layers.points._points_utils import points_to_squares from napari.layers.utils._text_constants import Anchor from napari.layers.utils.color_encoding import ConstantColorEncoding from napari.layers.utils.color_manager import ColorProperties from napari.utils.colormaps.standardize_color import transform_color from napari.utils.transforms import CompositeAffine def _make_cycled_properties(values, length): """Helper function to make property values Parameters ---------- values The values to be cycled. length : int The length of the resulting property array Returns ------- cycled_properties : np.ndarray The property array comprising the cycled values. """ cycled_properties = np.array(list(islice(cycle(values), 0, length))) return cycled_properties def test_empty_points(): pts = Points() assert pts.data.shape == (0, 2) def test_empty_points_with_properties(): """Test instantiating an empty Points layer with properties See: https://github.com/napari/napari/pull/1069 """ properties = { 'label': np.array(['label1', 'label2']), 'cont_prop': np.array([0], dtype=float), } pts = Points(property_choices=properties) current_props = {k: v[0] for k, v in properties.items()} np.testing.assert_equal(pts.current_properties, current_props) # verify the property datatype is correct assert pts.properties['cont_prop'].dtype == float # add two points and verify the default property was applied pts.add([10, 10]) pts.add([20, 20]) props = { 'label': np.array(['label1', 'label1']), 'cont_prop': np.array([0, 0], dtype=float), } np.testing.assert_equal(pts.properties, props) def test_empty_points_with_properties_list(): """Test instantiating an empty Points layer with properties stored in a list See: https://github.com/napari/napari/pull/1069 """ properties = {'label': ['label1', 'label2'], 'cont_prop': [0]} pts = Points(property_choices=properties) current_props = {k: np.asarray(v[0]) for k, v in properties.items()} np.testing.assert_equal(pts.current_properties, current_props) # add two points and verify the default property was applied pts.add([10, 10]) pts.add([20, 20]) props = { 'label': np.array(['label1', 'label1']), 'cont_prop': np.array([0, 0], dtype=float), } np.testing.assert_equal(pts.properties, props) def test_empty_layer_with_face_colormap(): """Test creating an empty layer where the face color is a colormap See: https://github.com/napari/napari/pull/1069 """ default_properties = {'point_type': np.array([1.5], dtype=float)} layer = Points( property_choices=default_properties, face_color='point_type', face_colormap='gray', ) assert layer.face_color_mode == 'colormap' # verify the current_face_color is correct face_color = np.array([1, 1, 1, 1]) np.testing.assert_allclose(layer._face.current_color, face_color) def test_empty_layer_with_edge_colormap(): """Test creating an empty layer where the face color is a colormap See: https://github.com/napari/napari/pull/1069 """ default_properties = {'point_type': np.array([1.5], dtype=float)} layer = Points( property_choices=default_properties, edge_color='point_type', edge_colormap='gray', ) assert layer.edge_color_mode == 'colormap' # verify the current_face_color is correct edge_color = np.array([1, 1, 1, 1]) np.testing.assert_allclose(layer._edge.current_color, edge_color) @pytest.mark.parametrize('feature_name', ('edge', 'face')) def test_set_current_properties_on_empty_layer_with_color_cycle(feature_name): """Test setting current_properties an empty layer where the face/edge color is a color cycle. See: https://github.com/napari/napari/pull/3110 """ default_properties = {'annotation': np.array(['tail', 'nose', 'paw'])} color_cycle = [[0, 1, 0, 1], [1, 0, 1, 1]] color_parameters = { 'colors': 'annotation', 'categorical_colormap': color_cycle, 'mode': 'cycle', } color_name = f'{feature_name}_color' points_kwargs = { 'property_choices': default_properties, color_name: color_parameters, } layer = Points(**points_kwargs) color_mode = getattr(layer, f'{feature_name}_color_mode') assert color_mode == 'cycle' layer.current_properties = {'annotation': np.array(['paw'])} layer.add([10, 10]) colors = getattr(layer, color_name) np.testing.assert_allclose(colors, [color_cycle[1]]) assert len(layer.data) == 1 cm = getattr(layer, f'_{feature_name}') assert cm.color_properties.current_value == 'paw' def test_empty_layer_with_text_properties(): """Test initializing an empty layer with text defined""" default_properties = {'point_type': np.array([1.5], dtype=float)} text_kwargs = {'string': 'point_type', 'color': 'red'} layer = Points( property_choices=default_properties, text=text_kwargs, ) assert layer.text.values.size == 0 np.testing.assert_allclose(layer.text.color.constant, [1, 0, 0, 1]) # add a point and check that the appropriate text value was added layer.add([1, 1]) np.testing.assert_equal(layer.text.values, ['1.5']) np.testing.assert_allclose(layer.text.color.constant, [1, 0, 0, 1]) def test_empty_layer_with_text_formatted(): """Test initializing an empty layer with text defined""" default_properties = {'point_type': np.array([1.5], dtype=float)} layer = Points( property_choices=default_properties, text='point_type: {point_type:.2f}', ) assert layer.text.values.size == 0 # add a point and check that the appropriate text value was added layer.add([1, 1]) np.testing.assert_equal(layer.text.values, ['point_type: 1.50']) def test_random_points(): """Test instantiating Points layer with random 2D data.""" shape = (10, 2) np.random.seed(0) data = 20 * np.random.random(shape) layer = Points(data) assert np.all(layer.data == data) assert layer.ndim == shape[1] assert layer._view_data.ndim == 2 assert len(layer.data) == 10 assert len(layer.selected_data) == 0 def test_integer_points(): """Test instantiating Points layer with integer data.""" shape = (10, 2) np.random.seed(0) data = np.random.randint(20, size=(10, 2)) layer = Points(data) assert np.all(layer.data == data) assert layer.ndim == shape[1] assert layer._view_data.ndim == 2 assert len(layer.data) == 10 def test_negative_points(): """Test instantiating Points layer with negative data.""" shape = (10, 2) np.random.seed(0) data = 20 * np.random.random(shape) - 10 layer = Points(data) assert np.all(layer.data == data) assert layer.ndim == shape[1] assert layer._view_data.ndim == 2 assert len(layer.data) == 10 def test_empty_points_array(): """Test instantiating Points layer with empty array.""" shape = (0, 2) data = np.empty(shape) layer = Points(data) assert np.all(layer.data == data) assert layer.ndim == shape[1] assert layer._view_data.ndim == 2 assert len(layer.data) == 0 def test_3D_points(): """Test instantiating Points layer with random 3D data.""" shape = (10, 3) np.random.seed(0) data = 20 * np.random.random(shape) layer = Points(data) assert np.all(layer.data == data) assert layer.ndim == shape[1] assert layer._view_data.ndim == 2 assert len(layer.data) == 10 def test_single_point_extent(): """Test extent of a single 3D point at the origin.""" shape = (1, 3) data = np.zeros(shape) layer = Points(data) assert np.all(layer.extent.data == 0) assert np.all(layer.extent.world == 0) assert np.all(layer.extent.step == 1) def test_4D_points(): """Test instantiating Points layer with random 4D data.""" shape = (10, 4) np.random.seed(0) data = 20 * np.random.random(shape) layer = Points(data) assert np.all(layer.data == data) assert layer.ndim == shape[1] assert layer._view_data.ndim == 2 assert len(layer.data) == 10 def test_changing_points(): """Test changing Points data.""" shape_a = (10, 2) shape_b = (20, 2) np.random.seed(0) data_a = 20 * np.random.random(shape_a) data_b = 20 * np.random.random(shape_b) layer = Points(data_a) layer.data = data_b assert np.all(layer.data == data_b) assert layer.ndim == shape_b[1] assert layer._view_data.ndim == 2 assert len(layer.data) == 20 def test_selecting_points(): """Test selecting points.""" shape = (10, 2) np.random.seed(0) data = 20 * np.random.random(shape) layer = Points(data) layer.mode = 'select' data_to_select = {1, 2} layer.selected_data = data_to_select assert layer.selected_data == data_to_select # test switching to 3D layer._slice_dims(ndisplay=3) assert layer.selected_data == data_to_select # select different points while in 3D mode other_data_to_select = {0} layer.selected_data = other_data_to_select assert layer.selected_data == other_data_to_select # selection should persist when going back to 2D mode layer._slice_dims(ndisplay=2) assert layer.selected_data == other_data_to_select # selection should persist when switching between between select and pan_zoom layer.mode = 'pan_zoom' assert layer.selected_data == other_data_to_select layer.mode = 'select' assert layer.selected_data == other_data_to_select # add mode should clear the selection layer.mode = 'add' assert layer.selected_data == set() def test_adding_points(): """Test adding Points data.""" shape = (10, 2) np.random.seed(0) data = 20 * np.random.random(shape) layer = Points(data) assert len(layer.data) == 10 coord = [20, 20] layer.add(coord) assert len(layer.data) == 11 assert np.all(layer.data[10] == coord) # the added point should be selected assert layer.selected_data == {10} # test adding multiple points coords = [[10, 10], [15, 15]] layer.add(coords) assert len(layer.data) == 13 assert np.all(layer.data[11:, :] == coords) # test that the last added points can be deleted layer.remove_selected() np.testing.assert_equal(layer.data, np.vstack((data, coord))) def test_adding_points_to_empty(): """Test adding Points data to empty.""" shape = (0, 2) data = np.empty(shape) layer = Points(data) assert len(layer.data) == 0 coord = [20, 20] layer.add(coord) assert len(layer.data) == 1 assert np.all(layer.data[0] == coord) def test_removing_selected_points(): """Test selecting points.""" shape = (10, 2) np.random.seed(0) data = 20 * np.random.random(shape) layer = Points(data) # With nothing selected no points should be removed layer.remove_selected() assert len(layer.data) == shape[0] # Select two points and remove them layer.selected_data = {0, 3} layer.remove_selected() assert len(layer.data) == shape[0] - 2 assert len(layer.selected_data) == 0 keep = [1, 2] + list(range(4, 10)) assert np.all(layer.data == data[keep]) assert layer._value is None # Select another point and remove it layer.selected_data = {4} layer.remove_selected() assert len(layer.data) == shape[0] - 3 def test_deleting_selected_value_changes(): """Test deleting selected points appropriately sets self._value""" shape = (10, 2) np.random.seed(0) data = 20 * np.random.random(shape) layer = Points(data) # removing with self._value selected resets self._value to None layer._value = 1 layer.selected_data = {1, 2} layer.remove_selected() assert layer._value is None # removing with self._value outside selection doesn't change self._value layer._value = 3 layer.selected_data = {4} layer.remove_selected() assert layer._value == 3 def test_remove_selected_updates_value(): """Test that removing a point that is not layer._value updates the index to account for the removed data. """ shape = (10, 2) np.random.seed(0) data = 20 * np.random.random(shape) layer = Points(data) # set the value layer._value = 3 layer._value_stored = 3 layer.selected_data = {0, 5, 6, 7} layer.remove_selected() assert layer._value == 2 def test_remove_selected_removes_corresponding_attributes(): """Test that removing points at specific indices also removes any per-point attribute at the same index""" shape = (10, 2) np.random.seed(0) data = 20 * np.random.random(shape) size = np.random.rand(shape[0]) symbol = np.random.choice(['o', 's'], shape[0]) color = np.random.rand(shape[0], 4) feature = np.random.rand(shape[0]) shown = np.random.randint(2, size=shape[0]).astype(bool) text = 'feature' layer = Points( data, size=size, edge_width=size, symbol=symbol, features={'feature': feature}, face_color=color, edge_color=color, text=text, shown=shown, ) layer_expected = Points( data[1:], size=size[1:], symbol=symbol[1:], edge_width=size[1:], features={'feature': feature[1:]}, face_color=color[1:], edge_color=color[1:], text=text, # computed from feature shown=shown[1:], ) layer.selected_data = {0} layer.remove_selected() state_layer = layer._get_state() state_expected = layer_expected._get_state() assert_layer_state_equal(state_layer, state_expected) def test_move(): """Test moving points.""" shape = (10, 2) np.random.seed(0) data = 20 * np.random.random(shape) unmoved = copy(data) layer = Points(data) # Move one point relative to an initial drag start location layer._move([0], [0, 0]) layer._move([0], [10, 10]) layer._drag_start = None assert np.all(layer.data[0] == unmoved[0] + [10, 10]) assert np.all(layer.data[1:] == unmoved[1:]) # Move two points relative to an initial drag start location layer._move([1, 2], [2, 2]) layer._move([1, 2], np.add([2, 2], [-3, 4])) assert np.all(layer.data[1:2] == unmoved[1:2] + [-3, 4]) def test_changing_modes(): """Test changing modes.""" shape = (10, 2) np.random.seed(0) data = 20 * np.random.random(shape) layer = Points(data) assert layer.mode == 'pan_zoom' assert layer.interactive is True layer.mode = 'add' assert layer.mode == 'add' layer.mode = 'select' assert layer.mode == 'select' assert layer.interactive is False layer.mode = 'pan_zoom' assert layer.mode == 'pan_zoom' assert layer.interactive is True with pytest.raises(ValueError): layer.mode = 'not_a_mode' def test_name(): """Test setting layer name.""" np.random.seed(0) data = 20 * np.random.random((10, 2)) layer = Points(data) assert layer.name == 'Points' layer = Points(data, name='random') assert layer.name == 'random' layer.name = 'pts' assert layer.name == 'pts' def test_visibility(): """Test setting layer visibility.""" np.random.seed(0) data = 20 * np.random.random((10, 2)) layer = Points(data) assert layer.visible is True layer.visible = False assert layer.visible is False layer = Points(data, visible=False) assert layer.visible is False layer.visible = True assert layer.visible is True def test_opacity(): """Test setting layer opacity.""" np.random.seed(0) data = 20 * np.random.random((10, 2)) layer = Points(data) assert layer.opacity == 1.0 layer.opacity = 0.5 assert layer.opacity == 0.5 layer = Points(data, opacity=0.6) assert layer.opacity == 0.6 layer.opacity = 0.3 assert layer.opacity == 0.3 def test_blending(): """Test setting layer blending.""" np.random.seed(0) data = 20 * np.random.random((10, 2)) layer = Points(data) assert layer.blending == 'translucent' layer.blending = 'additive' assert layer.blending == 'additive' layer = Points(data, blending='additive') assert layer.blending == 'additive' layer.blending = 'opaque' assert layer.blending == 'opaque' def test_symbol(): """Test setting symbol.""" shape = (10, 2) np.random.seed(0) data = 20 * np.random.random(shape) layer = Points(data) assert np.all(layer.symbol == 'disc') layer.symbol = 'cross' assert np.all(layer.symbol == 'cross') symbol = ['o', 's'] * 5 expected = ['disc', 'square'] * 5 layer.symbol = symbol assert np.all(layer.symbol == expected) layer = Points(data, symbol='star') assert np.all(layer.symbol == 'star') properties_array = {'point_type': _make_cycled_properties(['A', 'B'], 10)} properties_list = {'point_type': list(_make_cycled_properties(['A', 'B'], 10))} @pytest.mark.parametrize("properties", [properties_array, properties_list]) def test_properties(properties): shape = (10, 2) np.random.seed(0) data = 20 * np.random.random(shape) layer = Points(data, properties=copy(properties)) np.testing.assert_equal(layer.properties, properties) current_prop = {'point_type': np.array(['B'])} assert layer.current_properties == current_prop # test removing points layer.selected_data = {0, 1} layer.remove_selected() remove_properties = properties['point_type'][2::] assert len(layer.properties['point_type']) == (shape[0] - 2) assert np.all(layer.properties['point_type'] == remove_properties) # test selection of properties layer.selected_data = {0} selected_annotation = layer.current_properties['point_type'] assert len(selected_annotation) == 1 assert selected_annotation[0] == 'A' # test adding points with properties layer.add([10, 10]) add_annotations = np.concatenate((remove_properties, ['A']), axis=0) assert np.all(layer.properties['point_type'] == add_annotations) # test copy/paste layer.selected_data = {0, 1} layer._copy_data() assert np.all(layer._clipboard['features']['point_type'] == ['A', 'B']) layer._paste_data() paste_annotations = np.concatenate((add_annotations, ['A', 'B']), axis=0) assert np.all(layer.properties['point_type'] == paste_annotations) assert layer.get_status(data[0])['coordinates'].endswith("point_type: B") assert layer.get_status(data[1])['coordinates'].endswith("point_type: A") @pytest.mark.parametrize("attribute", ['edge', 'face']) def test_adding_properties(attribute): """Test adding properties to an existing layer""" shape = (10, 2) np.random.seed(0) data = 20 * np.random.random(shape) layer = Points(data) # add properties properties = {'point_type': _make_cycled_properties(['A', 'B'], shape[0])} layer.properties = properties np.testing.assert_equal(layer.properties, properties) # add properties as a dataframe properties_df = pd.DataFrame(properties) layer.properties = properties_df np.testing.assert_equal(layer.properties, properties) # add properties as a dictionary with list values properties_list = { 'point_type': list(_make_cycled_properties(['A', 'B'], shape[0])) } layer.properties = properties_list assert isinstance(layer.properties['point_type'], np.ndarray) # removing a property that was the _*_color_property should give a warning color_manager = getattr(layer, f'_{attribute}') color_manager.color_properties = { 'name': 'point_type', 'values': np.empty(0), 'current_value': 'A', } properties_2 = { 'not_point_type': _make_cycled_properties(['A', 'B'], shape[0]) } with pytest.warns(RuntimeWarning): layer.properties = properties_2 def test_properties_dataframe(): """Test if properties can be provided as a DataFrame""" shape = (10, 2) np.random.seed(0) data = 20 * np.random.random(shape) properties = {'point_type': _make_cycled_properties(['A', 'B'], shape[0])} properties_df = pd.DataFrame(properties) properties_df = properties_df.astype(properties['point_type'].dtype) layer = Points(data, properties=properties_df) np.testing.assert_equal(layer.properties, properties) def test_add_points_with_properties_as_list(): # test adding points initialized with properties as list shape = (10, 2) np.random.seed(0) data = 20 * np.random.random(shape) properties = { 'point_type': list(_make_cycled_properties(['A', 'B'], shape[0])) } layer = Points(data, properties=copy(properties)) coord = [18, 18] layer.add(coord) new_prop = {'point_type': np.append(properties['point_type'], 'B')} np.testing.assert_equal(layer.properties, new_prop) def test_updating_points_properties(): # test adding points initialized with properties shape = (10, 2) np.random.seed(0) data = 20 * np.random.random(shape) properties = {'point_type': _make_cycled_properties(['A', 'B'], shape[0])} layer = Points(data, properties=copy(properties)) layer.mode = 'select' layer.selected_data = [len(data) - 1] layer.current_properties = {'point_type': np.array(['A'])} updated_properties = properties updated_properties['point_type'][-1] = 'A' np.testing.assert_equal(layer.properties, updated_properties) def test_setting_current_properties(): shape = (2, 2) np.random.seed(0) data = 20 * np.random.random(shape) properties = { 'annotation': ['paw', 'leg'], 'confidence': [0.5, 0.75], 'annotator': ['jane', 'ash'], 'model': ['worst', 'best'], } layer = Points(data, properties=copy(properties)) current_properties = { 'annotation': ['leg'], 'confidence': 1, 'annotator': 'ash', 'model': np.array(['best']), } layer.current_properties = current_properties expected_current_properties = { 'annotation': np.array(['leg']), 'confidence': np.array([1]), 'annotator': np.array(['ash']), 'model': np.array(['best']), } coerced_current_properties = layer.current_properties for k in coerced_current_properties: value = coerced_current_properties[k] assert isinstance(value, np.ndarray) np.testing.assert_equal(value, expected_current_properties[k]) properties_array = {'point_type': _make_cycled_properties(['A', 'B'], 10)} properties_list = {'point_type': list(_make_cycled_properties(['A', 'B'], 10))} @pytest.mark.parametrize("properties", [properties_array, properties_list]) def test_text_from_property_value(properties): """Test setting text from a property value""" shape = (10, 2) np.random.seed(0) data = 20 * np.random.random(shape) layer = Points(data, properties=copy(properties), text='point_type') np.testing.assert_equal(layer.text.values, properties['point_type']) @pytest.mark.parametrize("properties", [properties_array, properties_list]) def test_text_from_property_fstring(properties): """Test setting text with an f-string from the property value""" shape = (10, 2) np.random.seed(0) data = 20 * np.random.random(shape) layer = Points( data, properties=copy(properties), text='type: {point_type}' ) expected_text = ['type: ' + v for v in properties['point_type']] np.testing.assert_equal(layer.text.values, expected_text) # test updating the text layer.text = 'type-ish: {point_type}' expected_text_2 = ['type-ish: ' + v for v in properties['point_type']] np.testing.assert_equal(layer.text.values, expected_text_2) # copy/paste layer.selected_data = {0} layer._copy_data() layer._paste_data() expected_text_3 = expected_text_2 + ['type-ish: A'] np.testing.assert_equal(layer.text.values, expected_text_3) # add point layer.selected_data = {0} new_shape = np.random.random((1, 2)) layer.add(new_shape) expected_text_4 = expected_text_3 + ['type-ish: A'] np.testing.assert_equal(layer.text.values, expected_text_4) @pytest.mark.parametrize("properties", [properties_array, properties_list]) def test_set_text_with_kwarg_dict(properties): text_kwargs = { 'string': 'type: {point_type}', 'color': ConstantColorEncoding(constant=[0, 0, 0, 1]), 'rotation': 10, 'translation': [5, 5], 'anchor': Anchor.UPPER_LEFT, 'size': 10, 'visible': True, } shape = (10, 2) np.random.seed(0) data = 20 * np.random.random(shape) layer = Points(data, properties=copy(properties), text=text_kwargs) expected_text = ['type: ' + v for v in properties['point_type']] np.testing.assert_equal(layer.text.values, expected_text) for property, value in text_kwargs.items(): if property == 'string': continue layer_value = getattr(layer._text, property) np.testing.assert_equal(layer_value, value) @pytest.mark.parametrize("properties", [properties_array, properties_list]) def test_text_error(properties): """creating a layer with text as the wrong type should raise an error""" shape = (10, 2) np.random.seed(0) data = 20 * np.random.random(shape) # try adding text as the wrong type with pytest.raises(ValidationError): Points(data, properties=copy(properties), text=123) def test_select_properties_object_dtype(): """selecting points when they have a property of object dtype should not fail""" # pandas uses object as dtype for strings by default properties = pd.DataFrame({'color': ['red', 'green']}) pl = Points(np.ones((2, 2)), properties=properties) selection = {0, 1} pl.selected_data = selection assert pl.selected_data == selection def test_select_properties_unsortable(): """selecting multiple points when they have properties that cannot be sorted should not fail see https://github.com/napari/napari/issues/5174 """ properties = pd.DataFrame({'unsortable': [{}, {}]}) pl = Points(np.ones((2, 2)), properties=properties) selection = {0, 1} pl.selected_data = selection assert pl.selected_data == selection def test_refresh_text(): """Test refreshing the text after setting new properties""" shape = (10, 2) np.random.seed(0) data = 20 * np.random.random(shape) properties = {'point_type': ['A'] * shape[0]} layer = Points(data, properties=copy(properties), text='point_type') new_properties = {'point_type': ['B'] * shape[0]} layer.properties = new_properties np.testing.assert_equal(layer.text.values, new_properties['point_type']) def test_points_errors(): shape = (3, 2) np.random.seed(0) data = 20 * np.random.random(shape) # try adding properties with the wrong number of properties with pytest.raises(ValueError): annotations = {'point_type': np.array(['A', 'B'])} Points(data, properties=copy(annotations)) def test_edge_width(): """Test setting edge width.""" shape = (10, 2) np.random.seed(0) data = 20 * np.random.random(shape) layer = Points(data) np.testing.assert_array_equal(layer.edge_width, 0.05) layer.edge_width = 0.5 np.testing.assert_array_equal(layer.edge_width, 0.5) # fail outside of range 0, 1 if relative is enabled (default) with pytest.raises(ValueError): layer.edge_width = 2 layer.edge_width_is_relative = False layer.edge_width = 2 np.testing.assert_array_equal(layer.edge_width, 2) # fail if we try to come back again with pytest.raises(ValueError): layer.edge_width_is_relative = True # all should work on instantiation too layer = Points(data, edge_width=3, edge_width_is_relative=False) np.testing.assert_array_equal(layer.edge_width, 3) assert layer.edge_width_is_relative is False with pytest.raises(ValueError): layer.edge_width = -2 @pytest.mark.parametrize( "edge_width", [int(1), float(1), np.array([1, 2, 3, 4, 5]), [1, 2, 3, 4, 5]], ) def test_edge_width_types(edge_width): """Test edge_width dtypes with valid values""" shape = (5, 2) np.random.seed(0) data = 20 * np.random.random(shape) layer = Points(data, edge_width=edge_width, edge_width_is_relative=False) np.testing.assert_array_equal(layer.edge_width, edge_width) @pytest.mark.parametrize( "edge_width", [int(-1), float(-1), np.array([-1, 2, 3, 4, 5]), [-1, 2, 3, 4, 5]], ) def test_edge_width_types_negative(edge_width): """Test negative values in all edge_width dtypes""" shape = (5, 2) np.random.seed(0) data = 20 * np.random.random(shape) with pytest.raises(ValueError): Points(data, edge_width=edge_width, edge_width_is_relative=False) def test_out_of_slice_display(): """Test setting out_of_slice_display flag for 2D and 4D data.""" shape = (10, 2) np.random.seed(0) data = 20 * np.random.random(shape) layer = Points(data) assert layer.out_of_slice_display is False layer.out_of_slice_display = True assert layer.out_of_slice_display is True layer = Points(data, out_of_slice_display=True) assert layer.out_of_slice_display is True shape = (10, 4) data = 20 * np.random.random(shape) layer = Points(data) assert layer.out_of_slice_display is False layer.out_of_slice_display = True assert layer.out_of_slice_display is True layer = Points(data, out_of_slice_display=True) assert layer.out_of_slice_display is True @pytest.mark.parametrize("attribute", ['edge', 'face']) def test_switch_color_mode(attribute): """Test switching between color modes""" shape = (10, 2) np.random.seed(0) data = 20 * np.random.random(shape) # create a continuous property with a known value in the last element continuous_prop = np.random.random((shape[0],)) continuous_prop[-1] = 1 properties = { 'point_truthiness': continuous_prop, 'point_type': _make_cycled_properties(['A', 'B'], shape[0]), } initial_color = [1, 0, 0, 1] color_cycle = ['red', 'blue'] color_kwarg = f'{attribute}_color' colormap_kwarg = f'{attribute}_colormap' color_cycle_kwarg = f'{attribute}_color_cycle' args = { color_kwarg: initial_color, colormap_kwarg: 'gray', color_cycle_kwarg: color_cycle, } layer = Points(data, properties=properties, **args) layer_color_mode = getattr(layer, f'{attribute}_color_mode') layer_color = getattr(layer, f'{attribute}_color') assert layer_color_mode == 'direct' np.testing.assert_allclose( layer_color, np.repeat([initial_color], shape[0], axis=0) ) # there should not be an edge_color_property color_manager = getattr(layer, f'_{attribute}') color_property = color_manager.color_properties assert color_property is None # transitioning to colormap should raise a warning # because there isn't an edge color property yet and # the first property in points.properties is being automatically selected with pytest.warns(UserWarning): setattr(layer, f'{attribute}_color_mode', 'colormap') color_manager = getattr(layer, f'_{attribute}') color_property_name = color_manager.color_properties.name assert color_property_name == next(iter(properties)) layer_color = getattr(layer, f'{attribute}_color') np.testing.assert_allclose(layer_color[-1], [1, 1, 1, 1]) # switch to color cycle setattr(layer, f'{attribute}_color_mode', 'cycle') setattr(layer, f'{attribute}_color', 'point_type') color = getattr(layer, f'{attribute}_color') layer_color = transform_color(color_cycle * int(shape[0] / 2)) np.testing.assert_allclose(color, layer_color) # switch back to direct, edge_colors shouldn't change setattr(layer, f'{attribute}_color_mode', 'direct') new_edge_color = getattr(layer, f'{attribute}_color') np.testing.assert_allclose(new_edge_color, color) @pytest.mark.parametrize("attribute", ['edge', 'face']) def test_colormap_without_properties(attribute): """Setting the colormode to colormap should raise an exception""" shape = (10, 2) np.random.seed(0) data = 20 * np.random.random(shape) layer = Points(data) with pytest.raises(ValueError): setattr(layer, f'{attribute}_color_mode', 'colormap') @pytest.mark.parametrize("attribute", ['edge', 'face']) def test_colormap_with_categorical_properties(attribute): """Setting the colormode to colormap should raise an exception""" shape = (10, 2) np.random.seed(0) data = 20 * np.random.random(shape) properties = {'point_type': _make_cycled_properties(['A', 'B'], shape[0])} layer = Points(data, properties=properties) with pytest.raises(TypeError): with pytest.warns(UserWarning): setattr(layer, f'{attribute}_color_mode', 'colormap') @pytest.mark.parametrize("attribute", ['edge', 'face']) def test_add_colormap(attribute): """Test directly adding a vispy Colormap object""" shape = (10, 2) np.random.seed(0) data = 20 * np.random.random(shape) annotations = {'point_type': _make_cycled_properties([0, 1.5], shape[0])} color_kwarg = f'{attribute}_color' colormap_kwarg = f'{attribute}_colormap' args = {color_kwarg: 'point_type', colormap_kwarg: 'viridis'} layer = Points(data, properties=annotations, **args) setattr(layer, f'{attribute}_colormap', get_colormap('gray')) layer_colormap = getattr(layer, f'{attribute}_colormap') assert 'unnamed colormap' in layer_colormap.name @pytest.mark.parametrize("attribute", ['edge', 'face']) def test_add_point_direct(attribute: str): """Test adding points to layer directly""" layer = Points() assert len(getattr(layer, f'{attribute}_color')) == 0 setattr(layer, f'current_{attribute}_color', 'red') coord = [18, 18] layer.add(coord) np.testing.assert_allclose( [[1, 0, 0, 1]], getattr(layer, f'{attribute}_color') ) @pytest.mark.parametrize("attribute", ['edge', 'face']) def test_color_direct(attribute: str): """Test setting colors directly""" shape = (10, 2) np.random.seed(0) data = 20 * np.random.random(shape) layer_kwargs = {f'{attribute}_color': 'black'} layer = Points(data, **layer_kwargs) color_array = transform_color(['black'] * shape[0]) current_color = getattr(layer, f'current_{attribute}_color') layer_color = getattr(layer, f'{attribute}_color') assert current_color == 'black' assert len(layer.edge_color) == shape[0] np.testing.assert_allclose(color_array, layer_color) # With no data selected changing color has no effect setattr(layer, f'current_{attribute}_color', 'blue') current_color = getattr(layer, f'current_{attribute}_color') assert current_color == 'blue' np.testing.assert_allclose(color_array, layer_color) # Select data and change edge color of selection selected_data = {0, 1} layer.selected_data = {0, 1} current_color = getattr(layer, f'current_{attribute}_color') assert current_color == 'black' setattr(layer, f'current_{attribute}_color', 'green') colorarray_green = transform_color(['green'] * len(layer.selected_data)) color_array[list(selected_data)] = colorarray_green layer_color = getattr(layer, f'{attribute}_color') np.testing.assert_allclose(color_array, layer_color) # Add new point and test its color coord = [18, 18] layer.selected_data = {} setattr(layer, f'current_{attribute}_color', 'blue') layer.add(coord) color_array = np.vstack([color_array, transform_color('blue')]) layer_color = getattr(layer, f'{attribute}_color') assert len(layer_color) == shape[0] + 1 np.testing.assert_allclose(color_array, layer_color) # Check removing data adjusts colors correctly layer.selected_data = {0, 2} layer.remove_selected() assert len(layer.data) == shape[0] - 1 layer_color = getattr(layer, f'{attribute}_color') assert len(layer_color) == shape[0] - 1 np.testing.assert_allclose( layer_color, np.vstack((color_array[1], color_array[3:])), ) color_cycle_str = ['red', 'blue'] color_cycle_rgb = [[1, 0, 0], [0, 0, 1]] color_cycle_rgba = [[1, 0, 0, 1], [0, 0, 1, 1]] @pytest.mark.parametrize("attribute", ['edge', 'face']) @pytest.mark.parametrize( "color_cycle", [color_cycle_str, color_cycle_rgb, color_cycle_rgba], ) def test_color_cycle(attribute, color_cycle): """Test setting edge/face color with a color cycle list""" # create Points using list color cycle shape = (10, 2) np.random.seed(0) data = 20 * np.random.random(shape) properties = {'point_type': _make_cycled_properties(['A', 'B'], shape[0])} points_kwargs = { 'properties': properties, f'{attribute}_color': 'point_type', f'{attribute}_color_cycle': color_cycle, } layer = Points(data, **points_kwargs) np.testing.assert_equal(layer.properties, properties) color_array = transform_color( list(islice(cycle(color_cycle), 0, shape[0])) ) layer_color = getattr(layer, f'{attribute}_color') np.testing.assert_allclose(layer_color, color_array) # Add new point and test its color coord = [18, 18] layer.selected_data = {0} layer.add(coord) layer_color = getattr(layer, f'{attribute}_color') assert len(layer_color) == shape[0] + 1 np.testing.assert_allclose( layer_color, np.vstack((color_array, transform_color('red'))), ) # Check removing data adjusts colors correctly layer.selected_data = {0, 2} layer.remove_selected() assert len(layer.data) == shape[0] - 1 layer_color = getattr(layer, f'{attribute}_color') assert len(layer_color) == shape[0] - 1 np.testing.assert_allclose( layer_color, np.vstack((color_array[1], color_array[3:], transform_color('red'))), ) # test adding a point with a new property value layer.selected_data = {} current_properties = layer.current_properties current_properties['point_type'] = np.array(['new']) layer.current_properties = current_properties layer.add([10, 10]) color_manager = getattr(layer, f'_{attribute}') color_cycle_map = color_manager.categorical_colormap.colormap assert 'new' in color_cycle_map np.testing.assert_allclose( color_cycle_map['new'], np.squeeze(transform_color(color_cycle[0])) ) @pytest.mark.parametrize("attribute", ['edge', 'face']) def test_color_cycle_dict(attribute): """Test setting edge/face color with a color cycle dict""" data = np.array([[0, 0], [100, 0], [0, 100]]) properties = {'my_colors': [2, 6, 3]} points_kwargs = { 'properties': properties, f'{attribute}_color': 'my_colors', f'{attribute}_color_cycle': {1: 'green', 2: 'red', 3: 'blue'}, } layer = Points(data, **points_kwargs) color_manager = getattr(layer, f'_{attribute}') color_cycle_map = color_manager.categorical_colormap.colormap np.testing.assert_allclose(color_cycle_map[2], [1, 0, 0, 1]) # 2 is red np.testing.assert_allclose(color_cycle_map[3], [0, 0, 1, 1]) # 3 is blue np.testing.assert_allclose(color_cycle_map[6], [1, 1, 1, 1]) # 6 is white @pytest.mark.parametrize("attribute", ['edge', 'face']) def test_add_color_cycle_to_empty_layer(attribute): """Test adding a point to an empty layer when edge/face color is a color cycle See: https://github.com/napari/napari/pull/1069 """ default_properties = {'point_type': np.array(['A'])} color_cycle = ['red', 'blue'] points_kwargs = { 'property_choices': default_properties, f'{attribute}_color': 'point_type', f'{attribute}_color_cycle': color_cycle, } layer = Points(**points_kwargs) # verify the current_edge_color is correct expected_color = transform_color(color_cycle[0])[0] color_manager = getattr(layer, f'_{attribute}') current_color = color_manager.current_color np.testing.assert_allclose(current_color, expected_color) # add a point layer.add([10, 10]) props = {'point_type': np.array(['A'])} expected_color = np.array([[1, 0, 0, 1]]) np.testing.assert_equal(layer.properties, props) attribute_color = getattr(layer, f'{attribute}_color') np.testing.assert_allclose(attribute_color, expected_color) # add a point with a new property layer.selected_data = [] layer.current_properties = {'point_type': np.array(['B'])} layer.add([12, 12]) new_color = np.array([0, 0, 1, 1]) expected_color = np.vstack((expected_color, new_color)) new_properties = {'point_type': np.array(['A', 'B'])} attribute_color = getattr(layer, f'{attribute}_color') np.testing.assert_allclose(attribute_color, expected_color) np.testing.assert_equal(layer.properties, new_properties) @pytest.mark.parametrize("attribute", ['edge', 'face']) def test_adding_value_color_cycle(attribute): """Test that adding values to properties used to set a color cycle and then calling Points.refresh_colors() performs the update and adds the new value to the face/edge_color_cycle_map. See: https://github.com/napari/napari/issues/988 """ shape = (10, 2) np.random.seed(0) data = 20 * np.random.random(shape) properties = {'point_type': _make_cycled_properties(['A', 'B'], shape[0])} color_cycle = ['red', 'blue'] points_kwargs = { 'properties': properties, f'{attribute}_color': 'point_type', f'{attribute}_color_cycle': color_cycle, } layer = Points(data, **points_kwargs) # make point 0 point_type C props = layer.properties point_types = props['point_type'] point_types[0] = 'C' props['point_type'] = point_types layer.properties = props color_manager = getattr(layer, f'_{attribute}') color_cycle_map = color_manager.categorical_colormap.colormap color_map_keys = [*color_cycle_map] assert 'C' in color_map_keys @pytest.mark.parametrize("attribute", ['edge', 'face']) def test_color_colormap(attribute): """Test setting edge/face color with a colormap""" # create Points using with a colormap shape = (10, 2) np.random.seed(0) data = 20 * np.random.random(shape) properties = {'point_type': _make_cycled_properties([0, 1.5], shape[0])} points_kwargs = { 'properties': properties, f'{attribute}_color': 'point_type', f'{attribute}_colormap': 'gray', } layer = Points(data, **points_kwargs) np.testing.assert_equal(layer.properties, properties) color_mode = getattr(layer, f'{attribute}_color_mode') assert color_mode == 'colormap' color_array = transform_color(['black', 'white'] * int(shape[0] / 2)) attribute_color = getattr(layer, f'{attribute}_color') assert np.all(attribute_color == color_array) # change the color cycle - face_color should not change setattr(layer, f'{attribute}_color_cycle', ['red', 'blue']) attribute_color = getattr(layer, f'{attribute}_color') assert np.all(attribute_color == color_array) # Add new point and test its color coord = [18, 18] layer.selected_data = {0} layer.add(coord) attribute_color = getattr(layer, f'{attribute}_color') assert len(attribute_color) == shape[0] + 1 np.testing.assert_allclose( attribute_color, np.vstack((color_array, transform_color('black'))), ) # Check removing data adjusts colors correctly layer.selected_data = {0, 2} layer.remove_selected() assert len(layer.data) == shape[0] - 1 attribute_color = getattr(layer, f'{attribute}_color') assert len(attribute_color) == shape[0] - 1 np.testing.assert_allclose( attribute_color, np.vstack( ( color_array[1], color_array[3:], transform_color('black'), ) ), ) # adjust the clims setattr(layer, f'{attribute}_contrast_limits', (0, 3)) attribute_color = getattr(layer, f'{attribute}_color') np.testing.assert_allclose(attribute_color[-2], [0.5, 0.5, 0.5, 1]) # change the colormap new_colormap = 'viridis' setattr(layer, f'{attribute}_colormap', new_colormap) attribute_colormap = getattr(layer, f'{attribute}_colormap') assert attribute_colormap.name == new_colormap def test_size(): """Test setting size with scalar.""" shape = (10, 2) np.random.seed(0) data = 20 * np.random.random(shape) layer = Points(data) assert layer.current_size == 10 assert layer.size.shape == shape assert np.unique(layer.size)[0] == 10 # Add a new point, it should get current size coord = [17, 17] layer.add(coord) assert layer.size.shape == (11, 2) assert np.unique(layer.size)[0] == 10 # Setting size affects newly added points not current points layer.current_size = 20 assert layer.current_size == 20 assert layer.size.shape == (11, 2) assert np.unique(layer.size)[0] == 10 # Add new point, should have new size coord = [18, 18] layer.add(coord) assert layer.size.shape == (12, 2) assert np.unique(layer.size[:11])[0] == 10 assert np.all(layer.size[11] == [20, 20]) # Select data and change size layer.selected_data = {0, 1} assert layer.current_size == 10 layer.current_size = 16 assert layer.size.shape == (12, 2) assert np.unique(layer.size[2:11])[0] == 10 assert np.unique(layer.size[:2])[0] == 16 # Select data and size changes layer.selected_data = {11} assert layer.current_size == 20 def test_size_with_arrays(): """Test setting size with arrays.""" shape = (10, 2) np.random.seed(0) data = 20 * np.random.random(shape) layer = Points(data) sizes = 5 * np.random.random(shape) layer.size = sizes assert np.all(layer.size == sizes) # Test broadcasting of sizes sizes = [5, 5] layer.size = sizes assert np.all(layer.size[0] == sizes) # Test broadcasting of transposed sizes sizes = np.random.randint(low=1, high=5, size=shape[::-1]) layer.size = sizes np.testing.assert_equal(layer.size, sizes.T) # Un-broadcastable array should raise an exception bad_sizes = np.random.randint(low=1, high=5, size=(3, 8)) with pytest.raises(ValueError): layer.size = bad_sizes # Create new layer with new size array data sizes = 5 * np.random.random(shape) layer = Points(data, size=sizes) assert layer.current_size == 10 assert layer.size.shape == shape assert np.all(layer.size == sizes) # Create new layer with new size array data sizes = [5, 5] layer = Points(data, size=sizes) assert layer.current_size == 10 assert layer.size.shape == shape assert np.all(layer.size[0] == sizes) # Add new point, should have new size coord = [18, 18] layer.current_size = 13 layer.add(coord) assert layer.size.shape == (11, 2) assert np.unique(layer.size[:10])[0] == 5 assert np.all(layer.size[10] == [13, 13]) # Select data and change size layer.selected_data = {0, 1} assert layer.current_size == 5 layer.current_size = 16 assert layer.size.shape == (11, 2) assert np.unique(layer.size[2:10])[0] == 5 assert np.unique(layer.size[:2])[0] == 16 # Check removing data adjusts colors correctly layer.selected_data = {0, 2} layer.remove_selected() assert len(layer.data) == 9 assert len(layer.size) == 9 assert np.all(layer.size[0] == [16, 16]) assert np.all(layer.size[1] == [5, 5]) def test_size_with_3D_arrays(): """Test setting size with 3D arrays.""" shape = (10, 3) np.random.seed(0) data = 20 * np.random.random(shape) data[:2, 0] = 0 layer = Points(data) assert layer.current_size == 10 assert layer.size.shape == shape assert np.unique(layer.size)[0] == 10 sizes = 5 * np.random.random(shape) layer.size = sizes assert np.all(layer.size == sizes) # Test broadcasting of sizes sizes = [1, 5, 5] layer.size = sizes assert np.all(layer.size[0] == sizes) # Create new layer with new size array data sizes = 5 * np.random.random(shape) layer = Points(data, size=sizes) assert layer.current_size == 10 assert layer.size.shape == shape assert np.all(layer.size == sizes) # Create new layer with new size array data sizes = [1, 5, 5] layer = Points(data, size=sizes) assert layer.current_size == 10 assert layer.size.shape == shape assert np.all(layer.size[0] == sizes) # Add new point, should have new size in last dim only coord = [4, 18, 18] layer.current_size = 13 layer.add(coord) assert layer.size.shape == (11, 3) assert np.unique(layer.size[:10, 1:])[0] == 5 assert np.all(layer.size[10] == [1, 13, 13]) # Select data and change size layer.selected_data = {0, 1} assert layer.current_size == 5 layer.current_size = 16 assert layer.size.shape == (11, 3) assert np.unique(layer.size[2:10, 1:])[0] == 5 assert np.all(layer.size[0] == [16, 16, 16]) # Create new 3D layer with new 2D points size data sizes = [0, 5, 5] layer = Points(data, size=sizes) assert layer.current_size == 10 assert layer.size.shape == shape assert np.all(layer.size[0] == sizes) # Add new point, should have new size only in last 2 dimensions coord = [4, 18, 18] layer.current_size = 13 layer.add(coord) assert layer.size.shape == (11, 3) assert np.all(layer.size[10] == [0, 13, 13]) # Select data and change size layer.selected_data = {0, 1} assert layer.current_size == 5 layer.current_size = 16 assert layer.size.shape == (11, 3) assert np.unique(layer.size[2:10, 1:])[0] == 5 assert np.all(layer.size[0] == [0, 16, 16]) def test_copy_and_paste(): """Test copying and pasting selected points.""" shape = (10, 2) np.random.seed(0) data = 20 * np.random.random(shape) layer = Points(data) # Clipboard starts empty assert layer._clipboard == {} # Pasting empty clipboard doesn't change data layer._paste_data() assert len(layer.data) == 10 # Copying with nothing selected leave clipboard empty layer._copy_data() assert layer._clipboard == {} # Copying and pasting with two points selected adds to clipboard and data layer.selected_data = {0, 1} layer._copy_data() layer._paste_data() assert len(layer._clipboard.keys()) > 0 assert len(layer.data) == shape[0] + 2 assert np.all(layer.data[:2] == layer.data[-2:]) # Pasting again adds two more points to data layer._paste_data() assert len(layer.data) == shape[0] + 4 assert np.all(layer.data[:2] == layer.data[-2:]) # Unselecting everything and copying and pasting will empty the clipboard # and add no new data layer.selected_data = {} layer._copy_data() layer._paste_data() assert layer._clipboard == {} assert len(layer.data) == shape[0] + 4 def test_value(): """Test getting the value of the data at the current coordinates.""" shape = (10, 2) np.random.seed(0) data = 20 * np.random.random(shape) data[-1] = [0, 0] layer = Points(data) value = layer.get_value((0, 0)) assert value == 9 layer.data = layer.data + 20 value = layer.get_value((0, 0)) assert value is None @pytest.mark.parametrize( 'position,view_direction,dims_displayed,world,scale,expected', [ ((0, 5, 15, 15), [0, 1, 0, 0], [1, 2, 3], False, (1, 1, 1, 1), 2), ((0, 5, 15, 15), [0, -1, 0, 0], [1, 2, 3], False, (1, 1, 1, 1), 0), ((0, 5, 0, 0), [0, 1, 0, 0], [1, 2, 3], False, (1, 1, 1, 1), None), ((0, 5, 15, 15), [0, 1, 0, 0], [1, 2, 3], True, (1, 1, 2, 1), None), ((0, 5, 15, 15), [0, -1, 0, 0], [1, 2, 3], True, (1, 1, 2, 1), None), ((0, 5, 30, 15), [0, 1, 0, 0], [1, 2, 3], True, (1, 1, 2, 1), 2), ((0, 5, 30, 15), [0, -1, 0, 0], [1, 2, 3], True, (1, 1, 2, 1), 0), ((0, 5, 0, 0), [0, 1, 0, 0], [1, 2, 3], True, (1, 1, 2, 1), None), ], ) def test_value_3d( position, view_direction, dims_displayed, world, scale, expected ): """Test get_value in 3D with and without scale""" data = np.array([[0, 10, 15, 15], [0, 10, 5, 5], [0, 5, 15, 15]]) layer = Points(data, size=5, scale=scale) layer._slice_dims([0, 0, 0, 0], ndisplay=3) value = layer.get_value( position, view_direction=view_direction, dims_displayed=dims_displayed, world=world, ) if expected is None: assert value is None else: assert value == expected def test_message(): """Test converting value and coords to message.""" shape = (10, 2) np.random.seed(0) data = 20 * np.random.random(shape) data[-1] = [0, 0] layer = Points(data) msg = layer.get_status((0,) * 2) assert type(msg) == dict def test_message_3d(): """Test converting values and coords to message in 3D.""" shape = (10, 3) np.random.seed(0) data = 20 * np.random.random(shape) layer = Points(data) msg = layer.get_status( (0, 0, 0), view_direction=[1, 0, 0], dims_displayed=[0, 1, 2] ) assert type(msg) == dict def test_thumbnail(): """Test the image thumbnail for square data.""" shape = (10, 2) np.random.seed(0) data = 20 * np.random.random(shape) data[0] = [0, 0] data[-1] = [20, 20] layer = Points(data) layer._update_thumbnail() assert layer.thumbnail.shape == layer._thumbnail_shape def test_thumbnail_non_square_data(): """Test the image thumbnail for non-square data. See: https://github.com/napari/napari/issues/1450 """ # The points coordinates are in a short and wide range. data_range = [1, 32] np.random.seed(0) data = np.random.random((10, 2)) * data_range # Make sure the random points span the range. data[0, :] = [0, 0] data[-1, :] = data_range layer = Points(data) layer._update_thumbnail() assert layer.thumbnail.shape == layer._thumbnail_shape # Check that the thumbnail only contains non-zero RGB values in the middle two rows. mid_row = layer.thumbnail.shape[0] // 2 expected_zeros = np.zeros(shape=(mid_row - 1, 32, 3), dtype=np.uint8) np.testing.assert_array_equal( layer.thumbnail[: mid_row - 1, :, :3], expected_zeros ) assert ( np.count_nonzero(layer.thumbnail[mid_row - 1 : mid_row + 1, :, :3]) > 0 ) np.testing.assert_array_equal( layer.thumbnail[mid_row + 1 :, :, :3], expected_zeros ) def test_thumbnail_with_n_points_greater_than_max(): """Test thumbnail generation with n_points > _max_points_thumbnail see: https://github.com/napari/napari/pull/934 """ # 2D max_points = Points._max_points_thumbnail * 2 bigger_data = np.random.randint(10, 100, (max_points, 2)) big_layer = Points(bigger_data) big_layer._update_thumbnail() assert big_layer.thumbnail.shape == big_layer._thumbnail_shape # #3D bigger_data_3d = np.random.randint(10, 100, (max_points, 3)) bigger_layer_3d = Points(bigger_data_3d) bigger_layer_3d._slice_dims(ndisplay=3) bigger_layer_3d._update_thumbnail() assert bigger_layer_3d.thumbnail.shape == bigger_layer_3d._thumbnail_shape def test_view_data(): coords = np.array([[0, 1, 1], [0, 2, 2], [1, 3, 3], [3, 3, 3]]) layer = Points(coords) layer._slice_dims([0, slice(None), slice(None)]) assert np.all(layer._view_data == coords[np.ix_([0, 1], [1, 2])]) layer._slice_dims([1, slice(None), slice(None)]) assert np.all(layer._view_data == coords[np.ix_([2], [1, 2])]) layer._slice_dims([1, slice(None), slice(None)], ndisplay=3) assert np.all(layer._view_data == coords) def test_view_size(): """Test out of slice point rendering and slicing with no points.""" coords = np.array([[0, 1, 1], [0, 2, 2], [1, 3, 3], [3, 3, 3]]) sizes = np.array([[3, 5, 5], [3, 5, 5], [3, 3, 3], [2, 2, 3]]) layer = Points(coords, size=sizes, out_of_slice_display=False) layer._slice_dims([0, slice(None), slice(None)]) assert np.all(layer._view_size == sizes[np.ix_([0, 1], [1, 2])]) layer._slice_dims([1, slice(None), slice(None)]) assert np.all(layer._view_size == sizes[np.ix_([2], [1, 2])]) layer.out_of_slice_display = True assert len(layer._view_size) == 3 # test a slice with no points layer.out_of_slice_display = False layer._slice_dims([2, slice(None), slice(None)]) assert np.all(layer._view_size == []) def test_view_colors(): coords = [[0, 1, 1], [0, 2, 2], [1, 3, 3], [3, 3, 3]] face_color = np.array( [[1, 0, 0, 1], [0, 1, 0, 1], [0, 0, 1, 1], [0, 0, 1, 1]] ) edge_color = np.array( [[0, 0, 1, 1], [1, 0, 0, 1], [0, 1, 0, 1], [0, 0, 1, 1]] ) layer = Points(coords, face_color=face_color, edge_color=edge_color) layer._slice_dims([0, slice(None), slice(None)]) assert np.all(layer._view_face_color == face_color[[0, 1]]) assert np.all(layer._view_edge_color == edge_color[[0, 1]]) layer._slice_dims([1, slice(None), slice(None)]) assert np.all(layer._view_face_color == face_color[[2]]) assert np.all(layer._view_edge_color == edge_color[[2]]) # view colors should return empty array if there are no points layer._slice_dims([2, slice(None), slice(None)]) assert len(layer._view_face_color) == 0 assert len(layer._view_edge_color) == 0 def test_interaction_box(): """Test the boxes calculated for selected points""" data = [[3, 3]] size = 2 layer = Points(data, size=size) # get a box with no points selected index = [] box = layer.interaction_box(index) assert box is None # get a box with a point selected index = [0] expected_box = points_to_squares(data, size) box = layer.interaction_box(index) np.all([np.isin(p, expected_box) for p in box]) def test_world_data_extent(): """Test extent after applying transforms.""" data = [(7, -5, 0), (-2, 0, 15), (4, 30, 12)] min_val = (-2, -5, 0) max_val = (7, 30, 15) layer = Points(data) extent = np.array((min_val, max_val)) check_layer_world_data_extent(layer, extent, (3, 1, 1), (10, 20, 5), False) def test_scale_init(): layer = Points(None, scale=(1, 1, 1, 1)) assert layer.ndim == 4 layer1 = Points([], scale=(1, 1, 1, 1)) assert layer1.ndim == 4 layer2 = Points([]) assert layer2.ndim == 2 with pytest.raises(ValueError): Points([[1, 1, 1]], scale=(1, 1, 1, 1)) def test_update_none(): layer = Points([(1, 2, 3), (1, 3, 2)]) assert layer.ndim == 3 assert layer.data.size == 6 layer.data = None assert layer.ndim == 3 assert layer.data.size == 0 layer.data = [(1, 2, 3), (1, 3, 2)] assert layer.ndim == 3 assert layer.data.size == 6 def test_set_face_color_mode_after_set_properties(): # See GitHub issue for more details: # https://github.com/napari/napari/issues/2755 np.random.seed(0) num_points = 3 points = Points(np.random.random((num_points, 2))) points.properties = { 'cat': np.random.randint(low=0, high=num_points, size=num_points), 'cont': np.random.random(num_points), } # Initially the color_mode is DIRECT, which means that the face ColorManager # has no color_properties, so the first property is used with a warning. with pytest.warns(UserWarning): points.face_color_mode = 'cycle' first_property_key, first_property_values = next( iter(points.properties.items()) ) expected_properties = ColorProperties( name=first_property_key, values=first_property_values, current_value=first_property_values[-1], ) assert points._face.color_properties == expected_properties def test_to_mask_2d_with_size_1(): points = Points([[1, 4]], size=1) mask = points.to_mask(shape=(5, 7)) expected_mask = np.array( [ [0, 0, 0, 0, 0, 0, 0], [0, 0, 0, 0, 1, 0, 0], [0, 0, 0, 0, 0, 0, 0], [0, 0, 0, 0, 0, 0, 0], [0, 0, 0, 0, 0, 0, 0], ], dtype=bool, ) np.testing.assert_array_equal(mask, expected_mask) def test_to_mask_2d_with_size_2(): points = Points([[1, 4]], size=2) mask = points.to_mask(shape=(5, 7)) expected_mask = np.array( [ [0, 0, 0, 0, 1, 0, 0], [0, 0, 0, 1, 1, 1, 0], [0, 0, 0, 0, 1, 0, 0], [0, 0, 0, 0, 0, 0, 0], [0, 0, 0, 0, 0, 0, 0], ], dtype=bool, ) np.testing.assert_array_equal(mask, expected_mask) def test_to_mask_2d_with_size_4(): points = Points([[1, 4]], size=4) mask = points.to_mask(shape=(5, 7)) expected_mask = np.array( [ [0, 0, 0, 1, 1, 1, 0], [0, 0, 1, 1, 1, 1, 1], [0, 0, 0, 1, 1, 1, 0], [0, 0, 0, 0, 1, 0, 0], [0, 0, 0, 0, 0, 0, 0], ], dtype=bool, ) np.testing.assert_array_equal(mask, expected_mask) def test_to_mask_2d_with_size_4_top_left(): points = Points([[0, 0]], size=4) mask = points.to_mask(shape=(5, 7)) expected_mask = np.array( [ [1, 1, 1, 0, 0, 0, 0], [1, 1, 0, 0, 0, 0, 0], [1, 0, 0, 0, 0, 0, 0], [0, 0, 0, 0, 0, 0, 0], [0, 0, 0, 0, 0, 0, 0], ], dtype=bool, ) np.testing.assert_array_equal(mask, expected_mask) def test_to_mask_2d_with_size_4_bottom_right(): points = Points([[4, 6]], size=4) mask = points.to_mask(shape=(5, 7)) expected_mask = np.array( [ [0, 0, 0, 0, 0, 0, 0], [0, 0, 0, 0, 0, 0, 0], [0, 0, 0, 0, 0, 0, 1], [0, 0, 0, 0, 0, 1, 1], [0, 0, 0, 0, 1, 1, 1], ], dtype=bool, ) np.testing.assert_array_equal(mask, expected_mask) def test_to_mask_2d_with_diff_sizes(): points = Points([[2, 2], [1, 4]], size=[[1, 1], [2, 2]]) mask = points.to_mask(shape=(5, 7)) expected_mask = np.array( [ [0, 0, 0, 0, 1, 0, 0], [0, 0, 0, 1, 1, 1, 0], [0, 0, 1, 0, 1, 0, 0], [0, 0, 0, 0, 0, 0, 0], [0, 0, 0, 0, 0, 0, 0], ], dtype=bool, ) np.testing.assert_array_equal(mask, expected_mask) def test_to_mask_2d_with_overlap(): points = Points([[1, 3], [1, 4]], size=2) mask = points.to_mask(shape=(5, 7)) expected_mask = np.array( [ [0, 0, 0, 1, 1, 0, 0], [0, 0, 1, 1, 1, 1, 0], [0, 0, 0, 1, 1, 0, 0], [0, 0, 0, 0, 0, 0, 0], [0, 0, 0, 0, 0, 0, 0], ], dtype=bool, ) np.testing.assert_array_equal(mask, expected_mask) def test_to_mask_2d_with_translate(): points = Points([[1, 4]], size=2) mask = points.to_mask( shape=(5, 7), data_to_world=CompositeAffine(translate=(-1, 2)) ) expected_mask = np.array( [ [0, 0, 0, 0, 0, 0, 0], [0, 0, 1, 0, 0, 0, 0], [0, 1, 1, 1, 0, 0, 0], [0, 0, 1, 0, 0, 0, 0], [0, 0, 0, 0, 0, 0, 0], ], dtype=bool, ) np.testing.assert_array_equal(mask, expected_mask) def test_to_mask_2d_with_rotate(): # Make the size just over 2, instead of exactly 2, to ensure that all expected pixels are # included, despite floating point imprecision caused by applying the rotation. points = Points([[-4, 1]], size=2.1) mask = points.to_mask( shape=(5, 7), data_to_world=CompositeAffine(rotate=90) ) # The point [-4, 1] is defined in world coordinates, so after applying # the inverse data_to_world transform will become [1, 4]. expected_mask = np.array( [ [0, 0, 0, 0, 1, 0, 0], [0, 0, 0, 1, 1, 1, 0], [0, 0, 0, 0, 1, 0, 0], [0, 0, 0, 0, 0, 0, 0], [0, 0, 0, 0, 0, 0, 0], ], dtype=bool, ) np.testing.assert_array_equal(mask, expected_mask) def test_to_mask_2d_with_isotropic_scale(): points = Points([[2, 8]], size=4) mask = points.to_mask( shape=(5, 7), data_to_world=CompositeAffine(scale=(2, 2)) ) expected_mask = np.array( [ [0, 0, 0, 0, 1, 0, 0], [0, 0, 0, 1, 1, 1, 0], [0, 0, 0, 0, 1, 0, 0], [0, 0, 0, 0, 0, 0, 0], [0, 0, 0, 0, 0, 0, 0], ], dtype=bool, ) np.testing.assert_array_equal(mask, expected_mask) def test_to_mask_2d_with_negative_isotropic_scale(): points = Points([[2, -8]], size=4) mask = points.to_mask( shape=(5, 7), data_to_world=CompositeAffine(scale=(2, -2)) ) expected_mask = np.array( [ [0, 0, 0, 0, 1, 0, 0], [0, 0, 0, 1, 1, 1, 0], [0, 0, 0, 0, 1, 0, 0], [0, 0, 0, 0, 0, 0, 0], [0, 0, 0, 0, 0, 0, 0], ], dtype=bool, ) np.testing.assert_array_equal(mask, expected_mask) def test_to_mask_2d_with_anisotropic_scale_isotropic_output(): # With isotropic output, the size of the output ball is determined # by the geometric mean of the scale which is sqrt(2), so absorb that # into the size to keep the math simple. points = Points([[2, 4]], size=2 * np.sqrt(2)) mask = points.to_mask( shape=(5, 7), data_to_world=CompositeAffine(scale=(2, 1)), isotropic_output=True, ) expected_mask = np.array( [ [0, 0, 0, 0, 1, 0, 0], [0, 0, 0, 1, 1, 1, 0], [0, 0, 0, 0, 1, 0, 0], [0, 0, 0, 0, 0, 0, 0], [0, 0, 0, 0, 0, 0, 0], ], dtype=bool, ) np.testing.assert_array_equal(mask, expected_mask) def test_to_mask_2d_with_anisotropic_scale_anisotropic_output(): points = Points([[2, 4]], size=4) mask = points.to_mask( shape=(5, 7), data_to_world=CompositeAffine(scale=(2, 1)), isotropic_output=False, ) # With anisotropic output, the output ball will be squashed # in the dimension with scaling, so that after adding it back as an image # with the same scaling, it should be roughly isotropic. expected_mask = np.array( [ [0, 0, 0, 0, 1, 0, 0], [0, 0, 1, 1, 1, 1, 1], [0, 0, 0, 0, 1, 0, 0], [0, 0, 0, 0, 0, 0, 0], [0, 0, 0, 0, 0, 0, 0], ], dtype=bool, ) np.testing.assert_array_equal(mask, expected_mask) def test_to_mask_2d_with_points_scale_but_no_mask_scale(): points = Points([[1, 4]], size=2, scale=(2, 2)) mask = points.to_mask(shape=(5, 7)) expected_mask = np.array( [ [0, 0, 0, 0, 1, 0, 0], [0, 0, 0, 1, 1, 1, 0], [0, 0, 0, 0, 1, 0, 0], [0, 0, 0, 0, 0, 0, 0], [0, 0, 0, 0, 0, 0, 0], ], dtype=bool, ) np.testing.assert_array_equal(mask, expected_mask) def test_to_mask_2d_with_same_points_and_mask_scale(): scale = (2, 2) points = Points([[1, 4]], size=2, scale=scale) mask = points.to_mask( shape=(5, 7), data_to_world=CompositeAffine(scale=scale) ) expected_mask = np.array( [ [0, 0, 0, 0, 1, 0, 0], [0, 0, 0, 1, 1, 1, 0], [0, 0, 0, 0, 1, 0, 0], [0, 0, 0, 0, 0, 0, 0], [0, 0, 0, 0, 0, 0, 0], ], dtype=bool, ) np.testing.assert_array_equal(mask, expected_mask) def test_to_mask_3d_with_size_1(): points = Points([[1, 2, 3]], size=1) mask = points.to_mask(shape=(3, 4, 5)) expected_mask = np.array( [ [ [0, 0, 0, 0, 0], [0, 0, 0, 0, 0], [0, 0, 0, 0, 0], [0, 0, 0, 0, 0], ], [ [0, 0, 0, 0, 0], [0, 0, 0, 0, 0], [0, 0, 0, 1, 0], [0, 0, 0, 0, 0], ], [ [0, 0, 0, 0, 0], [0, 0, 0, 0, 0], [0, 0, 0, 0, 0], [0, 0, 0, 0, 0], ], ], dtype=bool, ) np.testing.assert_array_equal(mask, expected_mask) def test_to_mask_3d_with_size_2(): points = Points([[1, 2, 3]], size=2) mask = points.to_mask(shape=(3, 4, 5)) expected_mask = np.array( [ [ [0, 0, 0, 0, 0], [0, 0, 0, 0, 0], [0, 0, 0, 1, 0], [0, 0, 0, 0, 0], ], [ [0, 0, 0, 0, 0], [0, 0, 0, 1, 0], [0, 0, 1, 1, 1], [0, 0, 0, 1, 0], ], [ [0, 0, 0, 0, 0], [0, 0, 0, 0, 0], [0, 0, 0, 1, 0], [0, 0, 0, 0, 0], ], ], dtype=bool, ) np.testing.assert_array_equal(mask, expected_mask) def test_set_properties_updates_text_values(): points = np.random.rand(3, 2) properties = {'class': np.array(['A', 'B', 'C'])} layer = Points(points, properties=properties, text='class') layer.properties = {'class': np.array(['D', 'E', 'F'])} np.testing.assert_array_equal(layer.text.values, ['D', 'E', 'F']) def test_set_properties_with_invalid_shape_errors_safely(): properties = { 'class': np.array(['A', 'B', 'C']), } points = Points(np.random.rand(3, 2), text='class', properties=properties) np.testing.assert_equal(points.properties, properties) np.testing.assert_array_equal(points.text.values, ['A', 'B', 'C']) with pytest.raises(ValueError): points.properties = {'class': np.array(['D', 'E'])} np.testing.assert_equal(points.properties, properties) np.testing.assert_array_equal(points.text.values, ['A', 'B', 'C']) def test_set_properties_with_missing_text_property_text_becomes_constant_empty_and_warns(): properties = { 'class': np.array(['A', 'B', 'C']), } points = Points(np.random.rand(3, 2), text='class', properties=properties) np.testing.assert_equal(points.properties, properties) np.testing.assert_array_equal(points.text.values, ['A', 'B', 'C']) with pytest.warns(RuntimeWarning): points.properties = {'not_class': np.array(['D', 'E', 'F'])} values = points.text.values np.testing.assert_array_equal(values, ['', '', '']) def test_text_param_and_setter_are_consistent(): """See https://github.com/napari/napari/issues/1833""" data = np.random.rand(5, 3) * 100 properties = { 'accepted': np.random.choice([True, False], (5,)), } text = {'string': 'accepted', 'color': 'black'} points_init = Points(data, properties=properties, text=text) points_set = Points(data, properties=properties) points_set.text = text np.testing.assert_array_equal( points_init.text.values, points_set.text.values, ) np.testing.assert_array_equal( points_init.text.color, points_set.text.color ) def test_editable_2d_layer_ndisplay_3(): """Interactivity doesn't work for 2D points layers being rendered in 3D. Verify that layer.editable is set to False upon switching to 3D rendering mode. See: https://github.com/napari/napari/pull/4184 """ data = np.random.random((10, 2)) layer = Points(data, size=5) assert layer.editable is True # simulate switching to 3D rendering # layer should no longer b editable layer._slice_dims([0, 0, 0], ndisplay=3) assert layer.editable is False def test_editable_3d_layer_ndisplay_3(): """Interactivity works for 3D points layers being rendered in 3D. Verify that layer.editable remains True upon switching to 3D rendering mode. See: https://github.com/napari/napari/pull/4184 """ data = np.random.random((10, 3)) layer = Points(data, size=5) assert layer.editable is True # simulate switching to 3D rendering # layer should no longer b editable layer._slice_dims([0, 0, 0], ndisplay=3) assert layer.editable is True def test_shown(): """Test setting shown property""" shape = (10, 2) np.random.seed(0) data = 20 * np.random.random(shape) layer = Points(data) assert len(layer.shown) == shape[0] assert np.all(layer.shown == True) # noqa # Hide the last point layer.shown[-1] = False assert np.all(layer.shown[:-1] == True) # noqa assert layer.shown[-1] == False # noqa # Add a new point, it should be shown but not affect the others coord = [17, 17] layer.add(coord) assert len(layer.shown) == shape[0] + 1 assert np.all(layer.shown[:-2] == True) # noqa assert layer.shown[-2] == False # noqa assert layer.shown[-1] == True # noqa def test_selected_data_with_non_uniform_sizes(): data = np.zeros((3, 2)) size = [[1, 3], [1, 4], [1, 3]] layer = Points(data, size=size) # Current size is the default 10 because passed size is not a scalar. assert layer.current_size == 10 # The first two points have different mean sizes, so the current size # should not change. layer.selected_data = (0, 1) assert layer.current_size == 10 # The first and last point have the same mean size, so the current size # should change to that mean. layer.selected_data = (0, 2) assert layer.current_size == 2 def test_shown_view_size_and_view_data_have_the_same_dimension(): data = [[0, 0, 0], [1, 1, 1]] # Data with default settings layer = Points( data, out_of_slice_display=False, shown=[True, True], size=3 ) assert layer._view_size.shape[0] == layer._view_data.shape[0] assert layer._view_size.shape[0] == 1 assert np.array_equal(layer._view_size, [3]) # shown == [True, False] layer = Points( data, out_of_slice_display=False, shown=[True, False], size=3 ) assert layer._view_size.shape[0] == layer._view_data.shape[0] assert layer._view_size.shape[0] == 1 assert np.array_equal(layer._view_size, [3]) # shown == [False, True] layer = Points( data, out_of_slice_display=False, shown=[False, True], size=3 ) assert layer._view_size.shape[0] == layer._view_data.shape[0] assert layer._view_size.shape[0] == 0 assert np.array_equal(layer._view_size, []) # shown == [False, False] layer = Points( data, out_of_slice_display=False, shown=[False, False], size=3 ) assert layer._view_size.shape[0] == layer._view_data.shape[0] assert layer._view_size.shape[0] == 0 assert np.array_equal(layer._view_size, []) # Out of slice display == True layer = Points(data, out_of_slice_display=True, shown=[True, True], size=3) assert layer._view_size.shape[0] == layer._view_data.shape[0] assert layer._view_size.shape[0] == 2 assert np.array_equal(layer._view_size, [3, 1]) # Out of slice display == True && shown == [True, False] layer = Points( data, out_of_slice_display=True, shown=[True, False], size=3 ) assert layer._view_size.shape[0] == layer._view_data.shape[0] assert layer._view_size.shape[0] == 1 assert np.array_equal(layer._view_size, [3]) # Out of slice display == True && shown == [False, True] layer = Points( data, out_of_slice_display=True, shown=[False, True], size=3 ) assert layer._view_size.shape[0] == layer._view_data.shape[0] assert layer._view_size.shape[0] == 1 assert np.array_equal(layer._view_size, [1]) # Out of slice display == True && shown == [False, False] layer = Points( data, out_of_slice_display=True, shown=[False, False], size=3 ) assert layer._view_size.shape[0] == layer._view_data.shape[0] assert layer._view_size.shape[0] == 0 assert np.array_equal(layer._view_size, []) def test_empty_data_from_tuple(): """Test that empty data raises an error.""" layer = Points(name="points") layer2 = Points.create(*layer.as_layer_data_tuple()) assert layer2.data.size == 0 @pytest.mark.parametrize( 'attribute, new_value', [ ("size", [20, 20]), ("face_color", np.asarray([0.0, 0.0, 1.0, 1.0])), ("edge_color", np.asarray([0.0, 0.0, 1.0, 1.0])), ("edge_width", np.asarray([0.2])), ], ) def test_new_point_size_editable(attribute, new_value): """tests the newly placed points may be edited without re-selecting""" layer = Points() layer.mode = Mode.ADD layer.add((0, 0)) setattr(layer, f"current_{attribute}", new_value) np.testing.assert_allclose(getattr(layer, attribute)[0], new_value) def test_antialiasing_setting_and_event_emission(): """Antialiasing changing should cause event emission.""" data = [[0, 0, 0], [1, 1, 1]] layer = Points(data) layer.events.antialiasing = Mock() layer.antialiasing = 5 assert layer.antialiasing == 5 layer.events.antialiasing.assert_called_once() def test_antialiasing_value_clipping(): """Antialiasing can only be set to positive values.""" data = [[0, 0, 0], [1, 1, 1]] layer = Points(data) with pytest.warns(RuntimeWarning): layer.antialiasing = -1 assert layer.antialiasing == 0 def test_set_drag_start(): """Drag start should only change when currently None.""" data = [[0, 0], [1, 1]] layer = Points(data) assert layer._drag_start is None position = (0, 1) layer._set_drag_start({0}, position=position) np.testing.assert_array_equal(layer._drag_start, position) layer._set_drag_start({0}, position=(1, 2)) np.testing.assert_array_equal(layer._drag_start, position) @pytest.mark.parametrize( "dims_indices,target_indices", [ ((8, slice(None), slice(None)), [2]), ((10, slice(None), slice(None)), [0, 1, 3, 4]), ((10 + 2 * 1e-12, slice(None), slice(None)), [0, 1, 3, 4]), ((10.1, slice(None), slice(None)), [0, 1, 3, 4]), ], ) def test_point_slice_request_response(dims_indices, target_indices): """Test points slicing with request and response.""" data = [ (10, 2, 4), (10 + 2 * 1e-7, 4, 6), (8, 1, 7), (10.1, 7, 2), (10 - 2 * 1e-7, 1, 6), ] layer = Points(data) request = layer._make_slice_request_internal( layer._slice_input, dims_indices ) response = request() assert len(response.indices) == len(target_indices) assert all([a == b for a, b in zip(response.indices, target_indices)]) def test_editable_and_visible_are_independent(): """See https://github.com/napari/napari/issues/1346""" data = np.empty((0, 2)) layer = Points(data) assert layer.editable assert layer.visible layer.editable = False layer.visible = False assert not layer.editable assert not layer.visible layer.visible = True assert not layer.editable napari-0.5.0a1/napari/layers/points/_tests/test_points_key_bindings.py000066400000000000000000000071121437041365600262620ustar00rootroot00000000000000from napari.layers.points import Points from napari.layers.points import _points_key_bindings as key_bindings def test_modes(layer): data = [[1, 3], [8, 4], [10, 10], [15, 4]] layer = Points(data, size=1) key_bindings.activate_points_add_mode(layer) assert layer.mode == 'add' key_bindings.activate_points_select_mode(layer) assert layer.mode == 'select' key_bindings.activate_points_pan_zoom_mode(layer) assert layer.mode == 'pan_zoom' def test_copy_paste(layer): data = [[1, 3], [8, 4], [10, 10], [15, 4]] layer = Points(data, size=1) layer.mode = 'select' assert len(layer.data) == 4 assert layer._clipboard == {} layer.selected_data = {0, 1} key_bindings.copy(layer) assert len(layer.data) == 4 assert len(layer._clipboard) > 0 key_bindings.paste(layer) assert len(layer.data) == 6 assert len(layer._clipboard) > 0 def test_select_all_in_slice(layer): data = [[1, 3], [8, 4], [10, 10], [15, 4]] layer = Points(data, size=1) layer.mode = 'select' layer._set_view_slice() assert len(layer.data) == 4 assert len(layer.selected_data) == 0 key_bindings.select_all_in_slice(layer) assert len(layer.selected_data) == 4 key_bindings.select_all_in_slice(layer) assert len(layer.selected_data) == 0 def test_select_all_in_slice_3d_data(layer): data = [[0, 1, 3], [0, 8, 4], [0, 10, 10], [1, 15, 4]] layer = Points(data, size=1) layer.mode = 'select' layer._set_view_slice() assert len(layer.data) == 4 assert len(layer.selected_data) == 0 key_bindings.select_all_in_slice(layer) assert len(layer.selected_data) == 3 key_bindings.select_all_in_slice(layer) assert len(layer.selected_data) == 0 def test_select_all_data(layer): data = [[1, 3], [8, 4], [10, 10], [15, 4]] layer = Points(data, size=1) layer.mode = 'select' layer._set_view_slice() assert len(layer.data) == 4 assert len(layer.selected_data) == 0 key_bindings.select_all_data(layer) assert len(layer.selected_data) == 4 key_bindings.select_all_data(layer) assert len(layer.selected_data) == 0 def test_select_all_data_3d_data(layer): data = [[0, 1, 3], [0, 8, 4], [0, 10, 10], [1, 15, 4]] layer = Points(data, size=1) layer.mode = 'select' layer._set_view_slice() assert len(layer.data) == 4 assert len(layer.selected_data) == 0 key_bindings.select_all_data(layer) assert len(layer.selected_data) == 4 key_bindings.select_all_data(layer) assert len(layer.selected_data) == 0 def test_select_all_mixed(layer): data = [[0, 1, 3], [0, 8, 4], [0, 10, 10], [1, 15, 4]] layer = Points(data, size=1) layer.mode = 'select' layer._set_view_slice() assert len(layer.data) == 4 assert len(layer.selected_data) == 0 key_bindings.select_all_data(layer) assert len(layer.selected_data) == 4 key_bindings.select_all_in_slice(layer) assert len(layer.selected_data) == 1 key_bindings.select_all_in_slice(layer) assert len(layer.selected_data) == 4 key_bindings.select_all_in_slice(layer) assert len(layer.selected_data) == 1 key_bindings.select_all_data(layer) assert len(layer.selected_data) == 4 key_bindings.select_all_data(layer) assert len(layer.selected_data) == 0 def test_delete_selected_points(layer): data = [[1, 3], [8, 4], [10, 10], [15, 4]] layer = Points(data, size=1) layer.mode = 'select' assert len(layer.data) == 4 layer.selected_data = {0, 1} key_bindings.delete_selected_points(layer) assert len(layer.data) == 2 napari-0.5.0a1/napari/layers/points/_tests/test_points_mouse_bindings.py000066400000000000000000000632171437041365600266320ustar00rootroot00000000000000from dataclasses import dataclass, field from typing import List, Optional, Tuple, Union import numpy as np import pytest from napari.layers import Points from napari.utils._proxies import ReadOnlyWrapper from napari.utils.interactions import ( mouse_move_callbacks, mouse_press_callbacks, mouse_release_callbacks, ) @dataclass class Event: """Create a subclass for simulating vispy mouse events.""" type: str is_dragging: bool = False modifiers: List[str] = field(default_factory=list) position: Union[Tuple[int, int], Tuple[int, int, int]] = ( 0, 0, ) # world coords pos: np.ndarray = np.zeros(2) # canvas coords view_direction: Optional[List[float]] = None up_direction: Optional[List[float]] = None dims_displayed: List[int] = field(default_factory=lambda: [0, 1]) @pytest.fixture def create_known_points_layer_2d(): """Create points layer with known coordinates Returns ------- layer : napari.layers.Points Points layer. n_points : int Number of points in the points layer known_non_point : list Data coordinates that are known to contain no points. Useful during testing when needing to guarantee no point is clicked on. """ data = [[1, 3], [8, 4], [10, 10], [15, 4]] known_non_point = [20, 30] n_points = len(data) layer = Points(data, size=1) assert np.all(layer.data == data) assert layer.ndim == 2 assert len(layer.data) == n_points assert len(layer.selected_data) == 0 return layer, n_points, known_non_point @pytest.fixture def create_known_points_layer_3d(): """Create 3D points layer with known coordinates displayed in 3D. Returns ------- layer : napari.layers.Points Points layer. n_points : int Number of points in the points layer known_non_point : list Data coordinates that are known to contain no points. Useful during testing when needing to guarantee no point is clicked on. """ data = [[1, 2, 3], [8, 6, 4], [10, 5, 10], [15, 8, 4]] known_non_point = [4, 5, 6] n_points = len(data) layer = Points(data, size=1) layer._slice_dims(ndisplay=3) assert np.all(layer.data == data) assert layer.ndim == 3 assert len(layer._slice_input.displayed) == 3 assert len(layer.data) == n_points assert len(layer._view_size) == n_points assert len(layer.selected_data) == 0 return layer, n_points, known_non_point def test_not_adding_or_selecting_point(create_known_points_layer_2d): """Don't add or select a point by clicking on one in pan_zoom mode.""" layer, n_points, _ = create_known_points_layer_2d layer.mode = 'pan_zoom' # Simulate click event = ReadOnlyWrapper(Event(type='mouse_press')) mouse_press_callbacks(layer, event) # Simulate release event = ReadOnlyWrapper(Event(type='mouse_release')) mouse_release_callbacks(layer, event) # Check no new point added and non selected assert len(layer.data) == n_points assert len(layer.selected_data) == 0 def test_add_point(create_known_points_layer_2d): """Add point by clicking in add mode.""" layer, n_points, known_non_point = create_known_points_layer_2d # Add point at location where non exists layer.mode = 'add' # Simulate click event = ReadOnlyWrapper( Event(type='mouse_press', position=known_non_point) ) mouse_press_callbacks(layer, event) # Simulate release event = ReadOnlyWrapper( Event(type='mouse_release', position=known_non_point) ) mouse_release_callbacks(layer, event) # Check new point added at coordinates location assert len(layer.data) == n_points + 1 np.testing.assert_allclose(layer.data[-1], known_non_point) def test_add_point_3d(create_known_points_layer_3d): """Add a point by clicking in 3D mode.""" layer, n_points, known_not_point = create_known_points_layer_3d layer.mode = 'add' # Simulate click event = ReadOnlyWrapper( Event( type='mouse_press', position=known_not_point, view_direction=[1, 0, 0], dims_displayed=[0, 1, 2], ) ) mouse_press_callbacks(layer, event) # Simulate release event = ReadOnlyWrapper( Event(type='mouse_release', position=known_not_point) ) mouse_release_callbacks(layer, event) # Check clicked point selected assert len(layer.data) == (n_points + 1) np.testing.assert_array_equal(layer.data[-1], known_not_point) def test_drag_in_add_mode(create_known_points_layer_2d): """Drag in add mode and make sure no point is added.""" layer, n_points, known_non_point = create_known_points_layer_2d # Add point at location where non exists layer.mode = 'add' layer.interactive = True # Simulate click event = ReadOnlyWrapper( Event(type='mouse_press', position=known_non_point) ) mouse_press_callbacks(layer, event) known_non_point_end = [40, 60] # Simulate drag end event = ReadOnlyWrapper( Event( type='mouse_move', is_dragging=True, position=known_non_point_end ) ) mouse_move_callbacks(layer, event) # Simulate release event = ReadOnlyWrapper( Event( type='mouse_release', position=known_non_point_end, pos=np.array([4, 4]), ) ) mouse_release_callbacks(layer, event) # Check that no new point has been added assert len(layer.data) == n_points def test_select_point(create_known_points_layer_2d): """Select a point by clicking on one in select mode.""" layer, n_points, _ = create_known_points_layer_2d layer.mode = 'select' position = tuple(layer.data[0]) # Simulate click event = ReadOnlyWrapper(Event(type='mouse_press', position=position)) mouse_press_callbacks(layer, event) # Simulate release event = ReadOnlyWrapper(Event(type='mouse_release', position=position)) mouse_release_callbacks(layer, event) # Check clicked point selected assert len(layer.selected_data) == 1 assert 0 in layer.selected_data def test_select_point_3d(create_known_points_layer_3d): """Select a point by clicking on one in select mode in 3D mode.""" layer, n_points, _ = create_known_points_layer_3d layer.mode = 'select' position = tuple(layer.data[1]) # Simulate click event = ReadOnlyWrapper( Event( type='mouse_press', position=position, view_direction=[1, 0, 0], dims_displayed=[0, 1, 2], ) ) mouse_press_callbacks(layer, event) # Simulate release event = ReadOnlyWrapper(Event(type='mouse_release', position=position)) mouse_release_callbacks(layer, event) # Check clicked point selected assert len(layer.selected_data) == 1 assert 1 in layer.selected_data def test_unselect_by_click_point_3d(create_known_points_layer_3d): """Select unselecting point by shift clicking on it again in 3D mode.""" layer, n_points, _ = create_known_points_layer_3d layer.mode = 'select' position = tuple(layer.data[1]) layer.selected_data = {0, 1} # Simulate shift+click on point 1 event = ReadOnlyWrapper( Event( type='mouse_press', position=position, modifiers=['Shift'], view_direction=[1, 0, 0], dims_displayed=[0, 1, 2], ) ) mouse_press_callbacks(layer, event) # Simulate release event = ReadOnlyWrapper( Event(type='mouse_release', modifiers=['Shift'], position=position) ) mouse_release_callbacks(layer, event) # Check clicked point selected assert layer.selected_data == {0} def test_select_by_shift_click_3d(create_known_points_layer_3d): """Select selecting point by shift clicking on an additional point in 3D""" layer, n_points, _ = create_known_points_layer_3d layer.mode = 'select' position = tuple(layer.data[1]) layer.selected_data = {0} # Simulate shift+click on point 1 event = ReadOnlyWrapper( Event( type='mouse_press', position=position, modifiers=['Shift'], view_direction=[1, 0, 0], dims_displayed=[0, 1, 2], ) ) mouse_press_callbacks(layer, event) # Simulate release event = ReadOnlyWrapper( Event(type='mouse_release', modifiers=['Shift'], position=position) ) mouse_release_callbacks(layer, event) # Check clicked point selected assert layer.selected_data == {0, 1} def test_unselect_by_click_empty_3d(create_known_points_layer_3d): """Select unselecting point by clicking in empty space""" layer, n_points, known_not_point = create_known_points_layer_3d layer.mode = 'select' layer.selected_data = {0, 1} # Simulate click on point event = ReadOnlyWrapper( Event( type='mouse_press', position=known_not_point, view_direction=[1, 0, 0], dims_displayed=[0, 1, 2], ) ) mouse_press_callbacks(layer, event) # Simulate release event = ReadOnlyWrapper( Event(type='mouse_release', position=known_not_point) ) mouse_release_callbacks(layer, event) # Check clicked point selected assert len(layer.selected_data) == 0 def test_after_in_add_mode_point(create_known_points_layer_2d): """Don't add or select a point by clicking on one in pan_zoom mode.""" layer, n_points, _ = create_known_points_layer_2d layer.mode = 'add' layer.mode = 'pan_zoom' position = tuple(layer.data[0]) # Simulate click event = ReadOnlyWrapper(Event(type='mouse_press', position=position)) mouse_press_callbacks(layer, event) # Simulate release event = ReadOnlyWrapper(Event(type='mouse_release', position=position)) mouse_release_callbacks(layer, event) # Check no new point added and non selected assert len(layer.data) == n_points assert len(layer.selected_data) == 0 def test_after_in_select_mode_point(create_known_points_layer_2d): """Don't add or select a point by clicking on one in pan_zoom mode.""" layer, n_points, _ = create_known_points_layer_2d layer.mode = 'select' layer.mode = 'pan_zoom' position = tuple(layer.data[0]) # Simulate click event = ReadOnlyWrapper(Event(type='mouse_press', position=position)) mouse_press_callbacks(layer, event) # Simulate release event = ReadOnlyWrapper(Event(type='mouse_release', position=position)) mouse_release_callbacks(layer, event) # Check no new point added and non selected assert len(layer.data) == n_points assert len(layer.selected_data) == 0 def test_unselect_select_point(create_known_points_layer_2d): """Select a point by clicking on one in select mode.""" layer, n_points, _ = create_known_points_layer_2d layer.mode = 'select' position = tuple(layer.data[0]) layer.selected_data = {2, 3} # Simulate click event = ReadOnlyWrapper(Event(type='mouse_press', position=position)) mouse_press_callbacks(layer, event) # Simulate release event = ReadOnlyWrapper(Event(type='mouse_release', position=position)) mouse_release_callbacks(layer, event) # Check clicked point selected assert len(layer.selected_data) == 1 assert 0 in layer.selected_data def test_add_select_point(create_known_points_layer_2d): """Add to a selection of points point by shift-clicking on one.""" layer, n_points, _ = create_known_points_layer_2d layer.mode = 'select' position = tuple(layer.data[0]) layer.selected_data = {2, 3} # Simulate click event = ReadOnlyWrapper( Event(type='mouse_press', modifiers=['Shift'], position=position) ) mouse_press_callbacks(layer, event) # Simulate release event = ReadOnlyWrapper( Event(type='mouse_release', modifiers=['Shift'], position=position) ) mouse_release_callbacks(layer, event) # Check clicked point selected assert len(layer.selected_data) == 3 assert layer.selected_data == {2, 3, 0} def test_remove_select_point(create_known_points_layer_2d): """Remove from a selection of points point by shift-clicking on one.""" layer, n_points, _ = create_known_points_layer_2d layer.mode = 'select' position = tuple(layer.data[0]) layer.selected_data = {0, 2, 3} # Simulate click event = ReadOnlyWrapper( Event(type='mouse_press', modifiers=['Shift'], position=position) ) mouse_press_callbacks(layer, event) # Simulate release event = ReadOnlyWrapper( Event(type='mouse_release', modifiers=['Shift'], position=position) ) mouse_release_callbacks(layer, event) # Check clicked point selected assert len(layer.selected_data) == 2 assert layer.selected_data == {2, 3} def test_not_selecting_point(create_known_points_layer_2d): """Don't select a point by not clicking on one in select mode.""" layer, n_points, known_non_point = create_known_points_layer_2d layer.mode = 'select' # Simulate click event = ReadOnlyWrapper( Event(type='mouse_press', position=known_non_point) ) mouse_press_callbacks(layer, event) # Simulate release event = ReadOnlyWrapper( Event(type='mouse_release', position=known_non_point) ) mouse_release_callbacks(layer, event) # Check clicked point selected assert len(layer.selected_data) == 0 def test_unselecting_points(create_known_points_layer_2d): """Unselect points by not clicking on one in select mode.""" layer, n_points, known_non_point = create_known_points_layer_2d layer.mode = 'select' layer.selected_data = {2, 3} assert len(layer.selected_data) == 2 # Simulate click event = ReadOnlyWrapper( Event(type='mouse_press', position=known_non_point) ) mouse_press_callbacks(layer, event) # Simulate release event = ReadOnlyWrapper( Event(type='mouse_release', position=known_non_point) ) mouse_release_callbacks(layer, event) # Check clicked point selected assert len(layer.selected_data) == 0 def test_selecting_all_points_with_drag_2d(create_known_points_layer_2d): """Select all points when drag box includes all of them.""" layer, n_points, known_non_point = create_known_points_layer_2d layer.mode = 'select' # Simulate click event = ReadOnlyWrapper( Event(type='mouse_press', position=known_non_point) ) mouse_press_callbacks(layer, event) # Simulate drag start event = ReadOnlyWrapper( Event(type='mouse_move', is_dragging=True, position=known_non_point) ) mouse_move_callbacks(layer, event) # Simulate drag end event = ReadOnlyWrapper(Event(type='mouse_move', is_dragging=True)) mouse_move_callbacks(layer, event) # Simulate release event = ReadOnlyWrapper(Event(type='mouse_release', is_dragging=True)) mouse_release_callbacks(layer, event) # Check all points selected as drag box contains them assert len(layer.selected_data) == n_points def test_selecting_no_points_with_drag_2d(create_known_points_layer_2d): """Select no points when drag box outside of all of them.""" layer, n_points, known_non_point = create_known_points_layer_2d layer.mode = 'select' # Simulate click event = ReadOnlyWrapper( Event(type='mouse_press', position=known_non_point) ) mouse_press_callbacks(layer, event) # Simulate drag start event = ReadOnlyWrapper( Event(type='mouse_move', is_dragging=True, position=known_non_point) ) mouse_move_callbacks(layer, event) # Simulate drag end event = ReadOnlyWrapper( Event(type='mouse_move', is_dragging=True, position=(50, 60)) ) mouse_move_callbacks(layer, event) # Simulate release event = ReadOnlyWrapper( Event(type='mouse_release', is_dragging=True, position=(50, 60)) ) mouse_release_callbacks(layer, event) # Check no points selected as drag box doesn't contain them assert len(layer.selected_data) == 0 def test_selecting_points_with_drag_3d(create_known_points_layer_3d): """Select all points when drag box includes all of them.""" layer, n_points, known_non_point = create_known_points_layer_3d layer.mode = 'select' # Simulate click event = ReadOnlyWrapper( Event( type='mouse_press', position=(5, 0, 0), view_direction=[1, 0, 0], up_direction=[0, 1, 0], dims_displayed=[0, 1, 2], ) ) mouse_press_callbacks(layer, event) # Simulate drag start event = ReadOnlyWrapper( Event( type='mouse_move', is_dragging=True, position=(5, 0, 0), view_direction=[1, 0, 0], up_direction=[0, 1, 0], dims_displayed=[0, 1, 2], ) ) mouse_move_callbacks(layer, event) # Simulate drag end event = ReadOnlyWrapper( Event( type='mouse_move', is_dragging=True, position=(5, 6, 6), view_direction=[1, 0, 0], up_direction=[0, 1, 0], dims_displayed=[0, 1, 2], ) ) mouse_move_callbacks(layer, event) # Simulate release event = ReadOnlyWrapper( Event( type='mouse_release', is_dragging=True, position=(5, 6, 6), view_direction=[1, 0, 0], up_direction=[0, 1, 0], dims_displayed=[0, 1, 2], ) ) mouse_release_callbacks(layer, event) # Check all points selected as drag box contains them assert layer.selected_data == {0, 1} def test_selecting_no_points_with_drag_3d(create_known_points_layer_3d): """Select no points when drag box outside of all of them.""" layer, n_points, known_non_point = create_known_points_layer_3d layer.mode = 'select' # Simulate click event = ReadOnlyWrapper( Event( type='mouse_press', position=(5, 15, 15), view_direction=[1, 0, 0], up_direction=[0, 1, 0], dims_displayed=[0, 1, 2], ) ) mouse_press_callbacks(layer, event) # Simulate drag start event = ReadOnlyWrapper( Event( type='mouse_move', is_dragging=True, position=(5, 15, 15), view_direction=[1, 0, 0], up_direction=[0, 1, 0], dims_displayed=[0, 1, 2], ) ) mouse_move_callbacks(layer, event) # Simulate drag end event = ReadOnlyWrapper( Event( type='mouse_move', is_dragging=True, position=(5, 20, 20), view_direction=[1, 0, 0], up_direction=[0, 1, 0], dims_displayed=[0, 1, 2], ) ) mouse_move_callbacks(layer, event) # Simulate release event = ReadOnlyWrapper( Event( type='mouse_release', is_dragging=True, position=(5, 20, 20), view_direction=[1, 0, 0], up_direction=[0, 1, 0], dims_displayed=[0, 1, 2], ) ) mouse_release_callbacks(layer, event) # Check all points selected as drag box contains them assert len(layer.selected_data) == 0 @pytest.mark.parametrize( 'pre_selection,on_point,modifier', [ (set(), True, []), ({0}, True, []), ({0, 1, 2}, True, []), ({1, 2}, True, []), (set(), True, ['Shift']), ({0}, True, ['Shift']), ({0, 1, 2}, True, ['Shift']), ({1, 2}, True, ['Shift']), (set(), False, []), ({0}, False, []), ({0, 1, 2}, False, []), ({1, 2}, False, []), (set(), False, ['Shift']), ({0}, False, ['Shift']), ({0, 1, 2}, False, ['Shift']), ({1, 2}, False, ['Shift']), ], ) def test_drag_start_selection( create_known_points_layer_2d, pre_selection, on_point, modifier ): """Check layer drag start and drag box behave as expected.""" layer, n_points, known_non_point = create_known_points_layer_2d layer.mode = 'select' layer.selected_data = pre_selection if on_point: initial_position = tuple(layer.data[0]) else: initial_position = tuple(known_non_point) zero_pos = [0, 0] initial_position_1 = tuple(layer.data[1]) diff_data_1 = [ layer.data[1, 0] - layer.data[0, 0], layer.data[1, 1] - layer.data[0, 1], ] assert layer._drag_start is None assert layer._drag_box is None # Simulate click event = ReadOnlyWrapper( Event( type='mouse_press', position=initial_position, modifiers=modifier ) ) mouse_press_callbacks(layer, event) if modifier: if not on_point: assert layer.selected_data == pre_selection elif 0 in pre_selection: assert layer.selected_data == pre_selection - {0} else: assert layer.selected_data == pre_selection | {0} elif not on_point: assert layer.selected_data == set() elif 0 in pre_selection: assert layer.selected_data == pre_selection else: assert layer.selected_data == {0} if len(layer.selected_data) > 0: center = layer.data[list(layer.selected_data), :].mean(axis=0) else: center = [0, 0] if not modifier: start_position = [ initial_position[0] - center[0], initial_position[1] - center[1], ] else: start_position = initial_position is_point_move = len(layer.selected_data) > 0 and on_point and not modifier np.testing.assert_array_equal(layer._drag_start, start_position) # Simulate drag start on a different position offset_position = [initial_position[0] + 20, initial_position[1] + 20] event = ReadOnlyWrapper( Event( type='mouse_move', is_dragging=True, position=offset_position, modifiers=modifier, ) ) mouse_move_callbacks(layer, event) # Initial mouse_move is already considered a move and not a press. # Therefore, the _drag_start value should be identical and the data or drag_box should reflect # the mouse position. np.testing.assert_array_equal(layer._drag_start, start_position) if is_point_move: if 1 in layer.selected_data and 0 in layer.selected_data: np.testing.assert_array_equal( layer.data[1], [ offset_position[0] + diff_data_1[0], offset_position[1] + diff_data_1[1], ], ) elif 1 not in layer.selected_data: np.testing.assert_array_equal(layer.data[1], initial_position_1) if 0 in layer.selected_data: np.testing.assert_array_equal( layer.data[0], [offset_position[0], offset_position[1]] ) else: raise AssertionError("Unreachable code") # pragma: no cover else: np.testing.assert_array_equal( layer._drag_box, [initial_position, offset_position] ) # Simulate drag start on new different position offset_position = zero_pos event = ReadOnlyWrapper( Event( type='mouse_move', is_dragging=True, position=offset_position, modifiers=modifier, ) ) mouse_move_callbacks(layer, event) # Initial mouse_move is already considered a move and not a press. # Therefore, the _drag_start value should be identical and the data or drag_box should reflect # the mouse position. np.testing.assert_array_equal(layer._drag_start, start_position) if is_point_move: if 1 in layer.selected_data and 0 in layer.selected_data: np.testing.assert_array_equal( layer.data[1], [ offset_position[0] + diff_data_1[0], offset_position[1] + diff_data_1[1], ], ) elif 1 not in layer.selected_data: np.testing.assert_array_equal(layer.data[1], initial_position_1) if 0 in layer.selected_data: np.testing.assert_array_equal( layer.data[0], [offset_position[0], offset_position[1]] ) else: raise AssertionError("Unreachable code") # pragma: no cover else: np.testing.assert_array_equal( layer._drag_box, [initial_position, offset_position] ) # Simulate release event = ReadOnlyWrapper( Event(type='mouse_release', is_dragging=True, modifiers=modifier) ) mouse_release_callbacks(layer, event) if on_point and 0 in pre_selection and modifier: assert layer.selected_data == pre_selection - {0} elif on_point and 0 in pre_selection and not modifier: assert layer.selected_data == pre_selection elif on_point and 0 not in pre_selection and modifier: assert layer.selected_data == pre_selection | {0} elif on_point and 0 not in pre_selection and not modifier: assert layer.selected_data == {0} elif 0 in pre_selection and modifier: assert 0 not in layer.selected_data assert layer.selected_data == (set(range(n_points)) - pre_selection) elif 0 in pre_selection and not modifier: assert 0 in layer.selected_data assert layer.selected_data == set(range(n_points)) elif 0 not in pre_selection and modifier: assert 0 in layer.selected_data assert layer.selected_data == (set(range(n_points)) - pre_selection) elif 0 not in pre_selection and not modifier: assert 0 in layer.selected_data assert layer.selected_data == set(range(n_points)) else: assert False, 'Unreachable code' # pragma: no cover assert layer._drag_box is None assert layer._drag_start is None napari-0.5.0a1/napari/layers/points/_tests/test_points_utils.py000066400000000000000000000017421437041365600247600ustar00rootroot00000000000000import numpy as np from napari.layers.points._points_utils import ( _create_box_from_corners_3d, _points_in_box_3d, ) def test_create_box_from_corners_3d(): corners = np.array([[5, 0, 0], [5, 10, 10]]) normal = np.array([1, 0, 0]) up_dir = np.array([0, 1, 0]) box = _create_box_from_corners_3d( box_corners=corners, box_normal=normal, up_vector=up_dir ) expected_box = np.array([[5, 0, 0], [5, 0, 10], [5, 10, 10], [5, 10, 0]]) np.testing.assert_allclose(box, expected_box) def test_points_in_box_3d(): normal = np.array([1, 0, 0]) up_dir = np.array([0, 1, 0]) corners = np.array([[10, 10, 10], [10, 20, 20]]) points = np.array([[0, 15, 15], [10, 30, 25], [10, 12, 18], [20, 15, 30]]) sizes = np.ones((points.shape[0],)) inside = _points_in_box_3d( box_corners=corners, box_normal=normal, up_direction=up_dir, points=points, sizes=sizes, ) assert set(inside) == {0, 2} napari-0.5.0a1/napari/layers/points/points.py000066400000000000000000002464611437041365600212110ustar00rootroot00000000000000import numbers import warnings from copy import copy, deepcopy from itertools import cycle from typing import Dict, List, Optional, Sequence, Tuple, Union import numpy as np import pandas as pd from scipy.stats import gmean from napari.layers.base import Layer, no_op from napari.layers.base._base_mouse_bindings import ( highlight_box_handles, transform_with_box, ) from napari.layers.points._points_constants import Mode, Shading from napari.layers.points._points_mouse_bindings import add, highlight, select from napari.layers.points._points_utils import ( _create_box_from_corners_3d, coerce_symbols, create_box, fix_data_points, points_to_squares, ) from napari.layers.points._slice import _PointSliceRequest, _PointSliceResponse from napari.layers.utils._color_manager_constants import ColorMode from napari.layers.utils._slice_input import _SliceInput from napari.layers.utils.color_manager import ColorManager from napari.layers.utils.color_transformations import ColorType from napari.layers.utils.interactivity_utils import ( displayed_plane_from_nd_line_segment, ) from napari.layers.utils.layer_utils import ( _features_to_properties, _FeatureTable, _unique_element, ) from napari.layers.utils.text_manager import TextManager from napari.utils.colormaps import Colormap, ValidColormapArg from napari.utils.colormaps.standardize_color import hex_to_name, rgb_to_hex from napari.utils.events import Event from napari.utils.events.custom_types import Array from napari.utils.geometry import project_points_onto_plane, rotate_points from napari.utils.status_messages import generate_layer_coords_status from napari.utils.transforms import Affine from napari.utils.translations import trans DEFAULT_COLOR_CYCLE = np.array([[1, 0, 1, 1], [0, 1, 0, 1]]) class Points(Layer): """Points layer. Parameters ---------- data : array (N, D) Coordinates for N points in D dimensions. ndim : int Number of dimensions for shapes. When data is not None, ndim must be D. An empty points layer can be instantiated with arbitrary ndim. features : dict[str, array-like] or DataFrame Features table where each row corresponds to a point and each column is a feature. properties : dict {str: array (N,)}, DataFrame Properties for each point. Each property should be an array of length N, where N is the number of points. property_choices : dict {str: array (N,)} possible values for each property. text : str, dict Text to be displayed with the points. If text is set to a key in properties, the value of that property will be displayed. Multiple properties can be composed using f-string-like syntax (e.g., '{property_1}, {float_property:.2f}). A dictionary can be provided with keyword arguments to set the text values and display properties. See TextManager.__init__() for the valid keyword arguments. For example usage, see /napari/examples/add_points_with_text.py. symbol : str, array Symbols to be used for the point markers. Must be one of the following: arrow, clobber, cross, diamond, disc, hbar, ring, square, star, tailed_arrow, triangle_down, triangle_up, vbar, x. size : float, array Size of the point marker in data pixels. If given as a scalar, all points are made the same size. If given as an array, size must be the same or broadcastable to the same shape as the data. edge_width : float, array Width of the symbol edge in pixels. edge_width_is_relative : bool If enabled, edge_width is interpreted as a fraction of the point size. edge_color : str, array-like, dict Color of the point marker border. Numeric color values should be RGB(A). edge_color_cycle : np.ndarray, list Cycle of colors (provided as string name, RGB, or RGBA) to map to edge_color if a categorical attribute is used color the vectors. edge_colormap : str, napari.utils.Colormap Colormap to set edge_color if a continuous attribute is used to set face_color. edge_contrast_limits : None, (float, float) clims for mapping the property to a color map. These are the min and max value of the specified property that are mapped to 0 and 1, respectively. The default value is None. If set the none, the clims will be set to (property.min(), property.max()) face_color : str, array-like, dict Color of the point marker body. Numeric color values should be RGB(A). face_color_cycle : np.ndarray, list Cycle of colors (provided as string name, RGB, or RGBA) to map to face_color if a categorical attribute is used color the vectors. face_colormap : str, napari.utils.Colormap Colormap to set face_color if a continuous attribute is used to set face_color. face_contrast_limits : None, (float, float) clims for mapping the property to a color map. These are the min and max value of the specified property that are mapped to 0 and 1, respectively. The default value is None. If set the none, the clims will be set to (property.min(), property.max()) out_of_slice_display : bool If True, renders points not just in central plane but also slightly out of slice according to specified point marker size. n_dimensional : bool This property will soon be deprecated in favor of 'out_of_slice_display'. Use that instead. name : str Name of the layer. metadata : dict Layer metadata. scale : tuple of float Scale factors for the layer. translate : tuple of float Translation values for the layer. rotate : float, 3-tuple of float, or n-D array. If a float convert into a 2D rotation matrix using that value as an angle. If 3-tuple convert into a 3D rotation matrix, using a yaw, pitch, roll convention. Otherwise assume an nD rotation. Angles are assumed to be in degrees. They can be converted from radians with np.degrees if needed. shear : 1-D array or n-D array Either a vector of upper triangular values, or an nD shear matrix with ones along the main diagonal. affine : n-D array or napari.utils.transforms.Affine (N+1, N+1) affine transformation matrix in homogeneous coordinates. The first (N, N) entries correspond to a linear transform and the final column is a length N translation vector and a 1 or a napari `Affine` transform object. Applied as an extra transform on top of the provided scale, rotate, and shear values. opacity : float Opacity of the layer visual, between 0.0 and 1.0. blending : str One of a list of preset blending modes that determines how RGB and alpha values of the layer visual get mixed. Allowed values are {'opaque', 'translucent', and 'additive'}. visible : bool Whether the layer visual is currently being displayed. cache : bool Whether slices of out-of-core datasets should be cached upon retrieval. Currently, this only applies to dask arrays. shading : str, Shading Render lighting and shading on points. Options are: * 'none' No shading is added to the points. * 'spherical' Shading and depth buffer are changed to give a 3D spherical look to the points antialiasing: float Amount of antialiasing in canvas pixels. canvas_size_limits : tuple of float Lower and upper limits for the size of points in canvas pixels. shown : 1-D array of bool Whether to show each point. Attributes ---------- data : array (N, D) Coordinates for N points in D dimensions. features : DataFrame-like Features table where each row corresponds to a point and each column is a feature. feature_defaults : DataFrame-like Stores the default value of each feature in a table with one row. properties : dict {str: array (N,)} or DataFrame Annotations for each point. Each property should be an array of length N, where N is the number of points. text : str Text to be displayed with the points. If text is set to a key in properties, the value of that property will be displayed. Multiple properties can be composed using f-string-like syntax (e.g., '{property_1}, {float_property:.2f}). For example usage, see /napari/examples/add_points_with_text.py. symbol : array of str Array of symbols for each point. size : array (N, D) Array of sizes for each point in each dimension. Must have the same shape as the layer `data`. edge_width : array (N,) Width of the marker edges in pixels for all points edge_width : array (N,) Width of the marker edges for all points as a fraction of their size. edge_color : Nx4 numpy array Array of edge color RGBA values, one for each point. edge_color_cycle : np.ndarray, list Cycle of colors (provided as string name, RGB, or RGBA) to map to edge_color if a categorical attribute is used color the vectors. edge_colormap : str, napari.utils.Colormap Colormap to set edge_color if a continuous attribute is used to set face_color. edge_contrast_limits : None, (float, float) clims for mapping the property to a color map. These are the min and max value of the specified property that are mapped to 0 and 1, respectively. The default value is None. If set the none, the clims will be set to (property.min(), property.max()) face_color : Nx4 numpy array Array of face color RGBA values, one for each point. face_color_cycle : np.ndarray, list Cycle of colors (provided as string name, RGB, or RGBA) to map to face_color if a categorical attribute is used color the vectors. face_colormap : str, napari.utils.Colormap Colormap to set face_color if a continuous attribute is used to set face_color. face_contrast_limits : None, (float, float) clims for mapping the property to a color map. These are the min and max value of the specified property that are mapped to 0 and 1, respectively. The default value is None. If set the none, the clims will be set to (property.min(), property.max()) current_symbol : Symbol Symbol for the next point to be added or the currently selected points. current_size : float Size of the marker for the next point to be added or the currently selected point. current_edge_width : float Edge width of the marker for the next point to be added or the currently selected point. current_edge_color : str Edge color of the marker edge for the next point to be added or the currently selected point. current_face_color : str Face color of the marker edge for the next point to be added or the currently selected point. out_of_slice_display : bool If True, renders points not just in central plane but also slightly out of slice according to specified point marker size. selected_data : set Integer indices of any selected points. mode : str Interactive mode. The normal, default mode is PAN_ZOOM, which allows for normal interactivity with the canvas. In ADD mode clicks of the cursor add points at the clicked location. In SELECT mode the cursor can select points by clicking on them or by dragging a box around them. Once selected points can be moved, have their properties edited, or be deleted. face_color_mode : str Face color setting mode. DIRECT (default mode) allows each point to be set arbitrarily CYCLE allows the color to be set via a color cycle over an attribute COLORMAP allows color to be set via a color map over an attribute edge_color_mode : str Edge color setting mode. DIRECT (default mode) allows each point to be set arbitrarily CYCLE allows the color to be set via a color cycle over an attribute COLORMAP allows color to be set via a color map over an attribute shading : Shading Shading mode. antialiasing: float Amount of antialiasing in canvas pixels. canvas_size_limits : tuple of float Lower and upper limits for the size of points in canvas pixels. shown : 1-D array of bool Whether each point is shown. Notes ----- _view_data : array (M, 2) 2D coordinates of points in the currently viewed slice. _view_size : array (M, ) Size of the point markers in the currently viewed slice. _view_symbol : array (M, ) Symbols of the point markers in the currently viewed slice. _view_edge_width : array (M, ) Edge width of the point markers in the currently viewed slice. _indices_view : array (M, ) Integer indices of the points in the currently viewed slice and are shown. _selected_view : Integer indices of selected points in the currently viewed slice within the `_view_data` array. _selected_box : array (4, 2) or None Four corners of any box either around currently selected points or being created during a drag action. Starting in the top left and going clockwise. _drag_start : list or None Coordinates of first cursor click during a drag action. Gets reset to None after dragging is done. """ _modeclass = Mode _drag_modes = { Mode.PAN_ZOOM: no_op, Mode.TRANSFORM: transform_with_box, Mode.ADD: add, Mode.SELECT: select, } _move_modes = { Mode.PAN_ZOOM: no_op, Mode.TRANSFORM: highlight_box_handles, Mode.ADD: no_op, Mode.SELECT: highlight, } _cursor_modes = { Mode.PAN_ZOOM: 'standard', Mode.TRANSFORM: 'standard', Mode.ADD: 'crosshair', Mode.SELECT: 'standard', } # TODO write better documentation for edge_color and face_color # The max number of points that will ever be used to render the thumbnail # If more points are present then they are randomly subsampled _max_points_thumbnail = 1024 def __init__( self, data=None, *, ndim=None, features=None, properties=None, text=None, symbol='o', size=10, edge_width=0.05, edge_width_is_relative=True, edge_color='dimgray', edge_color_cycle=None, edge_colormap='viridis', edge_contrast_limits=None, face_color='white', face_color_cycle=None, face_colormap='viridis', face_contrast_limits=None, out_of_slice_display=False, n_dimensional=None, name=None, metadata=None, scale=None, translate=None, rotate=None, shear=None, affine=None, opacity=1, blending='translucent', visible=True, cache=True, property_choices=None, experimental_clipping_planes=None, shading='none', canvas_size_limits=(2, 10000), antialiasing=1, shown=True, ) -> None: if ndim is None and scale is not None: ndim = len(scale) data, ndim = fix_data_points(data, ndim) # Indices of selected points self._selected_data = set() self._selected_data_stored = set() self._selected_data_history = set() # Indices of selected points within the currently viewed slice self._selected_view = [] # Index of hovered point self._value = None self._value_stored = None self._highlight_index = [] self._highlight_box = None self._drag_start = None self._drag_normal = None self._drag_up = None # initialize view data self.__indices_view = np.empty(0, int) self._view_size_scale = [] self._drag_box = None self._drag_box_stored = None self._is_selecting = False self._clipboard = {} self._round_index = False super().__init__( data, ndim, name=name, metadata=metadata, scale=scale, translate=translate, rotate=rotate, shear=shear, affine=affine, opacity=opacity, blending=blending, visible=visible, cache=cache, experimental_clipping_planes=experimental_clipping_planes, ) self.events.add( size=Event, edge_width=Event, edge_width_is_relative=Event, face_color=Event, current_face_color=Event, edge_color=Event, current_edge_color=Event, properties=Event, current_properties=Event, symbol=Event, out_of_slice_display=Event, n_dimensional=Event, highlight=Event, shading=Event, antialiasing=Event, canvas_size_limits=Event, features=Event, feature_defaults=Event, ) # Save the point coordinates self._data = np.asarray(data) self._feature_table = _FeatureTable.from_layer( features=features, properties=properties, property_choices=property_choices, num_data=len(self.data), ) self._text = TextManager._from_layer( text=text, features=self.features, ) self._edge_width_is_relative = False self._shown = np.empty(0).astype(bool) # Indices of selected points self._selected_data = set() self._selected_data_stored = set() self._selected_data_history = set() # Indices of selected points within the currently viewed slice self._selected_view = [] # The following point properties are for the new points that will # be added. For any given property, if a list is passed to the # constructor so each point gets its own value then the default # value is used when adding new points self._current_size = np.asarray(size) if np.isscalar(size) else 10 self._current_edge_width = ( np.asarray(edge_width) if np.isscalar(edge_width) else 0.1 ) self.current_symbol = ( np.asarray(symbol) if np.isscalar(symbol) else 'o' ) # Index of hovered point self._value = None self._value_stored = None self._mode = Mode.PAN_ZOOM self._status = self.mode color_properties = ( self.properties if self._data.size > 0 else self.property_choices ) self._edge = ColorManager._from_layer_kwargs( n_colors=len(data), colors=edge_color, continuous_colormap=edge_colormap, contrast_limits=edge_contrast_limits, categorical_colormap=edge_color_cycle, properties=color_properties, ) self._face = ColorManager._from_layer_kwargs( n_colors=len(data), colors=face_color, continuous_colormap=face_colormap, contrast_limits=face_contrast_limits, categorical_colormap=face_color_cycle, properties=color_properties, ) if n_dimensional is not None: self._out_of_slice_display = n_dimensional else: self._out_of_slice_display = out_of_slice_display # Save the point style params self.size = size self.shown = shown self.symbol = symbol self.edge_width = edge_width self.edge_width_is_relative = edge_width_is_relative self.canvas_size_limits = canvas_size_limits self.shading = shading self.antialiasing = antialiasing # Trigger generation of view slice and thumbnail self.refresh() @property def data(self) -> np.ndarray: """(N, D) array: coordinates for N points in D dimensions.""" return self._data @data.setter def data(self, data: Optional[np.ndarray]): data, _ = fix_data_points(data, self.ndim) cur_npoints = len(self._data) self._data = data # Add/remove property and style values based on the number of new points. with self.events.blocker_all(): with self._edge.events.blocker_all(): with self._face.events.blocker_all(): self._feature_table.resize(len(data)) self.text.apply(self.features) if len(data) < cur_npoints: # If there are now fewer points, remove the size and colors of the # extra ones if len(self._edge.colors) > len(data): self._edge._remove( np.arange(len(data), len(self._edge.colors)) ) if len(self._face.colors) > len(data): self._face._remove( np.arange(len(data), len(self._face.colors)) ) self._shown = self._shown[: len(data)] self._size = self._size[: len(data)] self._edge_width = self._edge_width[: len(data)] self._symbol = self._symbol[: len(data)] elif len(data) > cur_npoints: # If there are now more points, add the size and colors of the # new ones adding = len(data) - cur_npoints if len(self._size) > 0: new_size = copy(self._size[-1]) for i in self._slice_input.displayed: new_size[i] = self.current_size else: # Add the default size, with a value for each dimension new_size = np.repeat( self.current_size, self._size.shape[1] ) size = np.repeat([new_size], adding, axis=0) if len(self._edge_width) > 0: new_edge_width = copy(self._edge_width[-1]) else: new_edge_width = self.current_edge_width edge_width = np.repeat( [new_edge_width], adding, axis=0 ) if len(self._symbol) > 0: new_symbol = copy(self._symbol[-1]) else: new_symbol = self.current_symbol symbol = np.repeat([new_symbol], adding, axis=0) # add new colors self._edge._add(n_colors=adding) self._face._add(n_colors=adding) shown = np.repeat([True], adding, axis=0) self._shown = np.concatenate( (self._shown, shown), axis=0 ) self.size = np.concatenate((self._size, size), axis=0) self.edge_width = np.concatenate( (self._edge_width, edge_width), axis=0 ) self.symbol = np.concatenate( (self._symbol, symbol), axis=0 ) self.selected_data = set( np.arange(cur_npoints, len(data)) ) self._update_dims() self.events.data(value=self.data) self._reset_editable() def _on_selection(self, selected): if selected: self._set_highlight() else: self._highlight_box = None self._highlight_index = [] self.events.highlight() @property def features(self): """Dataframe-like features table. It is an implementation detail that this is a `pandas.DataFrame`. In the future, we will target the currently-in-development Data API dataframe protocol [1]. This will enable us to use alternate libraries such as xarray or cuDF for additional features without breaking existing usage of this. If you need to specifically rely on the pandas API, please coerce this to a `pandas.DataFrame` using `features_to_pandas_dataframe`. References ---------- .. [1]: https://data-apis.org/dataframe-protocol/latest/API.html """ return self._feature_table.values @features.setter def features( self, features: Union[Dict[str, np.ndarray], pd.DataFrame], ) -> None: self._feature_table.set_values(features, num_data=len(self.data)) self._update_color_manager( self._face, self._feature_table, "face_color" ) self._update_color_manager( self._edge, self._feature_table, "edge_color" ) self.text.refresh(self.features) self.events.properties() self.events.features() @property def feature_defaults(self): """Dataframe-like with one row of feature default values. See `features` for more details on the type of this property. """ return self._feature_table.defaults @property def property_choices(self) -> Dict[str, np.ndarray]: return self._feature_table.choices() @property def properties(self) -> Dict[str, np.ndarray]: """dict {str: np.ndarray (N,)}, DataFrame: Annotations for each point""" return self._feature_table.properties() @staticmethod def _update_color_manager(color_manager, feature_table, name): if color_manager.color_properties is not None: color_name = color_manager.color_properties.name if color_name not in feature_table.values: color_manager.color_mode = ColorMode.DIRECT color_manager.color_properties = None warnings.warn( trans._( 'property used for {name} dropped', deferred=True, name=name, ), RuntimeWarning, ) else: color_manager.color_properties = { 'name': color_name, 'values': feature_table.values[color_name].to_numpy(), 'current_value': feature_table.defaults[color_name][0], } @properties.setter def properties( self, properties: Union[Dict[str, Array], pd.DataFrame, None] ): self.features = properties @property def current_properties(self) -> Dict[str, np.ndarray]: """dict{str: np.ndarray(1,)}: properties for the next added point.""" return self._feature_table.currents() @current_properties.setter def current_properties(self, current_properties): update_indices = None if self._update_properties and len(self.selected_data) > 0: update_indices = list(self.selected_data) self._feature_table.set_currents( current_properties, update_indices=update_indices ) current_properties = self.current_properties self._edge._update_current_properties(current_properties) self._face._update_current_properties(current_properties) self.events.current_properties() self.events.feature_defaults() if update_indices is not None: self.events.properties() self.events.features() @property def text(self) -> TextManager: """TextManager: the TextManager object containing containing the text properties""" return self._text @text.setter def text(self, text): self._text._update_from_layer( text=text, features=self.features, ) def refresh_text(self): """Refresh the text values. This is generally used if the features were updated without changing the data """ self.text.refresh(self.features) def _get_ndim(self) -> int: """Determine number of dimensions of the layer.""" return self.data.shape[1] @property def _extent_data(self) -> np.ndarray: """Extent of layer in data coordinates. Returns ------- extent_data : array, shape (2, D) """ if len(self.data) == 0: extrema = np.full((2, self.ndim), np.nan) else: maxs = np.max(self.data, axis=0) mins = np.min(self.data, axis=0) extrema = np.vstack([mins, maxs]) return extrema @property def out_of_slice_display(self) -> bool: """bool: renders points slightly out of slice.""" return self._out_of_slice_display @out_of_slice_display.setter def out_of_slice_display(self, out_of_slice_display: bool) -> None: self._out_of_slice_display = bool(out_of_slice_display) self.events.out_of_slice_display() self.events.n_dimensional() self.refresh() @property def n_dimensional(self) -> bool: """ This property will soon be deprecated in favor of `out_of_slice_display`. Use that instead. """ return self._out_of_slice_display @n_dimensional.setter def n_dimensional(self, value: bool) -> None: self.out_of_slice_display = value @property def symbol(self) -> np.ndarray: """str: symbol used for all point markers.""" return self._symbol @symbol.setter def symbol(self, symbol: Union[str, np.ndarray, list]) -> None: symbol = np.broadcast_to(symbol, self.data.shape[0]) self._symbol = coerce_symbols(symbol) self.events.symbol() self.events.highlight() @property def current_symbol(self) -> Union[int, float]: """float: symbol of marker for the next added point.""" return self._current_symbol @current_symbol.setter def current_symbol(self, symbol: Union[None, float]) -> None: symbol = coerce_symbols(np.array([symbol]))[0] self._current_symbol = symbol if self._update_properties and len(self.selected_data) > 0: self.symbol[list(self.selected_data)] = symbol self.events.symbol() @property def size(self) -> np.ndarray: """(N, D) array: size of all N points in D dimensions.""" return self._size @size.setter def size(self, size: Union[int, float, np.ndarray, list]) -> None: try: self._size = np.broadcast_to(size, self.data.shape).copy() except ValueError as e: try: self._size = np.broadcast_to( size, self.data.shape[::-1] ).T.copy() except ValueError: raise ValueError( trans._( "Size is not compatible for broadcasting", deferred=True, ) ) from e self.refresh() @property def current_size(self) -> Union[int, float]: """float: size of marker for the next added point.""" return self._current_size @current_size.setter def current_size(self, size: Union[None, float]) -> None: if (isinstance(size, numbers.Number) and size < 0) or ( isinstance(size, list) and min(size) < 0 ): warnings.warn( message=trans._( 'current_size value must be positive, value will be left at {value}.', deferred=True, value=self.current_size, ), category=RuntimeWarning, ) size = self.current_size self._current_size = size if self._update_properties and len(self.selected_data) > 0: for i in self.selected_data: self.size[i, :] = (self.size[i, :] > 0) * size self.refresh() self.events.size() @property def antialiasing(self) -> float: """Amount of antialiasing in canvas pixels.""" return self._antialiasing @antialiasing.setter def antialiasing(self, value: float): """Set the amount of antialiasing in canvas pixels. Values can only be positive. """ if value < 0: warnings.warn( message=trans._( 'antialiasing value must be positive, value will be set to 0.', deferred=True, ), category=RuntimeWarning, ) self._antialiasing = max(0, value) self.events.antialiasing(value=self._antialiasing) @property def shading(self) -> Shading: """shading mode.""" return self._shading @shading.setter def shading(self, value): self._shading = Shading(value) self.events.shading() @property def canvas_size_limits(self) -> Tuple[float, float]: """Limit the canvas size of points""" return self._canvas_size_limits @canvas_size_limits.setter def canvas_size_limits(self, value): self._canvas_size_limits = float(value[0]), float(value[1]) self.events.canvas_size_limits() @property def shown(self): """ Boolean array determining which points to show """ return self._shown @shown.setter def shown(self, shown): self._shown = np.broadcast_to(shown, self.data.shape[0]).astype(bool) self.refresh() @property def edge_width(self) -> np.ndarray: """(N, D) array: edge_width of all N points.""" return self._edge_width @edge_width.setter def edge_width( self, edge_width: Union[int, float, np.ndarray, list] ) -> None: # broadcast to np.array edge_width = np.broadcast_to(edge_width, self.data.shape[0]).copy() # edge width cannot be negative if np.any(edge_width < 0): raise ValueError( trans._( 'All edge_width must be > 0', deferred=True, ) ) # if relative edge width is enabled, edge_width must be between 0 and 1 if self.edge_width_is_relative and np.any(edge_width > 1): raise ValueError( trans._( 'All edge_width must be between 0 and 1 if edge_width_is_relative is enabled', deferred=True, ) ) self._edge_width = edge_width self.refresh() @property def edge_width_is_relative(self) -> bool: """bool: treat edge_width as a fraction of point size.""" return self._edge_width_is_relative @edge_width_is_relative.setter def edge_width_is_relative(self, edge_width_is_relative: bool) -> None: if edge_width_is_relative and np.any( (self.edge_width > 1) | (self.edge_width < 0) ): raise ValueError( trans._( 'edge_width_is_relative can only be enabled if edge_width is between 0 and 1', deferred=True, ) ) self._edge_width_is_relative = edge_width_is_relative self.events.edge_width_is_relative() @property def current_edge_width(self) -> Union[int, float]: """float: edge_width of marker for the next added point.""" return self._current_edge_width @current_edge_width.setter def current_edge_width(self, edge_width: Union[None, float]) -> None: self._current_edge_width = edge_width if self._update_properties and len(self.selected_data) > 0: for i in self.selected_data: self.edge_width[i] = (self.edge_width[i] > 0) * edge_width self.refresh() self.events.edge_width() @property def edge_color(self) -> np.ndarray: """(N x 4) np.ndarray: Array of RGBA edge colors for each point""" return self._edge.colors @edge_color.setter def edge_color(self, edge_color): self._edge._set_color( color=edge_color, n_colors=len(self.data), properties=self.properties, current_properties=self.current_properties, ) self.events.edge_color() @property def edge_color_cycle(self) -> np.ndarray: """Union[list, np.ndarray] : Color cycle for edge_color. Can be a list of colors defined by name, RGB or RGBA """ return self._edge.categorical_colormap.fallback_color.values @edge_color_cycle.setter def edge_color_cycle(self, edge_color_cycle: Union[list, np.ndarray]): self._edge.categorical_colormap = edge_color_cycle @property def edge_colormap(self) -> Colormap: """Return the colormap to be applied to a property to get the edge color. Returns ------- colormap : napari.utils.Colormap The Colormap object. """ return self._edge.continuous_colormap @edge_colormap.setter def edge_colormap(self, colormap: ValidColormapArg): self._edge.continuous_colormap = colormap @property def edge_contrast_limits(self) -> Tuple[float, float]: """None, (float, float): contrast limits for mapping the edge_color colormap property to 0 and 1 """ return self._edge.contrast_limits @edge_contrast_limits.setter def edge_contrast_limits( self, contrast_limits: Union[None, Tuple[float, float]] ): self._edge.contrast_limits = contrast_limits @property def current_edge_color(self) -> str: """str: Edge color of marker for the next added point or the selected point(s).""" hex_ = rgb_to_hex(self._edge.current_color)[0] return hex_to_name.get(hex_, hex_) @current_edge_color.setter def current_edge_color(self, edge_color: ColorType) -> None: if self._update_properties and len(self.selected_data) > 0: update_indices = list(self.selected_data) else: update_indices = [] self._edge._update_current_color( edge_color, update_indices=update_indices ) self.events.current_edge_color() @property def edge_color_mode(self) -> str: """str: Edge color setting mode DIRECT (default mode) allows each point to be set arbitrarily CYCLE allows the color to be set via a color cycle over an attribute COLORMAP allows color to be set via a color map over an attribute """ return self._edge.color_mode @edge_color_mode.setter def edge_color_mode(self, edge_color_mode: Union[str, ColorMode]): self._set_color_mode(edge_color_mode, 'edge') @property def face_color(self) -> np.ndarray: """(N x 4) np.ndarray: Array of RGBA face colors for each point""" return self._face.colors @face_color.setter def face_color(self, face_color): self._face._set_color( color=face_color, n_colors=len(self.data), properties=self.properties, current_properties=self.current_properties, ) self.events.face_color() @property def face_color_cycle(self) -> np.ndarray: """Union[np.ndarray, cycle]: Color cycle for face_color Can be a list of colors defined by name, RGB or RGBA """ return self._face.categorical_colormap.fallback_color.values @face_color_cycle.setter def face_color_cycle(self, face_color_cycle: Union[np.ndarray, cycle]): self._face.categorical_colormap = face_color_cycle @property def face_colormap(self) -> Colormap: """Return the colormap to be applied to a property to get the face color. Returns ------- colormap : napari.utils.Colormap The Colormap object. """ return self._face.continuous_colormap @face_colormap.setter def face_colormap(self, colormap: ValidColormapArg): self._face.continuous_colormap = colormap @property def face_contrast_limits(self) -> Union[None, Tuple[float, float]]: """None, (float, float) : clims for mapping the face_color colormap property to 0 and 1 """ return self._face.contrast_limits @face_contrast_limits.setter def face_contrast_limits( self, contrast_limits: Union[None, Tuple[float, float]] ): self._face.contrast_limits = contrast_limits @property def current_face_color(self) -> str: """Face color of marker for the next added point or the selected point(s).""" hex_ = rgb_to_hex(self._face.current_color)[0] return hex_to_name.get(hex_, hex_) @current_face_color.setter def current_face_color(self, face_color: ColorType) -> None: if self._update_properties and len(self.selected_data) > 0: update_indices = list(self.selected_data) else: update_indices = [] self._face._update_current_color( face_color, update_indices=update_indices ) self.events.current_face_color() @property def face_color_mode(self) -> str: """str: Face color setting mode DIRECT (default mode) allows each point to be set arbitrarily CYCLE allows the color to be set via a color cycle over an attribute COLORMAP allows color to be set via a color map over an attribute """ return self._face.color_mode @face_color_mode.setter def face_color_mode(self, face_color_mode): self._set_color_mode(face_color_mode, 'face') def _set_color_mode( self, color_mode: Union[ColorMode, str], attribute: str ): """Set the face_color_mode or edge_color_mode property Parameters ---------- color_mode : str, ColorMode The value for setting edge or face_color_mode. If color_mode is a string, it should be one of: 'direct', 'cycle', or 'colormap' attribute : str in {'edge', 'face'} The name of the attribute to set the color of. Should be 'edge' for edge_color_mode or 'face' for face_color_mode. """ color_mode = ColorMode(color_mode) color_manager = getattr(self, f'_{attribute}') if color_mode == ColorMode.DIRECT: color_manager.color_mode = color_mode elif color_mode in (ColorMode.CYCLE, ColorMode.COLORMAP): if color_manager.color_properties is not None: color_property = color_manager.color_properties.name else: color_property = '' if color_property == '': if self.features.shape[1] > 0: new_color_property = next(iter(self.features)) color_manager.color_properties = { 'name': new_color_property, 'values': self.features[new_color_property].to_numpy(), 'current_value': np.squeeze( self.current_properties[new_color_property] ), } warnings.warn( trans._( '_{attribute}_color_property was not set, setting to: {new_color_property}', deferred=True, attribute=attribute, new_color_property=new_color_property, ) ) else: raise ValueError( trans._( 'There must be a valid Points.properties to use {color_mode}', deferred=True, color_mode=color_mode, ) ) # ColorMode.COLORMAP can only be applied to numeric properties color_property = color_manager.color_properties.name if (color_mode == ColorMode.COLORMAP) and not issubclass( self.features[color_property].dtype.type, np.number ): raise TypeError( trans._( 'selected property must be numeric to use ColorMode.COLORMAP', deferred=True, ) ) color_manager.color_mode = color_mode def refresh_colors(self, update_color_mapping: bool = False): """Calculate and update face and edge colors if using a cycle or color map Parameters ---------- update_color_mapping : bool If set to True, the function will recalculate the color cycle map or colormap (whichever is being used). If set to False, the function will use the current color cycle map or color map. For example, if you are adding/modifying points and want them to be colored with the same mapping as the other points (i.e., the new points shouldn't affect the color cycle map or colormap), set ``update_color_mapping=False``. Default value is False. """ self._edge._refresh_colors(self.properties, update_color_mapping) self._face._refresh_colors(self.properties, update_color_mapping) def _get_state(self): """Get dictionary of layer state. Returns ------- state : dict Dictionary of layer state. """ state = self._get_base_state() state.update( { 'symbol': self.symbol if self.data.size else [self.current_symbol], 'edge_width': self.edge_width, 'edge_width_is_relative': self.edge_width_is_relative, 'face_color': self.face_color if self.data.size else [self.current_face_color], 'face_color_cycle': self.face_color_cycle, 'face_colormap': self.face_colormap.name, 'face_contrast_limits': self.face_contrast_limits, 'edge_color': self.edge_color if self.data.size else [self.current_edge_color], 'edge_color_cycle': self.edge_color_cycle, 'edge_colormap': self.edge_colormap.name, 'edge_contrast_limits': self.edge_contrast_limits, 'properties': self.properties, 'property_choices': self.property_choices, 'text': self.text.dict(), 'out_of_slice_display': self.out_of_slice_display, 'n_dimensional': self.out_of_slice_display, 'size': self.size, 'ndim': self.ndim, 'data': self.data, 'features': self.features, 'shading': self.shading, 'antialiasing': self.antialiasing, 'canvas_size_limits': self.canvas_size_limits, 'shown': self.shown, } ) return state @property def selected_data(self) -> set: """set: set of currently selected points.""" return self._selected_data @selected_data.setter def selected_data(self, selected_data): self._selected_data = set(selected_data) self._selected_view = list( np.intersect1d( np.array(list(self._selected_data)), self._indices_view, return_indices=True, )[2] ) # Update properties based on selected points if not len(self._selected_data): self._set_highlight() return index = list(self._selected_data) if ( unique_edge_color := _unique_element(self.edge_color[index]) ) is not None: with self.block_update_properties(): self.current_edge_color = unique_edge_color if ( unique_face_color := _unique_element(self.face_color[index]) ) is not None: with self.block_update_properties(): self.current_face_color = unique_face_color # Calculate the mean size across the displayed dimensions for # each point to be consistent with `_view_size`. mean_size = np.mean( self.size[np.ix_(index, self._slice_input.displayed)], axis=1 ) if (unique_size := _unique_element(mean_size)) is not None: with self.block_update_properties(): self.current_size = unique_size if ( unique_edge_width := _unique_element(self.edge_width[index]) ) is not None: with self.block_update_properties(): self.current_edge_width = unique_edge_width if (unique_symbol := _unique_element(self.symbol[index])) is not None: with self.block_update_properties(): self.current_symbol = unique_symbol unique_properties = {} for k, v in self.properties.items(): unique_properties[k] = _unique_element(v[index]) if all(p is not None for p in unique_properties.values()): with self.block_update_properties(): self.current_properties = unique_properties self._set_highlight() def interaction_box(self, index) -> Optional[np.ndarray]: """Create the interaction box around a list of points in view. Parameters ---------- index : list List of points around which to construct the interaction box. Returns ------- box : np.ndarray or None 4x2 array of corners of the interaction box in clockwise order starting in the upper-left corner. """ if len(index) > 0: data = self._view_data[index] size = self._view_size[index] data = points_to_squares(data, size) return create_box(data) return None @Layer.mode.getter def mode(self) -> str: """str: Interactive mode Interactive mode. The normal, default mode is PAN_ZOOM, which allows for normal interactivity with the canvas. In ADD mode clicks of the cursor add points at the clicked location. In SELECT mode the cursor can select points by clicking on them or by dragging a box around them. Once selected points can be moved, have their properties edited, or be deleted. """ return str(self._mode) def _mode_setter_helper(self, mode): mode = super()._mode_setter_helper(mode) if mode == self._mode: return mode if mode == Mode.ADD: self.selected_data = set() self.interactive = True elif mode != Mode.SELECT or self._mode != Mode.SELECT: self._selected_data_stored = set() self._set_highlight() return mode @property def _indices_view(self): return self.__indices_view @_indices_view.setter def _indices_view(self, value): if len(self._shown) == 0: self.__indices_view = np.empty(0, int) else: self.__indices_view = value[self.shown[value]] @property def _view_data(self) -> np.ndarray: """Get the coords of the points in view Returns ------- view_data : (N x D) np.ndarray Array of coordinates for the N points in view """ if len(self._indices_view) > 0: data = self.data[ np.ix_(self._indices_view, self._slice_input.displayed) ] else: # if no points in this slice send dummy data data = np.zeros((0, self._slice_input.ndisplay)) return data @property def _view_text(self) -> np.ndarray: """Get the values of the text elements in view Returns ------- text : (N x 1) np.ndarray Array of text strings for the N text elements in view """ # This may be triggered when the string encoding instance changed, # in which case it has no cached values, so generate them here. self.text.string._apply(self.features) return self.text.view_text(self._indices_view) @property def _view_text_coords(self) -> Tuple[np.ndarray, str, str]: """Get the coordinates of the text elements in view Returns ------- text_coords : (N x D) np.ndarray Array of coordinates for the N text elements in view anchor_x : str The vispy text anchor for the x axis anchor_y : str The vispy text anchor for the y axis """ return self.text.compute_text_coords( self._view_data, self._slice_input.ndisplay, self._slice_input.order, ) @property def _view_text_color(self) -> np.ndarray: """Get the colors of the text elements at the given indices.""" self.text.color._apply(self.features) return self.text._view_color(self._indices_view) @property def _view_size(self) -> np.ndarray: """Get the sizes of the points in view Returns ------- view_size : (N x D) np.ndarray Array of sizes for the N points in view """ if len(self._indices_view) > 0: # Get the point sizes and scale for ndim display sizes = ( self.size[ np.ix_(self._indices_view, self._slice_input.displayed) ].mean(axis=1) * self._view_size_scale ) else: # if no points, return an empty list sizes = np.array([]) return sizes @property def _view_symbol(self) -> np.ndarray: """Get the symbols of the points in view Returns ------- symbol : (N,) np.ndarray Array of symbol strings for the N points in view """ return self.symbol[self._indices_view] @property def _view_edge_width(self) -> np.ndarray: """Get the edge_width of the points in view Returns ------- view_edge_width : (N,) np.ndarray Array of edge_widths for the N points in view """ return self.edge_width[self._indices_view] @property def _view_face_color(self) -> np.ndarray: """Get the face colors of the points in view Returns ------- view_face_color : (N x 4) np.ndarray RGBA color array for the face colors of the N points in view. If there are no points in view, returns array of length 0. """ return self.face_color[self._indices_view] @property def _view_edge_color(self) -> np.ndarray: """Get the edge colors of the points in view Returns ------- view_edge_color : (N x 4) np.ndarray RGBA color array for the edge colors of the N points in view. If there are no points in view, returns array of length 0. """ return self.edge_color[self._indices_view] def _reset_editable(self) -> None: """Set editable mode based on layer properties.""" # interaction currently does not work for 2D layers being rendered in 3D self.editable = not ( self.ndim == 2 and self._slice_input.ndisplay == 3 ) def _on_editable_changed(self) -> None: if not self.editable: self.mode = Mode.PAN_ZOOM def _get_value(self, position) -> Union[None, int]: """Index of the point at a given 2D position in data coordinates. Parameters ---------- position : tuple Position in data coordinates. Returns ------- value : int or None Index of point that is at the current coordinate if any. """ # Display points if there are any in this slice view_data = self._view_data selection = None if len(view_data) > 0: displayed_position = [ position[i] for i in self._slice_input.displayed ] # Get the point sizes # TODO: calculate distance in canvas space to account for canvas_size_limits. # Without this implementation, point hover and selection (and anything depending # on self.get_value()) won't be aware of the real extent of points, causing # unexpected behaviour. See #3734 for details. distances = abs(view_data - displayed_position) in_slice_matches = np.all( distances <= np.expand_dims(self._view_size, axis=1) / 2, axis=1, ) indices = np.where(in_slice_matches)[0] if len(indices) > 0: selection = self._indices_view[indices[-1]] return selection def _get_value_3d( self, start_point: np.ndarray, end_point: np.ndarray, dims_displayed: List[int], ) -> Union[int, None]: """Get the layer data value along a ray Parameters ---------- start_point : np.ndarray The start position of the ray used to interrogate the data. end_point : np.ndarray The end position of the ray used to interrogate the data. dims_displayed : List[int] The indices of the dimensions currently displayed in the Viewer. Returns ------- value : Union[int, None] The data value along the supplied ray. """ if (start_point is None) or (end_point is None): # if the ray doesn't intersect the data volume, no points could have been intersected return None plane_point, plane_normal = displayed_plane_from_nd_line_segment( start_point, end_point, dims_displayed ) # project the in view points onto the plane projected_points, projection_distances = project_points_onto_plane( points=self._view_data, plane_point=plane_point, plane_normal=plane_normal, ) # rotate points and plane to be axis aligned with normal [0, 0, 1] rotated_points, rotation_matrix = rotate_points( points=projected_points, current_plane_normal=plane_normal, new_plane_normal=[0, 0, 1], ) rotated_click_point = np.dot(rotation_matrix, plane_point) # find the points the click intersects distances = abs(rotated_points[:, :2] - rotated_click_point[:2]) in_slice_matches = np.all( distances <= np.expand_dims(self._view_size, axis=1) / 2, axis=1, ) indices = np.where(in_slice_matches)[0] if len(indices) > 0: # find the point that is most in the foreground candidate_point_distances = projection_distances[indices] closest_index = indices[np.argmin(candidate_point_distances)] selection = self._indices_view[closest_index] else: selection = None return selection def _display_bounding_box_augmented(self, dims_displayed: np.ndarray): """An augmented, axis-aligned (ndisplay, 2) bounding box. This bounding box for includes the full size of displayed points and enables calculation of intersections in `Layer._get_value_3d()`. """ if len(self._view_size) == 0: return None max_point_size = np.max(self._view_size) bounding_box = np.copy( self._display_bounding_box(dims_displayed) ).astype(float) bounding_box[:, 0] -= max_point_size / 2 bounding_box[:, 1] += max_point_size / 2 return bounding_box def get_ray_intersections( self, position: List[float], view_direction: np.ndarray, dims_displayed: List[int], world: bool = True, ) -> Union[Tuple[np.ndarray, np.ndarray], Tuple[None, None]]: """Get the start and end point for the ray extending from a point through the displayed bounding box. This method overrides the base layer, replacing the bounding box used to calculate intersections with a larger one which includes the size of points in view. Parameters ---------- position the position of the point in nD coordinates. World vs. data is set by the world keyword argument. view_direction : np.ndarray a unit vector giving the direction of the ray in nD coordinates. World vs. data is set by the world keyword argument. dims_displayed a list of the dimensions currently being displayed in the viewer. world : bool True if the provided coordinates are in world coordinates. Default value is True. Returns ------- start_point : np.ndarray The point on the axis-aligned data bounding box that the cursor click intersects with. This is the point closest to the camera. The point is the full nD coordinates of the layer data. If the click does not intersect the axis-aligned data bounding box, None is returned. end_point : np.ndarray The point on the axis-aligned data bounding box that the cursor click intersects with. This is the point farthest from the camera. The point is the full nD coordinates of the layer data. If the click does not intersect the axis-aligned data bounding box, None is returned. """ if len(dims_displayed) != 3: return None, None # create the bounding box in data coordinates bounding_box = self._display_bounding_box_augmented(dims_displayed) if bounding_box is None: return None, None start_point, end_point = self._get_ray_intersections( position=position, view_direction=view_direction, dims_displayed=dims_displayed, world=world, bounding_box=bounding_box, ) return start_point, end_point def _set_view_slice(self): """Sets the view given the indices to slice with.""" # The new slicing code makes a request from the existing state and # executes the request on the calling thread directly. # For async slicing, the calling thread will not be the main thread. request = self._make_slice_request_internal( self._slice_input, self._slice_indices ) response = request() self._update_slice_response(response) def _make_slice_request(self, dims) -> _PointSliceRequest: """Make a Points slice request based on the given dims and these data.""" slice_input = self._make_slice_input( dims.point, dims.ndisplay, dims.order ) # TODO: [see Image] # For the existing sync slicing, slice_indices is passed through # to avoid some performance issues related to the evaluation of the # data-to-world transform and its inverse. Async slicing currently # absorbs these performance issues here, but we can likely improve # things either by caching the world-to-data transform on the layer # or by lazily evaluating it in the slice task itself. slice_indices = slice_input.data_indices(self._data_to_world.inverse) return self._make_slice_request_internal(slice_input, slice_indices) def _make_slice_request_internal( self, slice_input: _SliceInput, dims_indices ): return _PointSliceRequest( dims=slice_input, data=self.data, dims_indices=dims_indices, out_of_slice_display=self.out_of_slice_display, size=self.size, ) def _update_slice_response(self, response: _PointSliceResponse): """Handle a slicing response.""" self._slice_input = response.dims indices = response.indices scale = response.scale # Update the _view_size_scale in accordance to the self._indices_view setter. # If out_of_slice_display is False, scale is a number and not an array. # Therefore we have an additional if statement checking for # self._view_size_scale being an integer. if not isinstance(scale, np.ndarray): self._view_size_scale = scale elif len(self._shown) == 0: self._view_size_scale = np.empty(0, int) else: self._view_size_scale = scale[self.shown[indices]] self._indices_view = np.array(indices, dtype=int) # get the selected points that are in view self._selected_view = list( np.intersect1d( np.array(list(self._selected_data)), self._indices_view, return_indices=True, )[2] ) with self.events.highlight.blocker(): self._set_highlight(force=True) def _set_highlight(self, force=False): """Render highlights of shapes including boundaries, vertices, interaction boxes, and the drag selection box when appropriate. Highlighting only occurs in Mode.SELECT. Parameters ---------- force : bool Bool that forces a redraw to occur when `True` """ # Check if any point ids have changed since last call if ( self.selected_data == self._selected_data_stored and self._value == self._value_stored and np.all(self._drag_box == self._drag_box_stored) ) and not force: return self._selected_data_stored = copy(self.selected_data) self._value_stored = copy(self._value) self._drag_box_stored = copy(self._drag_box) if self._value is not None or len(self._selected_view) > 0: if len(self._selected_view) > 0: index = copy(self._selected_view) # highlight the hovered point if not in adding mode if ( self._value in self._indices_view and self._mode == Mode.SELECT and not self._is_selecting ): hover_point = list(self._indices_view).index(self._value) if hover_point not in index: index.append(hover_point) index.sort() else: # only highlight hovered points in select mode if ( self._value in self._indices_view and self._mode == Mode.SELECT and not self._is_selecting ): hover_point = list(self._indices_view).index(self._value) index = [hover_point] else: index = [] self._highlight_index = index else: self._highlight_index = [] # only display dragging selection box in 2D if self._is_selecting: if self._drag_normal is None: pos = create_box(self._drag_box) else: pos = _create_box_from_corners_3d( self._drag_box, self._drag_normal, self._drag_up ) pos = pos[list(range(4)) + [0]] else: pos = None self._highlight_box = pos self.events.highlight() def _update_thumbnail(self): """Update thumbnail with current points and colors.""" colormapped = np.zeros(self._thumbnail_shape) colormapped[..., 3] = 1 view_data = self._view_data if len(view_data) > 0: # Get the zoom factor required to fit all data in the thumbnail. de = self._extent_data min_vals = [de[0, i] for i in self._slice_input.displayed] shape = np.ceil( [de[1, i] - de[0, i] + 1 for i in self._slice_input.displayed] ).astype(int) zoom_factor = np.divide( self._thumbnail_shape[:2], shape[-2:] ).min() # Maybe subsample the points. if len(view_data) > self._max_points_thumbnail: thumbnail_indices = np.random.randint( 0, len(view_data), self._max_points_thumbnail ) points = view_data[thumbnail_indices] else: points = view_data thumbnail_indices = self._indices_view # Calculate the point coordinates in the thumbnail data space. thumbnail_shape = np.clip( np.ceil(zoom_factor * np.array(shape[:2])).astype(int), 1, # smallest side should be 1 pixel wide self._thumbnail_shape[:2], ) coords = np.floor( (points[:, -2:] - min_vals[-2:] + 0.5) * zoom_factor ).astype(int) coords = np.clip(coords, 0, thumbnail_shape - 1) # Draw single pixel points in the colormapped thumbnail. colormapped = np.zeros(tuple(thumbnail_shape) + (4,)) colormapped[..., 3] = 1 colors = self._face.colors[thumbnail_indices] colormapped[coords[:, 0], coords[:, 1]] = colors colormapped[..., 3] *= self.opacity self.thumbnail = colormapped def add(self, coords): """Adds points at coordinates. Parameters ---------- coords : array Point or points to add to the layer data. """ self.data = np.append(self.data, np.atleast_2d(coords), axis=0) def remove_selected(self): """Removes selected points if any.""" index = list(self.selected_data) index.sort() if len(index): self._shown = np.delete(self._shown, index, axis=0) self._size = np.delete(self._size, index, axis=0) self._symbol = np.delete(self._symbol, index, axis=0) self._edge_width = np.delete(self._edge_width, index, axis=0) with self._edge.events.blocker_all(): self._edge._remove(indices_to_remove=index) with self._face.events.blocker_all(): self._face._remove(indices_to_remove=index) self._feature_table.remove(index) self.text.remove(index) if self._value in self.selected_data: self._value = None else: if self._value is not None: # update the index of self._value to account for the # data being removed indices_removed = np.array(index) < self._value offset = np.sum(indices_removed) self._value -= offset self._value_stored -= offset self.data = np.delete(self.data, index, axis=0) self.selected_data = set() def _move( self, selection_indices: Sequence[int], position: Sequence[Union[int, float]], ) -> None: """Move points relative to drag start location. Parameters ---------- selection_indices : Sequence[int] Integer indices of points to move in self.data position : tuple Position to move points to in data coordinates. """ if len(selection_indices) > 0: selection_indices = list(selection_indices) disp = list(self._slice_input.displayed) self._set_drag_start(selection_indices, position) center = self.data[np.ix_(selection_indices, disp)].mean(axis=0) shift = np.array(position)[disp] - center - self._drag_start self.data[np.ix_(selection_indices, disp)] = ( self.data[np.ix_(selection_indices, disp)] + shift ) self.refresh() self.events.data(value=self.data) def _set_drag_start( self, selection_indices: Sequence[int], position: Sequence[Union[int, float]], center_by_data: bool = True, ) -> None: """Store the initial position at the start of a drag event. Parameters ---------- selection_indices : set of int integer indices of selected data used to index into self.data position : Sequence of numbers position of the drag start in data coordinates. center_by_data : bool Center the drag start based on the selected data. Used for modifier drag_box selection. """ selection_indices = list(selection_indices) dims_displayed = list(self._slice_input.displayed) if self._drag_start is None: self._drag_start = np.array(position, dtype=float)[dims_displayed] if len(selection_indices) > 0 and center_by_data: center = self.data[ np.ix_(selection_indices, dims_displayed) ].mean(axis=0) self._drag_start -= center def _paste_data(self): """Paste any point from clipboard and select them.""" npoints = len(self._view_data) totpoints = len(self.data) if len(self._clipboard.keys()) > 0: not_disp = self._slice_input.not_displayed data = deepcopy(self._clipboard['data']) offset = [ self._slice_indices[i] - self._clipboard['indices'][i] for i in not_disp ] data[:, not_disp] = data[:, not_disp] + np.array(offset) self._data = np.append(self.data, data, axis=0) self._shown = np.append( self.shown, deepcopy(self._clipboard['shown']), axis=0 ) self._size = np.append( self.size, deepcopy(self._clipboard['size']), axis=0 ) self._symbol = np.append( self.symbol, deepcopy(self._clipboard['symbol']), axis=0 ) self._feature_table.append(self._clipboard['features']) self.text._paste(**self._clipboard['text']) self._edge_width = np.append( self.edge_width, deepcopy(self._clipboard['edge_width']), axis=0, ) self._edge._paste( colors=self._clipboard['edge_color'], properties=_features_to_properties( self._clipboard['features'] ), ) self._face._paste( colors=self._clipboard['face_color'], properties=_features_to_properties( self._clipboard['features'] ), ) self._selected_view = list( range(npoints, npoints + len(self._clipboard['data'])) ) self._selected_data = set( range(totpoints, totpoints + len(self._clipboard['data'])) ) self.refresh() def _copy_data(self): """Copy selected points to clipboard.""" if len(self.selected_data) > 0: index = list(self.selected_data) self._clipboard = { 'data': deepcopy(self.data[index]), 'edge_color': deepcopy(self.edge_color[index]), 'face_color': deepcopy(self.face_color[index]), 'shown': deepcopy(self.shown[index]), 'size': deepcopy(self.size[index]), 'symbol': deepcopy(self.symbol[index]), 'edge_width': deepcopy(self.edge_width[index]), 'features': deepcopy(self.features.iloc[index]), 'indices': self._slice_indices, 'text': self.text._copy(index), } else: self._clipboard = {} def to_mask( self, *, shape: tuple, data_to_world: Optional[Affine] = None, isotropic_output: bool = True, ): """Return a binary mask array of all the points as balls. Parameters ---------- shape : tuple The shape of the mask to be generated. data_to_world : Optional[Affine] The data-to-world transform of the output mask image. This likely comes from a reference image. If None, then this is the same as this layer's data-to-world transform. isotropic_output : bool If True, then force the output mask to always contain isotropic balls in data/pixel coordinates. Otherwise, allow the anisotropy in the data-to-world transform to squash the balls in certain dimensions. By default this is True, but you should set it to False if you are going to create a napari image layer from the result with the same data-to-world transform and want the visualized balls to be roughly isotropic. Returns ------- np.ndarray The output binary mask array of the given shape containing this layer's points as balls. """ if data_to_world is None: data_to_world = self._data_to_world mask = np.zeros(shape, dtype=bool) mask_world_to_data = data_to_world.inverse points_data_to_mask_data = self._data_to_world.compose( mask_world_to_data ) points_in_mask_data_coords = np.atleast_2d( points_data_to_mask_data(self.data) ) # Calculating the radii of the output points in the mask is complex. # Points.size tells the size of the points in pixels in each dimension, # so we take the arithmetic mean across dimensions to define a scalar size # per point, which is consistent with visualization. mean_radii = np.mean(self.size, axis=1, keepdims=True) / 2 # Scale each radius by the geometric mean scale of the Points layer to # keep the balls isotropic when visualized in world coordinates. # Then scale each radius by the scale of the output image mask # using the geometric mean if isotropic output is desired. # The geometric means are used instead of the arithmetic mean # to maintain the volume scaling factor of the transforms. point_data_to_world_scale = gmean(np.abs(self._data_to_world.scale)) mask_world_to_data_scale = ( gmean(np.abs(mask_world_to_data.scale)) if isotropic_output else np.abs(mask_world_to_data.scale) ) radii_scale = point_data_to_world_scale * mask_world_to_data_scale output_data_radii = mean_radii * np.atleast_2d(radii_scale) for coords, radii in zip( points_in_mask_data_coords, output_data_radii ): # Define a minimal set of coordinates where the mask could be present # by defining an inclusive lower and exclusive upper bound for each dimension. lower_coords = np.maximum(np.floor(coords - radii), 0).astype(int) upper_coords = np.minimum( np.ceil(coords + radii) + 1, shape ).astype(int) # Generate every possible coordinate within the bounds defined above # in a grid of size D1 x D2 x ... x Dd x D (e.g. for D=2, this might be 4x5x2). submask_coords = [ range(lower_coords[i], upper_coords[i]) for i in range(self.ndim) ] submask_grids = np.stack( np.meshgrid(*submask_coords, copy=False, indexing='ij'), axis=-1, ) # Update the mask coordinates based on the normalized square distance # using a logical or to maintain any existing positive mask locations. normalized_square_distances = np.sum( ((submask_grids - coords) / radii) ** 2, axis=-1 ) mask[np.ix_(*submask_coords)] |= normalized_square_distances <= 1 return mask def get_status( self, position: Optional[Tuple] = None, *, view_direction: Optional[np.ndarray] = None, dims_displayed: Optional[List[int]] = None, world: bool = False, ) -> dict: """Status message information of the data at a coordinate position. # Parameters # ---------- # position : tuple # Position in either data or world coordinates. # view_direction : Optional[np.ndarray] # A unit vector giving the direction of the ray in nD world coordinates. # The default value is None. # dims_displayed : Optional[List[int]] # A list of the dimensions currently being displayed in the viewer. # The default value is None. # world : bool # If True the position is taken to be in world coordinates # and converted into data coordinates. False by default. # Returns # ------- # source_info : dict # Dict containing information that can be used in a status update. #""" if position is not None: value = self.get_value( position, view_direction=view_direction, dims_displayed=dims_displayed, world=world, ) else: value = None source_info = self._get_source_info() source_info['coordinates'] = generate_layer_coords_status( position[-self.ndim :], value ) # if this points layer has properties properties = self._get_properties( position, view_direction=view_direction, dims_displayed=dims_displayed, world=world, ) if properties: source_info['coordinates'] += "; " + ", ".join(properties) return source_info def _get_tooltip_text( self, position, *, view_direction: Optional[np.ndarray] = None, dims_displayed: Optional[List[int]] = None, world: bool = False, ): """ tooltip message of the data at a coordinate position. Parameters ---------- position : tuple Position in either data or world coordinates. view_direction : Optional[np.ndarray] A unit vector giving the direction of the ray in nD world coordinates. The default value is None. dims_displayed : Optional[List[int]] A list of the dimensions currently being displayed in the viewer. The default value is None. world : bool If True the position is taken to be in world coordinates and converted into data coordinates. False by default. Returns ------- msg : string String containing a message that can be used as a tooltip. """ return "\n".join( self._get_properties( position, view_direction=view_direction, dims_displayed=dims_displayed, world=world, ) ) def _get_properties( self, position, *, view_direction: Optional[np.ndarray] = None, dims_displayed: Optional[List[int]] = None, world: bool = False, ) -> list: if self.features.shape[1] == 0: return [] value = self.get_value( position, view_direction=view_direction, dims_displayed=dims_displayed, world=world, ) # if the cursor is not outside the image or on the background if value is None or value > self.data.shape[0]: return [] return [ f'{k}: {v[value]}' for k, v in self.features.items() if k != 'index' and len(v) > value and v[value] is not None and not (isinstance(v[value], float) and np.isnan(v[value])) ] napari-0.5.0a1/napari/layers/shapes/000077500000000000000000000000001437041365600172555ustar00rootroot00000000000000napari-0.5.0a1/napari/layers/shapes/__init__.py000066400000000000000000000005331437041365600213670ustar00rootroot00000000000000from napari.layers.shapes import _shapes_key_bindings from napari.layers.shapes.shapes import Shapes # Note that importing _shapes_key_bindings is needed as the Shapes layer gets # decorated with keybindings during that process, but it is not directly needed # by our users and so is deleted below del _shapes_key_bindings __all__ = ['Shapes'] napari-0.5.0a1/napari/layers/shapes/_mesh.py000066400000000000000000000060431437041365600207250ustar00rootroot00000000000000import numpy as np class Mesh: """Contains meshses of shapes that will ultimately get rendered. Parameters ---------- ndisplay : int Number of displayed dimensions. Attributes ---------- ndisplay : int Number of displayed dimensions. vertices : np.ndarray Qx2 array of vertices of all triangles for shapes including edges and faces vertices_centers : np.ndarray Qx2 array of centers of vertices of triangles for shapes. For vertices corresponding to faces these are the same as the actual vertices. For vertices corresponding to edges these values should be added to a scaled `vertices_offsets` to get the actual vertex positions. The scaling corresponds to the width of the edge vertices_offsets : np.ndarray Qx2 array of offsets of vertices of triangles for shapes. For vertices corresponding to faces these are 0. For vertices corresponding to edges these values should be scaled and added to the `vertices_centers` to get the actual vertex positions. The scaling corresponds to the width of the edge vertices_index : np.ndarray Qx2 array of the index (0, ..., N-1) of each shape that each vertex corresponds and the mesh type (0, 1) for face or edge. triangles : np.ndarray Px3 array of vertex indices that form the mesh triangles triangles_index : np.ndarray Px2 array of the index (0, ..., N-1) of each shape that each triangle corresponds and the mesh type (0, 1) for face or edge. triangles_colors : np.ndarray Px4 array of the rgba color of each triangle triangles_z_order : np.ndarray Length P array of the z order of each triangle. Must be a permutation of (0, ..., P-1) Notes ----- _types : list Length two list of the different mesh types corresponding to faces and edges """ _types = ['face', 'edge'] def __init__(self, ndisplay=2) -> None: self._ndisplay = ndisplay self.clear() def clear(self): """Resets mesh data""" self.vertices = np.empty((0, self.ndisplay)) self.vertices_centers = np.empty((0, self.ndisplay)) self.vertices_offsets = np.empty((0, self.ndisplay)) self.vertices_index = np.empty((0, 2), dtype=int) self.triangles = np.empty((0, 3), dtype=np.uint32) self.triangles_index = np.empty((0, 2), dtype=int) self.triangles_colors = np.empty((0, 4)) self.triangles_z_order = np.empty((0), dtype=int) self.displayed_triangles = np.empty((0, 3), dtype=np.uint32) self.displayed_triangles_index = np.empty((0, 2), dtype=int) self.displayed_triangles_colors = np.empty((0, 4)) @property def ndisplay(self): """int: Number of displayed dimensions.""" return self._ndisplay @ndisplay.setter def ndisplay(self, ndisplay): if self.ndisplay == ndisplay: return self._ndisplay = ndisplay self.clear() napari-0.5.0a1/napari/layers/shapes/_shape_list.py000066400000000000000000001340251437041365600221260ustar00rootroot00000000000000from collections.abc import Iterable from typing import Sequence, Union import numpy as np from napari.layers.shapes._mesh import Mesh from napari.layers.shapes._shapes_constants import ShapeType, shape_classes from napari.layers.shapes._shapes_models import Line, Path, Shape from napari.layers.shapes._shapes_utils import triangles_intersect_box from napari.utils.geometry import ( inside_triangles, intersect_line_with_triangles, line_in_triangles_3d, ) from napari.utils.translations import trans class ShapeList: """List of shapes class. Parameters ---------- data : list List of Shape objects ndisplay : int Number of displayed dimensions. Attributes ---------- shapes : (N, ) list Shape objects. data : (N, ) list of (M, D) array Data arrays for each shape. ndisplay : int Number of displayed dimensions. slice_keys : (N, 2, P) array Array of slice keys for each shape. Each slice key has the min and max values of the P non-displayed dimensions, useful for slicing multidimensional shapes. If the both min and max values of shape are equal then the shape is entirely contained within the slice specified by those values. shape_types : (N, ) list of str Name of shape type for each shape. edge_color : (N x 4) np.ndarray Array of RGBA edge colors for each shape. face_color : (N x 4) np.ndarray Array of RGBA face colors for each shape. edge_widths : (N, ) list of float Edge width for each shape. z_indices : (N, ) list of int z-index for each shape. Notes ----- _vertices : np.ndarray MxN array of all displayed vertices from all shapes where N is equal to ndisplay _index : np.ndarray Length M array with the index (0, ..., N-1) of each shape that each vertex corresponds to _z_index : np.ndarray Length N array with z_index of each shape _z_order : np.ndarray Length N array with z_order of each shape. This must be a permutation of (0, ..., N-1). _mesh : Mesh Mesh object containing all the mesh information that will ultimately be rendered. """ def __init__(self, data=(), ndisplay=2) -> None: self._ndisplay = ndisplay self.shapes = [] self._displayed = [] self._slice_key = [] self.displayed_vertices = [] self.displayed_index = [] self._vertices = np.empty((0, self.ndisplay)) self._index = np.empty((0), dtype=int) self._z_index = np.empty((0), dtype=int) self._z_order = np.empty((0), dtype=int) self._mesh = Mesh(ndisplay=self.ndisplay) self._edge_color = np.empty((0, 4)) self._face_color = np.empty((0, 4)) for d in data: self.add(d) @property def data(self): """list of (M, D) array: data arrays for each shape.""" return [s.data for s in self.shapes] @property def ndisplay(self): """int: Number of displayed dimensions.""" return self._ndisplay @ndisplay.setter def ndisplay(self, ndisplay): if self.ndisplay == ndisplay: return self._ndisplay = ndisplay self._mesh.ndisplay = self.ndisplay self._vertices = np.empty((0, self.ndisplay)) self._index = np.empty((0), dtype=int) for index in range(len(self.shapes)): shape = self.shapes[index] shape.ndisplay = self.ndisplay self.remove(index, renumber=False) self.add(shape, shape_index=index) self._update_z_order() @property def slice_keys(self): """(N, 2, P) array: slice key for each shape.""" return np.array([s.slice_key for s in self.shapes]) @property def shape_types(self): """list of str: shape types for each shape.""" return [s.name for s in self.shapes] @property def edge_color(self): """(N x 4) np.ndarray: Array of RGBA edge colors for each shape""" return self._edge_color @edge_color.setter def edge_color(self, edge_color): self._set_color(edge_color, 'edge') @property def face_color(self): """(N x 4) np.ndarray: Array of RGBA face colors for each shape""" return self._face_color @face_color.setter def face_color(self, face_color): self._set_color(face_color, 'face') def _set_color(self, colors, attribute): """Set the face_color or edge_color property Parameters ---------- colors : (N, 4) np.ndarray The value for setting edge or face_color. There must be one color for each shape attribute : str in {'edge', 'face'} The name of the attribute to set the color of. Should be 'edge' for edge_color or 'face' for face_color. """ n_shapes = len(self.data) if not np.all(colors.shape == (n_shapes, 4)): raise ValueError( trans._( '{attribute}_color must have shape ({n_shapes}, 4)', deferred=True, attribute=attribute, n_shapes=n_shapes, ) ) update_method = getattr(self, f'update_{attribute}_colors') indices = np.arange(len(colors)) update_method(indices, colors, update=False) self._update_displayed() @property def edge_widths(self): """list of float: edge width for each shape.""" return [s.edge_width for s in self.shapes] @property def z_indices(self): """list of int: z-index for each shape.""" return [s.z_index for s in self.shapes] @property def slice_key(self): """list: slice key for slicing n-dimensional shapes.""" return self._slice_key @slice_key.setter def slice_key(self, slice_key): slice_key = list(slice_key) if not np.all(self._slice_key == slice_key): self._slice_key = slice_key self._update_displayed() def _update_displayed(self): """Update the displayed data based on the slice key.""" # The list slice key is repeated to check against both the min and # max values stored in the shapes slice key. slice_key = np.array([self.slice_key, self.slice_key]) # Slice key must exactly match mins and maxs of shape as then the # shape is entirely contained within the current slice. if len(self.shapes) > 0: self._displayed = np.all(self.slice_keys == slice_key, axis=(1, 2)) else: self._displayed = [] disp_indices = np.where(self._displayed)[0] z_order = self._mesh.triangles_z_order disp_tri = np.isin( self._mesh.triangles_index[z_order, 0], disp_indices ) self._mesh.displayed_triangles = self._mesh.triangles[z_order][ disp_tri ] self._mesh.displayed_triangles_index = self._mesh.triangles_index[ z_order ][disp_tri] self._mesh.displayed_triangles_colors = self._mesh.triangles_colors[ z_order ][disp_tri] disp_vert = np.isin(self._index, disp_indices) self.displayed_vertices = self._vertices[disp_vert] self.displayed_index = self._index[disp_vert] def add( self, shape: Union[Shape, Sequence[Shape]], face_color=None, edge_color=None, shape_index=None, z_refresh=True, ): """Adds a single Shape object (single add mode) or multiple Shapes (multiple shape mode, which is much faster) If shape is a single instance of subclass Shape then single add mode will be used, otherwise multiple add mode Parameters ---------- shape : single Shape or iterable of Shape Each shape must be a subclass of Shape, one of "{'Line', 'Rectangle', 'Ellipse', 'Path', 'Polygon'}" face_color : color (or iterable of colors of same length as shape) edge_color : color (or iterable of colors of same length as shape) shape_index : None | int If int then edits the shape date at current index. To be used in conjunction with `remove` when renumber is `False`. If None, then appends a new shape to end of shapes list Must be None in multiple shape mode. z_refresh : bool If set to true, the mesh elements are reindexed with the new z order. When shape_index is provided, z_refresh will be overwritten to false, as the z indices will not change. When adding a batch of shapes, set to false and then call ShapesList._update_z_order() once at the end. """ # single shape mode if issubclass(type(shape), Shape): self._add_single_shape( shape=shape, face_color=face_color, edge_color=edge_color, shape_index=shape_index, z_refresh=z_refresh, ) # multiple shape mode elif isinstance(shape, Iterable): if shape_index is not None: raise ValueError( trans._( 'shape_index must be None when adding multiple shapes', deferred=True, ) ) self._add_multiple_shapes( shapes=shape, face_colors=face_color, edge_colors=edge_color, z_refresh=z_refresh, ) else: raise ValueError( trans._( 'Cannot add single nor multiple shape', deferred=True, ) ) def _add_single_shape( self, shape, face_color=None, edge_color=None, shape_index=None, z_refresh=True, ): """Adds a single Shape object Parameters ---------- shape : subclass Shape Must be a subclass of Shape, one of "{'Line', 'Rectangle', 'Ellipse', 'Path', 'Polygon'}" shape_index : None | int If int then edits the shape date at current index. To be used in conjunction with `remove` when renumber is `False`. If None, then appends a new shape to end of shapes list z_refresh : bool If set to true, the mesh elements are reindexed with the new z order. When shape_index is provided, z_refresh will be overwritten to false, as the z indices will not change. When adding a batch of shapes, set to false and then call ShapesList._update_z_order() once at the end. """ if not issubclass(type(shape), Shape): raise ValueError( trans._( 'shape must be subclass of Shape', deferred=True, ) ) if shape_index is None: shape_index = len(self.shapes) self.shapes.append(shape) self._z_index = np.append(self._z_index, shape.z_index) if face_color is None: face_color = np.array([1, 1, 1, 1]) self._face_color = np.vstack([self._face_color, face_color]) if edge_color is None: edge_color = np.array([0, 0, 0, 1]) self._edge_color = np.vstack([self._edge_color, edge_color]) else: z_refresh = False self.shapes[shape_index] = shape self._z_index[shape_index] = shape.z_index if face_color is None: face_color = self._face_color[shape_index] else: self._face_color[shape_index, :] = face_color if edge_color is None: edge_color = self._edge_color[shape_index] else: self._edge_color[shape_index, :] = edge_color self._vertices = np.append( self._vertices, shape.data_displayed, axis=0 ) index = np.repeat(shape_index, len(shape.data)) self._index = np.append(self._index, index, axis=0) # Add faces to mesh m = len(self._mesh.vertices) vertices = shape._face_vertices self._mesh.vertices = np.append(self._mesh.vertices, vertices, axis=0) vertices = shape._face_vertices self._mesh.vertices_centers = np.append( self._mesh.vertices_centers, vertices, axis=0 ) vertices = np.zeros(shape._face_vertices.shape) self._mesh.vertices_offsets = np.append( self._mesh.vertices_offsets, vertices, axis=0 ) index = np.repeat([[shape_index, 0]], len(vertices), axis=0) self._mesh.vertices_index = np.append( self._mesh.vertices_index, index, axis=0 ) triangles = shape._face_triangles + m self._mesh.triangles = np.append( self._mesh.triangles, triangles, axis=0 ) index = np.repeat([[shape_index, 0]], len(triangles), axis=0) self._mesh.triangles_index = np.append( self._mesh.triangles_index, index, axis=0 ) color_array = np.repeat([face_color], len(triangles), axis=0) self._mesh.triangles_colors = np.append( self._mesh.triangles_colors, color_array, axis=0 ) # Add edges to mesh m = len(self._mesh.vertices) vertices = ( shape._edge_vertices + shape.edge_width * shape._edge_offsets ) self._mesh.vertices = np.append(self._mesh.vertices, vertices, axis=0) vertices = shape._edge_vertices self._mesh.vertices_centers = np.append( self._mesh.vertices_centers, vertices, axis=0 ) vertices = shape._edge_offsets self._mesh.vertices_offsets = np.append( self._mesh.vertices_offsets, vertices, axis=0 ) index = np.repeat([[shape_index, 1]], len(vertices), axis=0) self._mesh.vertices_index = np.append( self._mesh.vertices_index, index, axis=0 ) triangles = shape._edge_triangles + m self._mesh.triangles = np.append( self._mesh.triangles, triangles, axis=0 ) index = np.repeat([[shape_index, 1]], len(triangles), axis=0) self._mesh.triangles_index = np.append( self._mesh.triangles_index, index, axis=0 ) color_array = np.repeat([edge_color], len(triangles), axis=0) self._mesh.triangles_colors = np.append( self._mesh.triangles_colors, color_array, axis=0 ) if z_refresh: # Set z_order self._update_z_order() def _add_multiple_shapes( self, shapes, face_colors=None, edge_colors=None, z_refresh=True, ): """Add multiple shapes at once (faster than adding them one by one) Parameters ---------- shapes : iterable of Shape Each Shape must be a subclass of Shape, one of "{'Line', 'Rectangle', 'Ellipse', 'Path', 'Polygon'}" face_colors : iterable of face_color edge_colors : iterable of edge_color z_refresh : bool If set to true, the mesh elements are reindexed with the new z order. When shape_index is provided, z_refresh will be overwritten to false, as the z indices will not change. When adding a batch of shapes, set to false and then call ShapesList._update_z_order() once at the end. TODO: Currently shares a lot of code with `add()`, with the difference being that `add()` supports inserting shapes at a specific `shape_index`, whereas `add_multiple` will append them as a full batch """ def _make_index(length, shape_index, cval=0): """Same but faster than `np.repeat([[shape_index, cval]], length, axis=0)`""" index = np.empty((length, 2), np.int32) index.fill(cval) index[:, 0] = shape_index return index all_z_index = [] all_vertices = [] all_index = [] all_mesh_vertices = [] all_mesh_vertices_centers = [] all_mesh_vertices_offsets = [] all_mesh_vertices_index = [] all_mesh_triangles = [] all_mesh_triangles_index = [] all_mesh_triangles_colors = [] m_mesh_vertices_count = len(self._mesh.vertices) if face_colors is None: face_colors = np.tile(np.array([1, 1, 1, 1]), (len(shapes), 1)) else: face_colors = np.asarray(face_colors) if edge_colors is None: edge_colors = np.tile(np.array([0, 0, 0, 1]), (len(shapes), 1)) else: edge_colors = np.asarray(edge_colors) if not len(face_colors) == len(edge_colors) == len(shapes): raise ValueError( trans._( 'shapes, face_colors, and edge_colors must be the same length', deferred=True, ) ) if not all(issubclass(type(shape), Shape) for shape in shapes): raise ValueError( trans._( 'all shapes must be subclass of Shape', deferred=True, ) ) for shape, face_color, edge_color in zip( shapes, face_colors, edge_colors ): shape_index = len(self.shapes) self.shapes.append(shape) all_z_index.append(shape.z_index) all_vertices.append(shape.data_displayed) all_index.append([shape_index] * len(shape.data)) # Add faces to mesh m_tmp = m_mesh_vertices_count all_mesh_vertices.append(shape._face_vertices) m_mesh_vertices_count += len(shape._face_vertices) all_mesh_vertices_centers.append(shape._face_vertices) vertices = np.zeros(shape._face_vertices.shape) all_mesh_vertices_offsets.append(vertices) all_mesh_vertices_index.append( _make_index(len(vertices), shape_index, cval=0) ) triangles = shape._face_triangles + m_tmp all_mesh_triangles.append(triangles) all_mesh_triangles_index.append( _make_index(len(triangles), shape_index, cval=0) ) color_array = np.repeat([face_color], len(triangles), axis=0) all_mesh_triangles_colors.append(color_array) # Add edges to mesh m_tmp = m_mesh_vertices_count vertices = ( shape._edge_vertices + shape.edge_width * shape._edge_offsets ) all_mesh_vertices.append(vertices) m_mesh_vertices_count += len(vertices) all_mesh_vertices_centers.append(shape._edge_vertices) all_mesh_vertices_offsets.append(shape._edge_offsets) all_mesh_vertices_index.append( _make_index(len(shape._edge_offsets), shape_index, cval=1) ) triangles = shape._edge_triangles + m_tmp all_mesh_triangles.append(triangles) all_mesh_triangles_index.append( _make_index(len(triangles), shape_index, cval=1) ) color_array = np.repeat([edge_color], len(triangles), axis=0) all_mesh_triangles_colors.append(color_array) # assemble properties self._z_index = np.append(self._z_index, np.array(all_z_index), axis=0) self._face_color = np.vstack((self._face_color, face_colors)) self._edge_color = np.vstack((self._edge_color, edge_colors)) self._vertices = np.vstack((self._vertices, np.vstack(all_vertices))) self._index = np.append(self._index, np.concatenate(all_index), axis=0) self._mesh.vertices = np.vstack( (self._mesh.vertices, np.vstack(all_mesh_vertices)) ) self._mesh.vertices_centers = np.vstack( (self._mesh.vertices_centers, np.vstack(all_mesh_vertices_centers)) ) self._mesh.vertices_offsets = np.vstack( (self._mesh.vertices_offsets, np.vstack(all_mesh_vertices_offsets)) ) self._mesh.vertices_index = np.vstack( (self._mesh.vertices_index, np.vstack(all_mesh_vertices_index)) ) self._mesh.triangles = np.vstack( (self._mesh.triangles, np.vstack(all_mesh_triangles)) ) self._mesh.triangles_index = np.vstack( (self._mesh.triangles_index, np.vstack(all_mesh_triangles_index)) ) self._mesh.triangles_colors = np.vstack( (self._mesh.triangles_colors, np.vstack(all_mesh_triangles_colors)) ) if z_refresh: # Set z_order self._update_z_order() def remove_all(self): """Removes all shapes""" self.shapes = [] self._vertices = np.empty((0, self.ndisplay)) self._index = np.empty((0), dtype=int) self._z_index = np.empty((0), dtype=int) self._z_order = np.empty((0), dtype=int) self._mesh.clear() self._update_displayed() def remove(self, index, renumber=True): """Removes a single shape located at index. Parameters ---------- index : int Location in list of the shape to be removed. renumber : bool Bool to indicate whether to renumber all shapes or not. If not the expectation is that this shape is being immediately added back to the list using `add_shape`. """ indices = self._index != index self._vertices = self._vertices[indices] self._index = self._index[indices] # Remove triangles indices = self._mesh.triangles_index[:, 0] != index self._mesh.triangles = self._mesh.triangles[indices] self._mesh.triangles_colors = self._mesh.triangles_colors[indices] self._mesh.triangles_index = self._mesh.triangles_index[indices] # Remove vertices indices = self._mesh.vertices_index[:, 0] != index self._mesh.vertices = self._mesh.vertices[indices] self._mesh.vertices_centers = self._mesh.vertices_centers[indices] self._mesh.vertices_offsets = self._mesh.vertices_offsets[indices] self._mesh.vertices_index = self._mesh.vertices_index[indices] indices = np.where(np.invert(indices))[0] num_indices = len(indices) if num_indices > 0: indices = self._mesh.triangles > indices[0] self._mesh.triangles[indices] = ( self._mesh.triangles[indices] - num_indices ) if renumber: del self.shapes[index] indices = self._index > index self._index[indices] = self._index[indices] - 1 self._z_index = np.delete(self._z_index, index) indices = self._mesh.triangles_index[:, 0] > index self._mesh.triangles_index[indices, 0] = ( self._mesh.triangles_index[indices, 0] - 1 ) indices = self._mesh.vertices_index[:, 0] > index self._mesh.vertices_index[indices, 0] = ( self._mesh.vertices_index[indices, 0] - 1 ) self._update_z_order() def _update_mesh_vertices(self, index, edge=False, face=False): """Updates the mesh vertex data and vertex data for a single shape located at index. Parameters ---------- index : int Location in list of the shape to be changed. edge : bool Bool to indicate whether to update mesh vertices corresponding to edges face : bool Bool to indicate whether to update mesh vertices corresponding to faces and to update the underlying shape vertices """ shape = self.shapes[index] if edge: indices = np.all(self._mesh.vertices_index == [index, 1], axis=1) self._mesh.vertices[indices] = ( shape._edge_vertices + shape.edge_width * shape._edge_offsets ) self._mesh.vertices_centers[indices] = shape._edge_vertices self._mesh.vertices_offsets[indices] = shape._edge_offsets self._update_displayed() if face: indices = np.all(self._mesh.vertices_index == [index, 0], axis=1) self._mesh.vertices[indices] = shape._face_vertices self._mesh.vertices_centers[indices] = shape._face_vertices indices = self._index == index self._vertices[indices] = shape.data_displayed self._update_displayed() def _update_z_order(self): """Updates the z order of the triangles given the z_index list""" self._z_order = np.argsort(self._z_index) if len(self._z_order) == 0: self._mesh.triangles_z_order = np.empty((0), dtype=int) else: _, idx, counts = np.unique( self._mesh.triangles_index[:, 0], return_index=True, return_counts=True, ) triangles_z_order = [ np.arange(idx[z], idx[z] + counts[z]) for z in self._z_order ] self._mesh.triangles_z_order = np.concatenate(triangles_z_order) self._update_displayed() def edit( self, index, data, face_color=None, edge_color=None, new_type=None ): """Updates the data of a single shape located at index. If `new_type` is not None then converts the shape type to the new type Parameters ---------- index : int Location in list of the shape to be changed. data : np.ndarray NxD array of vertices. new_type : None | str | Shape If string , must be one of "{'line', 'rectangle', 'ellipse', 'path', 'polygon'}". """ if new_type is not None: cur_shape = self.shapes[index] if type(new_type) == str: shape_type = ShapeType(new_type) if shape_type in shape_classes.keys(): shape_cls = shape_classes[shape_type] else: raise ValueError( trans._( '{shape_type} must be one of {shape_classes}', deferred=True, shape_type=shape_type, shape_classes=set(shape_classes), ) ) else: shape_cls = new_type shape = shape_cls( data, edge_width=cur_shape.edge_width, z_index=cur_shape.z_index, dims_order=cur_shape.dims_order, ) else: shape = self.shapes[index] shape.data = data if face_color is not None: self._face_color[index] = face_color if edge_color is not None: self._edge_color[index] = edge_color self.remove(index, renumber=False) self.add(shape, shape_index=index) self._update_z_order() def update_edge_width(self, index, edge_width): """Updates the edge width of a single shape located at index. Parameters ---------- index : int Location in list of the shape to be changed. edge_width : float thickness of lines and edges. """ self.shapes[index].edge_width = edge_width self._update_mesh_vertices(index, edge=True) def update_edge_color(self, index, edge_color, update=True): """Updates the edge color of a single shape located at index. Parameters ---------- index : int Location in list of the shape to be changed. edge_color : str | tuple If string can be any color name recognized by vispy or hex value if starting with `#`. If array-like must be 1-dimensional array with 3 or 4 elements. update : bool If True, update the mesh with the new color property. Set to False to avoid repeated updates when modifying multiple shapes. Default is True. """ self._edge_color[index] = edge_color indices = np.all(self._mesh.triangles_index == [index, 1], axis=1) self._mesh.triangles_colors[indices] = self._edge_color[index] if update: self._update_displayed() def update_edge_colors(self, indices, edge_colors, update=True): """same as update_edge_color() but for multiple indices/edgecolors at once""" self._edge_color[indices] = edge_colors all_indices = np.bitwise_and( np.isin(self._mesh.triangles_index[:, 0], indices), self._mesh.triangles_index[:, 1] == 1, ) self._mesh.triangles_colors[all_indices] = self._edge_color[ self._mesh.triangles_index[all_indices, 0] ] if update: self._update_displayed() def update_face_color(self, index, face_color, update=True): """Updates the face color of a single shape located at index. Parameters ---------- index : int Location in list of the shape to be changed. face_color : str | tuple If string can be any color name recognized by vispy or hex value if starting with `#`. If array-like must be 1-dimensional array with 3 or 4 elements. update : bool If True, update the mesh with the new color property. Set to False to avoid repeated updates when modifying multiple shapes. Default is True. """ self._face_color[index] = face_color indices = np.all(self._mesh.triangles_index == [index, 0], axis=1) self._mesh.triangles_colors[indices] = self._face_color[index] if update: self._update_displayed() def update_face_colors(self, indices, face_colors, update=True): """same as update_face_color() but for multiple indices/facecolors at once""" self._face_color[indices] = face_colors all_indices = np.bitwise_and( np.isin(self._mesh.triangles_index[:, 0], indices), self._mesh.triangles_index[:, 1] == 0, ) self._mesh.triangles_colors[all_indices] = self._face_color[ self._mesh.triangles_index[all_indices, 0] ] if update: self._update_displayed() def update_dims_order(self, dims_order): """Updates dimensions order for all shapes. Parameters ---------- dims_order : (D,) list Order that the dimensions are rendered in. """ for index in range(len(self.shapes)): if not self.shapes[index].dims_order == dims_order: shape = self.shapes[index] shape.dims_order = dims_order self.remove(index, renumber=False) self.add(shape, shape_index=index) self._update_z_order() def update_z_index(self, index, z_index): """Updates the z order of a single shape located at index. Parameters ---------- index : int Location in list of the shape to be changed. z_index : int Specifier of z order priority. Shapes with higher z order are displayed ontop of others. """ self.shapes[index].z_index = z_index self._z_index[index] = z_index self._update_z_order() def shift(self, index, shift): """Performs a 2D shift on a single shape located at index Parameters ---------- index : int Location in list of the shape to be changed. shift : np.ndarray length 2 array specifying shift of shapes. """ self.shapes[index].shift(shift) self._update_mesh_vertices(index, edge=True, face=True) def scale(self, index, scale, center=None): """Performs a scaling on a single shape located at index Parameters ---------- index : int Location in list of the shape to be changed. scale : float, list scalar or list specifying rescaling of shape. center : list length 2 list specifying coordinate of center of scaling. """ self.shapes[index].scale(scale, center=center) shape = self.shapes[index] self.remove(index, renumber=False) self.add(shape, shape_index=index) self._update_z_order() def rotate(self, index, angle, center=None): """Performs a rotation on a single shape located at index Parameters ---------- index : int Location in list of the shape to be changed. angle : float angle specifying rotation of shape in degrees. center : list length 2 list specifying coordinate of center of rotation. """ self.shapes[index].rotate(angle, center=center) self._update_mesh_vertices(index, edge=True, face=True) def flip(self, index, axis, center=None): """Performs an vertical flip on a single shape located at index Parameters ---------- index : int Location in list of the shape to be changed. axis : int integer specifying axis of flip. `0` flips horizontal, `1` flips vertical. center : list length 2 list specifying coordinate of center of flip axes. """ self.shapes[index].flip(axis, center=center) self._update_mesh_vertices(index, edge=True, face=True) def transform(self, index, transform): """Performs a linear transform on a single shape located at index Parameters ---------- index : int Location in list of the shape to be changed. transform : np.ndarray 2x2 array specifying linear transform. """ self.shapes[index].transform(transform) shape = self.shapes[index] self.remove(index, renumber=False) self.add(shape, shape_index=index) self._update_z_order() def outline(self, indices): """Finds outlines of shapes listed in indices Parameters ---------- indices : int | list Location in list of the shapes to be outline. If list must be a list of int Returns ------- centers : np.ndarray Nx2 array of centers of outline offsets : np.ndarray Nx2 array of offsets of outline triangles : np.ndarray Mx3 array of any indices of vertices for triangles of outline """ if type(indices) is list: meshes = self._mesh.triangles_index triangle_indices = [ i for i, x in enumerate(meshes) if x[0] in indices and x[1] == 1 ] meshes = self._mesh.vertices_index vertices_indices = [ i for i, x in enumerate(meshes) if x[0] in indices and x[1] == 1 ] else: triangle_indices = np.all( self._mesh.triangles_index == [indices, 1], axis=1 ) triangle_indices = np.where(triangle_indices)[0] vertices_indices = np.all( self._mesh.vertices_index == [indices, 1], axis=1 ) vertices_indices = np.where(vertices_indices)[0] offsets = self._mesh.vertices_offsets[vertices_indices] centers = self._mesh.vertices_centers[vertices_indices] triangles = self._mesh.triangles[triangle_indices] if type(indices) is list: t_ind = self._mesh.triangles_index[triangle_indices][:, 0] inds = self._mesh.vertices_index[vertices_indices][:, 0] starts = np.unique(inds, return_index=True)[1] for i, ind in enumerate(indices): inds = t_ind == ind adjust_index = starts[i] - vertices_indices[starts[i]] triangles[inds] = triangles[inds] + adjust_index else: triangles = triangles - vertices_indices[0] return centers, offsets, triangles def shapes_in_box(self, corners): """Determines which shapes, if any, are inside an axis aligned box. Looks only at displayed shapes Parameters ---------- corners : np.ndarray 2x2 array of two corners that will be used to create an axis aligned box. Returns ------- shapes : list List of shapes that are inside the box. """ triangles = self._mesh.vertices[self._mesh.displayed_triangles] intersects = triangles_intersect_box(triangles, corners) shapes = self._mesh.displayed_triangles_index[intersects, 0] shapes = np.unique(shapes).tolist() return shapes def inside(self, coord): """Determines if any shape at given coord by looking inside triangle meshes. Looks only at displayed shapes Parameters ---------- coord : sequence of float Image coordinates to check if any shapes are at. Returns ------- shape : int | None Index of shape if any that is at the coordinates. Returns `None` if no shape is found. """ triangles = self._mesh.vertices[self._mesh.displayed_triangles] indices = inside_triangles(triangles - coord) shapes = self._mesh.displayed_triangles_index[indices, 0] if len(shapes) > 0: z_list = self._z_order.tolist() order_indices = np.array([z_list.index(m) for m in shapes]) ordered_shapes = shapes[np.argsort(order_indices)] return ordered_shapes[0] else: return None def _inside_3d(self, ray_position: np.ndarray, ray_direction: np.ndarray): """Determines if any shape is intersected by a ray by looking inside triangle meshes. Looks only at displayed shapes. Parameters ---------- ray_position : np.ndarray (3,) array containing the location that was clicked. This should be in the same coordinate system as the vertices. ray_direction : np.ndarray (3,) array describing the direction camera is pointing in the scene. This should be in the same coordinate system as the vertices. Returns ------- shape : int | None Index of shape if any that is at the coordinates. Returns `None` if no shape is found. intersection_point : Optional[np.ndarray] The point where the ray intersects the mesh face. If there was no intersection, returns None. """ triangles = self._mesh.vertices[self._mesh.displayed_triangles] inside = line_in_triangles_3d( line_point=ray_position, line_direction=ray_direction, triangles=triangles, ) intersected_shapes = self._mesh.displayed_triangles_index[inside, 0] if len(intersected_shapes) > 0: intersection_points = self._triangle_intersection( triangle_indices=inside, ray_position=ray_position, ray_direction=ray_direction, ) start_to_intersection = intersection_points - ray_position distances = np.linalg.norm(start_to_intersection, axis=1) closest_shape_index = np.argmin(distances) shape = intersected_shapes[closest_shape_index] intersection = intersection_points[closest_shape_index] return shape, intersection else: return None, None def _triangle_intersection( self, triangle_indices: np.ndarray, ray_position: np.ndarray, ray_direction: np.ndarray, ): """Find the intersection of a ray with specified triangles. Parameters ---------- triangle_indices : np.ndarray (n,) array of shape indices to find the intersection with the ray. The indices should correspond with self._mesh.displayed_triangles. ray_position : np.ndarray (3,) array with the coordinate of the starting point of the ray in layer coordinates. Only provide the 3 displayed dimensions. ray_direction : np.ndarray (3,) array of the normal direction of the ray in layer coordinates. Only provide the 3 displayed dimensions. Returns ------- intersection_points : np.ndarray (n x 3) array of the intersection of the ray with each of the specified shapes in layer coordinates. Only the 3 displayed dimensions are provided. """ triangles = self._mesh.vertices[self._mesh.displayed_triangles] intersected_triangles = triangles[triangle_indices] intersection_points = intersect_line_with_triangles( line_point=ray_position, line_direction=ray_direction, triangles=intersected_triangles, ) return intersection_points def to_masks(self, mask_shape=None, zoom_factor=1, offset=(0, 0)): """Returns N binary masks, one for each shape, embedded in an array of shape `mask_shape`. Parameters ---------- mask_shape : np.ndarray | tuple | None 2-tuple defining shape of mask to be generated. If non specified, takes the max of all the vertices zoom_factor : float Premultiplier applied to coordinates before generating mask. Used for generating as downsampled mask. offset : 2-tuple Offset subtracted from coordinates before multiplying by the zoom_factor. Used for putting negative coordinates into the mask. Returns ------- masks : (N, M, P) np.ndarray Array where there is one binary mask of shape MxP for each of N shapes """ if mask_shape is None: mask_shape = self.displayed_vertices.max(axis=0).astype('int') masks = np.array( [ s.to_mask(mask_shape, zoom_factor=zoom_factor, offset=offset) for s in self.shapes ] ) return masks def to_labels(self, labels_shape=None, zoom_factor=1, offset=(0, 0)): """Returns a integer labels image, where each shape is embedded in an array of shape labels_shape with the value of the index + 1 corresponding to it, and 0 for background. For overlapping shapes z-ordering will be respected. Parameters ---------- labels_shape : np.ndarray | tuple | None 2-tuple defining shape of labels image to be generated. If non specified, takes the max of all the vertices zoom_factor : float Premultiplier applied to coordinates before generating mask. Used for generating as downsampled mask. offset : 2-tuple Offset subtracted from coordinates before multiplying by the zoom_factor. Used for putting negative coordinates into the mask. Returns ------- labels : np.ndarray MxP integer array where each value is either 0 for background or an integer up to N for points inside the corresponding shape. """ if labels_shape is None: labels_shape = self.displayed_vertices.max(axis=0).astype(np.int) labels = np.zeros(labels_shape, dtype=int) for ind in self._z_order[::-1]: mask = self.shapes[ind].to_mask( labels_shape, zoom_factor=zoom_factor, offset=offset ) labels[mask] = ind + 1 return labels def to_colors( self, colors_shape=None, zoom_factor=1, offset=(0, 0), max_shapes=None ): """Rasterize shapes to an RGBA image array. Each shape is embedded in an array of shape `colors_shape` with the RGBA value of the shape, and 0 for background. For overlapping shapes z-ordering will be respected. Parameters ---------- colors_shape : np.ndarray | tuple | None 2-tuple defining shape of colors image to be generated. If non specified, takes the max of all the vertiecs zoom_factor : float Premultiplier applied to coordinates before generating mask. Used for generating as downsampled mask. offset : 2-tuple Offset subtracted from coordinates before multiplying by the zoom_factor. Used for putting negative coordinates into the mask. max_shapes : None | int If provided, this is the maximum number of shapes that will be rasterized. If the number of shapes in view exceeds max_shapes, max_shapes shapes will be randomly selected from the in view shapes. If set to None, no maximum is applied. The default value is None. Returns ------- colors : (N, M, 4) array rgba array where each value is either 0 for background or the rgba value of the shape for points inside the corresponding shape. """ if colors_shape is None: colors_shape = self.displayed_vertices.max(axis=0).astype(np.int) colors = np.zeros(tuple(colors_shape) + (4,), dtype=float) colors[..., 3] = 1 z_order = self._z_order[::-1] shapes_in_view = np.argwhere(self._displayed) z_order_in_view_mask = np.isin(z_order, shapes_in_view) z_order_in_view = z_order[z_order_in_view_mask] # If there are too many shapes to render responsively, just render # the top max_shapes shapes if max_shapes is not None and len(z_order_in_view) > max_shapes: z_order_in_view = z_order_in_view[0:max_shapes] for ind in z_order_in_view: mask = self.shapes[ind].to_mask( colors_shape, zoom_factor=zoom_factor, offset=offset ) if type(self.shapes[ind]) in [Path, Line]: col = self._edge_color[ind] else: col = self._face_color[ind] colors[mask, :] = col return colors napari-0.5.0a1/napari/layers/shapes/_shapes_constants.py000066400000000000000000000043221437041365600233460ustar00rootroot00000000000000import sys from enum import auto from napari.layers.shapes._shapes_models import ( Ellipse, Line, Path, Polygon, Rectangle, ) from napari.utils.misc import StringEnum class Mode(StringEnum): """Mode: Interactive mode. The normal, default mode is PAN_ZOOM, which allows for normal interactivity with the canvas. The SELECT mode allows for entire shapes to be selected, moved and resized. The DIRECT mode allows for shapes to be selected and their individual vertices to be moved. The VERTEX_INSERT and VERTEX_REMOVE modes allow for individual vertices either to be added to or removed from shapes that are already selected. Note that shapes cannot be selected in this mode. The ADD_RECTANGLE, ADD_ELLIPSE, ADD_LINE, ADD_PATH, and ADD_POLYGON modes all allow for their corresponding shape type to be added. """ PAN_ZOOM = auto() TRANSFORM = auto() SELECT = auto() DIRECT = auto() ADD_RECTANGLE = auto() ADD_ELLIPSE = auto() ADD_LINE = auto() ADD_PATH = auto() ADD_POLYGON = auto() VERTEX_INSERT = auto() VERTEX_REMOVE = auto() class ColorMode(StringEnum): """ ColorMode: Color setting mode. DIRECT (default mode) allows each shape to be set arbitrarily CYCLE allows the color to be set via a color cycle over an attribute COLORMAP allows color to be set via a color map over an attribute """ DIRECT = auto() CYCLE = auto() COLORMAP = auto() class Box: """Box: Constants associated with the vertices of the interaction box""" WITH_HANDLE = [0, 1, 2, 3, 4, 5, 6, 7, 9] LINE_HANDLE = [7, 6, 4, 2, 0, 7, 8] LINE = [0, 2, 4, 6, 0] TOP_LEFT = 0 TOP_CENTER = 7 LEFT_CENTER = 1 BOTTOM_RIGHT = 4 BOTTOM_LEFT = 2 CENTER = 8 HANDLE = 9 LEN = 8 BACKSPACE = 'delete' if sys.platform == 'darwin' else 'backspace' class ShapeType(StringEnum): """ShapeType: Valid shape type.""" RECTANGLE = auto() ELLIPSE = auto() LINE = auto() PATH = auto() POLYGON = auto() shape_classes = { ShapeType.RECTANGLE: Rectangle, ShapeType.ELLIPSE: Ellipse, ShapeType.LINE: Line, ShapeType.PATH: Path, ShapeType.POLYGON: Polygon, } napari-0.5.0a1/napari/layers/shapes/_shapes_key_bindings.py000066400000000000000000000121761437041365600240050ustar00rootroot00000000000000import numpy as np from app_model.types import KeyCode from napari.layers.shapes._shapes_constants import Box, Mode from napari.layers.shapes._shapes_mouse_bindings import _move from napari.layers.shapes.shapes import Shapes from napari.layers.utils.layer_utils import ( register_layer_action, register_layer_attr_action, ) from napari.utils.translations import trans @Shapes.bind_key(KeyCode.Shift) def hold_to_lock_aspect_ratio(layer: Shapes): """Hold to lock aspect ratio when resizing a shape.""" # on key press layer._fixed_aspect = True box = layer._selected_box if box is not None: size = box[Box.BOTTOM_RIGHT] - box[Box.TOP_LEFT] if not np.any(size == np.zeros(2)): layer._aspect_ratio = abs(size[1] / size[0]) else: layer._aspect_ratio = 1 else: layer._aspect_ratio = 1 if layer._is_moving: assert layer._moving_coordinates is not None, layer _move(layer, layer._moving_coordinates) yield # on key release layer._fixed_aspect = False if layer._is_moving: _move(layer, layer._moving_coordinates) def register_shapes_action(description: str, repeatable: bool = False): return register_layer_action(Shapes, description, repeatable) def register_shapes_mode_action(description): return register_layer_attr_action(Shapes, description, 'mode') @register_shapes_mode_action(trans._('Transform')) def activate_shapes_transform_mode(layer): layer.mode = Mode.TRANSFORM @register_shapes_mode_action(trans._('Pan/zoom')) def activate_shapes_pan_zoom_mode(layer): layer.mode = Mode.PAN_ZOOM @register_shapes_mode_action(trans._('Add rectangles')) def activate_add_rectangle_mode(layer: Shapes): """Activate add rectangle tool.""" layer.mode = Mode.ADD_RECTANGLE @register_shapes_mode_action(trans._('Add ellipses')) def activate_add_ellipse_mode(layer: Shapes): """Activate add ellipse tool.""" layer.mode = Mode.ADD_ELLIPSE @register_shapes_mode_action(trans._('Add lines')) def activate_add_line_mode(layer: Shapes): """Activate add line tool.""" layer.mode = Mode.ADD_LINE @register_shapes_mode_action(trans._('Add path')) def activate_add_path_mode(layer: Shapes): """Activate add path tool.""" layer.mode = Mode.ADD_PATH @register_shapes_mode_action(trans._('Add polygons')) def activate_add_polygon_mode(layer: Shapes): """Activate add polygon tool.""" layer.mode = Mode.ADD_POLYGON @register_shapes_mode_action(trans._('Select vertices')) def activate_direct_mode(layer: Shapes): """Activate vertex selection tool.""" layer.mode = Mode.DIRECT @register_shapes_mode_action(trans._('Select shapes')) def activate_select_mode(layer: Shapes): """Activate shape selection tool.""" layer.mode = Mode.SELECT @register_shapes_mode_action(trans._('Insert vertex')) def activate_vertex_insert_mode(layer: Shapes): """Activate vertex insertion tool.""" layer.mode = Mode.VERTEX_INSERT @register_shapes_mode_action(trans._('Remove vertex')) def activate_vertex_remove_mode(layer: Shapes): """Activate vertex deletion tool.""" layer.mode = Mode.VERTEX_REMOVE shapes_fun_to_mode = [ (activate_shapes_pan_zoom_mode, Mode.PAN_ZOOM), (activate_shapes_transform_mode, Mode.TRANSFORM), (activate_add_rectangle_mode, Mode.ADD_RECTANGLE), (activate_add_ellipse_mode, Mode.ADD_ELLIPSE), (activate_add_line_mode, Mode.ADD_LINE), (activate_add_path_mode, Mode.ADD_PATH), (activate_add_polygon_mode, Mode.ADD_POLYGON), (activate_direct_mode, Mode.DIRECT), (activate_select_mode, Mode.SELECT), (activate_vertex_insert_mode, Mode.VERTEX_INSERT), (activate_vertex_remove_mode, Mode.VERTEX_REMOVE), ] @register_shapes_action(trans._('Copy any selected shapes')) def copy_selected_shapes(layer: Shapes): """Copy any selected shapes.""" if layer._mode in (Mode.DIRECT, Mode.SELECT): layer._copy_data() @register_shapes_action(trans._('Paste any copied shapes')) def paste_shape(layer: Shapes): """Paste any copied shapes.""" if layer._mode in (Mode.DIRECT, Mode.SELECT): layer._paste_data() @register_shapes_action(trans._('Select all shapes in the current view slice')) def select_all_shapes(layer: Shapes): """Select all shapes in the current view slice.""" if layer._mode in (Mode.DIRECT, Mode.SELECT): layer.selected_data = set(np.nonzero(layer._data_view._displayed)[0]) layer._set_highlight() @register_shapes_action(trans._('Delete any selected shapes')) def delete_selected_shapes(layer: Shapes): """.""" if not layer._is_creating: layer.remove_selected() @register_shapes_action(trans._('Move to front')) def move_shapes_selection_to_front(layer: Shapes): layer.move_to_front() @register_shapes_action(trans._('Move to back')) def move_shapes_selection_to_back(layer: Shapes): layer.move_to_back() @register_shapes_action( trans._( 'Finish any drawing, for example when using the path or polygon tool.' ), ) def finish_drawing_shape(layer: Shapes): """Finish any drawing, for example when using the path or polygon tool.""" layer._finish_drawing() napari-0.5.0a1/napari/layers/shapes/_shapes_models/000077500000000000000000000000001437041365600222425ustar00rootroot00000000000000napari-0.5.0a1/napari/layers/shapes/_shapes_models/__init__.py000066400000000000000000000006741437041365600243620ustar00rootroot00000000000000from napari.layers.shapes._shapes_models.ellipse import Ellipse from napari.layers.shapes._shapes_models.line import Line from napari.layers.shapes._shapes_models.path import Path from napari.layers.shapes._shapes_models.polygon import Polygon from napari.layers.shapes._shapes_models.rectangle import Rectangle from napari.layers.shapes._shapes_models.shape import Shape __all__ = ["Ellipse", "Line", "Path", "Polygon", "Rectangle", "Shape"] napari-0.5.0a1/napari/layers/shapes/_shapes_models/_polgyon_base.py000066400000000000000000000076161437041365600254460ustar00rootroot00000000000000import numpy as np from scipy.interpolate import splev, splprep from napari.layers.shapes._shapes_models.shape import Shape from napari.layers.shapes._shapes_utils import create_box from napari.utils.translations import trans class PolygonBase(Shape): """Class for a polygon or path. Parameters ---------- data : np.ndarray NxD array of vertices specifying the path. edge_width : float thickness of lines and edges. z_index : int Specifier of z order priority. Shapes with higher z order are displayed ontop of others. dims_order : (D,) list Order that the dimensions are to be rendered in. closed : bool Bool if shape edge is a closed path or not. filled : bool Flag if array is filled or not. name : str Name of the shape. interpolation_order : int Order of spline interpolation for the sides. 1 means no interpolation. """ def __init__( self, data, *, edge_width=1, z_index=0, dims_order=None, ndisplay=2, filled=True, closed=True, name='polygon', interpolation_order=1, interpolation_sampling=50, ) -> None: super().__init__( edge_width=edge_width, z_index=z_index, dims_order=dims_order, ndisplay=ndisplay, ) self._filled = filled self._closed = closed self.name = name self.interpolation_order = interpolation_order self.interpolation_sampling = interpolation_sampling self.data = data @property def data(self): """np.ndarray: NxD array of vertices.""" return self._data @data.setter def data(self, data): data = np.array(data).astype(float) if len(self.dims_order) != data.shape[1]: self._dims_order = list(range(data.shape[1])) if len(data) < 2: raise ValueError( trans._( "Shape needs at least two vertices, {number} provided.", deferred=True, number=len(data), ) ) self._data = data self._update_displayed_data() def _update_displayed_data(self): """Update the data that is to be displayed.""" # Raw vertices data = self.data_displayed # splprep fails if two adjacent values are identical, which happens # when a point was just created and the new potential point is set to exactly the same # to prevent issues, we remove the extra points. duplicates = np.isclose(data, np.roll(data, 1, axis=0)) # cannot index with bools directly (flattens by design) data_spline = data[~np.all(duplicates, axis=1)] if ( self.interpolation_order > 1 and len(data_spline) > self.interpolation_order ): data = data_spline.copy() if self._closed: data = np.append(data, data[:1], axis=0) tck, _ = splprep( data.T, s=0, k=self.interpolation_order, per=self._closed ) # the number of sampled data points might need to be carefully thought # about (might need to change with image scale?) u = np.linspace(0, 1, self.interpolation_sampling * len(data)) # get interpolated data (discard last element which is a copy) data = np.stack(splev(u, tck), axis=1)[:-1] # For path connect every all data self._set_meshes(data, face=self._filled, closed=self._closed) self._box = create_box(self.data_displayed) data_not_displayed = self.data[:, self.dims_not_displayed] self.slice_key = np.round( [ np.min(data_not_displayed, axis=0), np.max(data_not_displayed, axis=0), ] ).astype('int') napari-0.5.0a1/napari/layers/shapes/_shapes_models/_tests/000077500000000000000000000000001437041365600235435ustar00rootroot00000000000000napari-0.5.0a1/napari/layers/shapes/_shapes_models/_tests/test_shapes_models.py000066400000000000000000000117621437041365600300110ustar00rootroot00000000000000import numpy as np from napari.layers.shapes._shapes_models import ( Ellipse, Line, Path, Polygon, Rectangle, ) def test_rectangle(): """Test creating Shape with a random rectangle.""" # Test a single four corner rectangle np.random.seed(0) data = 20 * np.random.random((4, 2)) shape = Rectangle(data) assert np.all(shape.data == data) assert shape.data_displayed.shape == (4, 2) assert shape.slice_key.shape == (2, 0) # If given two corners, representation will be exapanded to four data = 20 * np.random.random((2, 2)) shape = Rectangle(data) assert len(shape.data) == 4 assert shape.data_displayed.shape == (4, 2) assert shape.slice_key.shape == (2, 0) def test_nD_rectangle(): """Test creating Shape with a random nD rectangle.""" # Test a single four corner planar 3D rectangle np.random.seed(0) data = 20 * np.random.random((4, 3)) data[:, 0] = 0 shape = Rectangle(data) assert np.all(shape.data == data) assert shape.data_displayed.shape == (4, 2) assert shape.slice_key.shape == (2, 1) shape.ndisplay = 3 assert shape.data_displayed.shape == (4, 3) def test_polygon(): """Test creating Shape with a random polygon.""" # Test a single six vertex polygon np.random.seed(0) data = 20 * np.random.random((6, 2)) shape = Polygon(data) assert np.all(shape.data == data) assert shape.data_displayed.shape == (6, 2) assert shape.slice_key.shape == (2, 0) # should get few triangles assert shape._edge_vertices.shape == (16, 2) assert shape._face_vertices.shape == (8, 2) data = np.array([[0, 0], [0, 1], [1, 1], [1, 0]]) shape = Polygon(data, interpolation_order=3) # should get many triangles assert shape._edge_vertices.shape == (500, 2) assert shape._face_vertices.shape == (251, 2) data = np.array([[0, 0, 0], [0, 0, 1], [0, 1, 1], [1, 1, 1]]) shape = Polygon(data, interpolation_order=3, ndisplay=3) # should get many vertices assert shape._edge_vertices.shape == (2500, 3) # faces are not made for non-coplanar 3d stuff assert shape._face_vertices.shape == (0, 3) def test_nD_polygon(): """Test creating Shape with a random nD polygon.""" # Test a single six vertex planar 3D polygon np.random.seed(0) data = 20 * np.random.random((6, 3)) data[:, 0] = 0 shape = Polygon(data) assert np.all(shape.data == data) assert shape.data_displayed.shape == (6, 2) assert shape.slice_key.shape == (2, 1) shape.ndisplay = 3 assert shape.data_displayed.shape == (6, 3) def test_path(): """Test creating Shape with a random path.""" # Test a single six vertex path np.random.seed(0) data = 20 * np.random.random((6, 2)) shape = Path(data) assert np.all(shape.data == data) assert shape.data_displayed.shape == (6, 2) assert shape.slice_key.shape == (2, 0) def test_nD_path(): """Test creating Shape with a random nD path.""" # Test a single six vertex 3D path np.random.seed(0) data = 20 * np.random.random((6, 3)) shape = Path(data) assert np.all(shape.data == data) assert shape.data_displayed.shape == (6, 2) assert shape.slice_key.shape == (2, 1) shape.ndisplay = 3 assert shape.data_displayed.shape == (6, 3) def test_line(): """Test creating Shape with a random line.""" # Test a single two vertex line np.random.seed(0) data = 20 * np.random.random((2, 2)) shape = Line(data) assert np.all(shape.data == data) assert shape.data_displayed.shape == (2, 2) assert shape.slice_key.shape == (2, 0) def test_nD_line(): """Test creating Shape with a random nD line.""" # Test a single two vertex 3D line np.random.seed(0) data = 20 * np.random.random((2, 3)) shape = Line(data) assert np.all(shape.data == data) assert shape.data_displayed.shape == (2, 2) assert shape.slice_key.shape == (2, 1) shape.ndisplay = 3 assert shape.data_displayed.shape == (2, 3) def test_ellipse(): """Test creating Shape with a random ellipse.""" # Test a single four corner ellipse np.random.seed(0) data = 20 * np.random.random((4, 2)) shape = Ellipse(data) assert np.all(shape.data == data) assert shape.data_displayed.shape == (4, 2) assert shape.slice_key.shape == (2, 0) # If center radii, representation will be exapanded to four corners data = 20 * np.random.random((2, 2)) shape = Ellipse(data) assert len(shape.data) == 4 assert shape.data_displayed.shape == (4, 2) assert shape.slice_key.shape == (2, 0) def test_nD_ellipse(): """Test creating Shape with a random nD ellipse.""" # Test a single four corner planar 3D ellipse np.random.seed(0) data = 20 * np.random.random((4, 3)) data[:, 0] = 0 shape = Ellipse(data) assert np.all(shape.data == data) assert shape.data_displayed.shape == (4, 2) assert shape.slice_key.shape == (2, 1) shape.ndisplay = 3 assert shape.data_displayed.shape == (4, 3) napari-0.5.0a1/napari/layers/shapes/_shapes_models/ellipse.py000066400000000000000000000070431437041365600242550ustar00rootroot00000000000000import numpy as np from napari.layers.shapes._shapes_models.shape import Shape from napari.layers.shapes._shapes_utils import ( center_radii_to_corners, rectangle_to_box, triangulate_edge, triangulate_ellipse, ) from napari.utils.translations import trans class Ellipse(Shape): """Class for a single ellipse Parameters ---------- data : (4, D) array or (2, 2) array. Either a (2, 2) array specifying the center and radii of an axis aligned ellipse, or a (4, D) array specifying the four corners of a bounding box that contains the ellipse. These need not be axis aligned. edge_width : float thickness of lines and edges. opacity : float Opacity of the shape, must be between 0 and 1. z_index : int Specifier of z order priority. Shapes with higher z order are displayed ontop of others. dims_order : (D,) list Order that the dimensions are to be rendered in. """ def __init__( self, data, *, edge_width=1, opacity=1, z_index=0, dims_order=None, ndisplay=2, ) -> None: super().__init__( edge_width=edge_width, z_index=z_index, dims_order=dims_order, ndisplay=ndisplay, ) self._closed = True self._use_face_vertices = True self.data = data self.name = 'ellipse' @property def data(self): """(4, D) array: ellipse vertices.""" return self._data @data.setter def data(self, data): data = np.array(data).astype(float) if len(self.dims_order) != data.shape[1]: self._dims_order = list(range(data.shape[1])) if len(data) == 2 and data.shape[1] == 2: data = center_radii_to_corners(data[0], data[1]) if len(data) != 4: raise ValueError( trans._( "Data shape does not match a ellipse. Ellipse expects four corner vertices, {number} provided.", deferred=True, number=len(data), ) ) self._data = data self._update_displayed_data() def _update_displayed_data(self): """Update the data that is to be displayed.""" # Build boundary vertices with num_segments vertices, triangles = triangulate_ellipse(self.data_displayed) self._set_meshes(vertices[1:-1], face=False) self._face_vertices = vertices self._face_triangles = triangles self._box = rectangle_to_box(self.data_displayed) data_not_displayed = self.data[:, self.dims_not_displayed] self.slice_key = np.round( [ np.min(data_not_displayed, axis=0), np.max(data_not_displayed, axis=0), ] ).astype('int') def transform(self, transform): """Performs a linear transform on the shape Parameters ---------- transform : np.ndarray 2x2 array specifying linear transform. """ self._box = self._box @ transform.T self._data[:, self.dims_displayed] = ( self._data[:, self.dims_displayed] @ transform.T ) self._face_vertices = self._face_vertices @ transform.T points = self._face_vertices[1:-1] centers, offsets, triangles = triangulate_edge( points, closed=self._closed ) self._edge_vertices = centers self._edge_offsets = offsets self._edge_triangles = triangles napari-0.5.0a1/napari/layers/shapes/_shapes_models/line.py000066400000000000000000000042111437041365600235410ustar00rootroot00000000000000import numpy as np from napari.layers.shapes._shapes_models.shape import Shape from napari.layers.shapes._shapes_utils import create_box from napari.utils.translations import trans class Line(Shape): """Class for a single line segment Parameters ---------- data : (2, D) array Line vertices. edge_width : float thickness of lines and edges. z_index : int Specifier of z order priority. Shapes with higher z order are displayed ontop of others. dims_order : (D,) list Order that the dimensions are to be rendered in. """ def __init__( self, data, *, edge_width=1, z_index=0, dims_order=None, ndisplay=2, ) -> None: super().__init__( edge_width=edge_width, z_index=z_index, dims_order=dims_order, ndisplay=ndisplay, ) self._filled = False self.data = data self.name = 'line' @property def data(self): """(2, D) array: line vertices.""" return self._data @data.setter def data(self, data): data = np.array(data).astype(float) if len(self.dims_order) != data.shape[1]: self._dims_order = list(range(data.shape[1])) if len(data) != 2: raise ValueError( trans._( "Data shape does not match a line. A line expects two end vertices, {number} provided.", deferred=True, number=len(data), ) ) self._data = data self._update_displayed_data() def _update_displayed_data(self): """Update the data that is to be displayed.""" # For path connect every all data self._set_meshes(self.data_displayed, face=False, closed=False) self._box = create_box(self.data_displayed) data_not_displayed = self.data[:, self.dims_not_displayed] self.slice_key = np.round( [ np.min(data_not_displayed, axis=0), np.max(data_not_displayed, axis=0), ] ).astype('int') napari-0.5.0a1/napari/layers/shapes/_shapes_models/path.py000066400000000000000000000020401437041365600235440ustar00rootroot00000000000000from napari.layers.shapes._shapes_models._polgyon_base import PolygonBase class Path(PolygonBase): """Class for a single path, which is a sequence of line segments. Parameters ---------- data : np.ndarray NxD array of vertices specifying the path. edge_width : float thickness of lines and edges. z_index : int Specifier of z order priority. Shapes with higher z order are displayed ontop of others. dims_order : (D,) list Order that the dimensions are to be rendered in. """ def __init__( self, data, *, edge_width=1, z_index=0, dims_order=None, ndisplay=2, interpolation_order=1, ) -> None: super().__init__( data, edge_width=edge_width, z_index=z_index, dims_order=dims_order, ndisplay=ndisplay, filled=False, closed=False, name='path', interpolation_order=interpolation_order, ) napari-0.5.0a1/napari/layers/shapes/_shapes_models/polygon.py000066400000000000000000000020061437041365600243010ustar00rootroot00000000000000from napari.layers.shapes._shapes_models._polgyon_base import PolygonBase class Polygon(PolygonBase): """Class for a single polygon Parameters ---------- data : np.ndarray NxD array of vertices specifying the shape. edge_width : float thickness of lines and edges. z_index : int Specifier of z order priority. Shapes with higher z order are displayed ontop of others. dims_order : (D,) list Order that the dimensions are to be rendered in. """ def __init__( self, data, *, edge_width=1, z_index=0, dims_order=None, ndisplay=2, interpolation_order=1, ) -> None: super().__init__( data=data, edge_width=edge_width, z_index=z_index, dims_order=dims_order, ndisplay=ndisplay, closed=True, filled=True, name='polygon', interpolation_order=interpolation_order, ) napari-0.5.0a1/napari/layers/shapes/_shapes_models/rectangle.py000066400000000000000000000051771437041365600245720ustar00rootroot00000000000000import numpy as np from napari.layers.shapes._shapes_models.shape import Shape from napari.layers.shapes._shapes_utils import find_corners, rectangle_to_box from napari.utils.translations import trans class Rectangle(Shape): """Class for a single rectangle Parameters ---------- data : (4, D) or (2, 2) array Either a (2, 2) array specifying the two corners of an axis aligned rectangle, or a (4, D) array specifying the four corners of a bounding box that contains the rectangle. These need not be axis aligned. edge_width : float thickness of lines and edges. z_index : int Specifier of z order priority. Shapes with higher z order are displayed ontop of others. dims_order : (D,) list Order that the dimensions are to be rendered in. """ def __init__( self, data, *, edge_width=1, z_index=0, dims_order=None, ndisplay=2, ) -> None: super().__init__( edge_width=edge_width, z_index=z_index, dims_order=dims_order, ndisplay=ndisplay, ) self._closed = True self.data = data self.name = 'rectangle' @property def data(self): """(4, D) array: rectangle vertices.""" return self._data @data.setter def data(self, data): data = np.array(data).astype(float) if len(self.dims_order) != data.shape[1]: self._dims_order = list(range(data.shape[1])) if len(data) == 2 and data.shape[1] == 2: data = find_corners(data) if len(data) != 4: print(data) raise ValueError( trans._( "Data shape does not match a rectangle. Rectangle expects four corner vertices, {number} provided.", deferred=True, number=len(data), ) ) self._data = data self._update_displayed_data() def _update_displayed_data(self): """Update the data that is to be displayed.""" # Add four boundary lines and then two triangles for each self._set_meshes(self.data_displayed, face=False) self._face_vertices = self.data_displayed self._face_triangles = np.array([[0, 1, 2], [0, 2, 3]]) self._box = rectangle_to_box(self.data_displayed) data_not_displayed = self.data[:, self.dims_not_displayed] self.slice_key = np.round( [ np.min(data_not_displayed, axis=0), np.max(data_not_displayed, axis=0), ] ).astype('int') napari-0.5.0a1/napari/layers/shapes/_shapes_models/shape.py000066400000000000000000000344241437041365600237230ustar00rootroot00000000000000from abc import ABC, abstractmethod import numpy as np from napari.layers.shapes._shapes_utils import ( is_collinear, path_to_mask, poly_to_mask, triangulate_edge, triangulate_face, ) from napari.utils.misc import argsort from napari.utils.translations import trans class Shape(ABC): """Base class for a single shape Parameters ---------- data : (N, D) array Vertices specifying the shape. edge_width : float thickness of lines and edges. z_index : int Specifier of z order priority. Shapes with higher z order are displayed ontop of others. dims_order : (D,) list Order that the dimensions are to be rendered in. ndisplay : int Number of displayed dimensions. Attributes ---------- data : (N, D) array Vertices specifying the shape. data_displayed : (N, 2) array Vertices of the shape that are currently displayed. Only 2D rendering currently supported. edge_width : float thickness of lines and edges. name : str Name of shape type. z_index : int Specifier of z order priority. Shapes with higher z order are displayed ontop of others. dims_order : (D,) list Order that the dimensions are rendered in. ndisplay : int Number of dimensions to be displayed, must be 2 as only 2D rendering currently supported. displayed : tuple List of dimensions that are displayed. not_displayed : tuple List of dimensions that are not displayed. slice_key : (2, M) array Min and max values of the M non-displayed dimensions, useful for slicing multidimensional shapes. Notes ----- _closed : bool Bool if shape edge is a closed path or not _box : np.ndarray 9x2 array of vertices of the interaction box. The first 8 points are the corners and midpoints of the box in clockwise order starting in the upper-left corner. The last point is the center of the box _face_vertices : np.ndarray Qx2 array of vertices of all triangles for the shape face _face_triangles : np.ndarray Px3 array of vertex indices that form the triangles for the shape face _edge_vertices : np.ndarray Rx2 array of centers of vertices of triangles for the shape edge. These values should be added to the scaled `_edge_offsets` to get the actual vertex positions. The scaling corresponds to the width of the edge _edge_offsets : np.ndarray Sx2 array of offsets of vertices of triangles for the shape edge. For These values should be scaled and added to the `_edge_vertices` to get the actual vertex positions. The scaling corresponds to the width of the edge _edge_triangles : np.ndarray Tx3 array of vertex indices that form the triangles for the shape edge _filled : bool Flag if array is filled or not. _use_face_vertices : bool Flag to use face vertices for mask generation. """ def __init__( self, *, shape_type='rectangle', edge_width=1, z_index=0, dims_order=None, ndisplay=2, ) -> None: self._dims_order = dims_order or list(range(2)) self._ndisplay = ndisplay self.slice_key = None self._face_vertices = np.empty((0, self.ndisplay)) self._face_triangles = np.empty((0, 3), dtype=np.uint32) self._edge_vertices = np.empty((0, self.ndisplay)) self._edge_offsets = np.empty((0, self.ndisplay)) self._edge_triangles = np.empty((0, 3), dtype=np.uint32) self._box = np.empty((9, 2)) self._closed = False self._filled = True self._use_face_vertices = False self.edge_width = edge_width self.z_index = z_index self.name = '' @property @abstractmethod def data(self): # user writes own docstring raise NotImplementedError() @data.setter @abstractmethod def data(self, data): raise NotImplementedError() @abstractmethod def _update_displayed_data(self): raise NotImplementedError() @property def ndisplay(self): """int: Number of displayed dimensions.""" return self._ndisplay @ndisplay.setter def ndisplay(self, ndisplay): if self.ndisplay == ndisplay: return self._ndisplay = ndisplay self._update_displayed_data() @property def dims_order(self): """(D,) list: Order that the dimensions are rendered in.""" return self._dims_order @dims_order.setter def dims_order(self, dims_order): if self.dims_order == dims_order: return self._dims_order = dims_order self._update_displayed_data() @property def dims_displayed(self): """tuple: Dimensions that are displayed.""" return self.dims_order[-self.ndisplay :] @property def dims_not_displayed(self): """tuple: Dimensions that are not displayed.""" return self.dims_order[: -self.ndisplay] @property def data_displayed(self): """(N, 2) array: Vertices of the shape that are currently displayed.""" return self.data[:, self.dims_displayed] @property def edge_width(self): """float: thickness of lines and edges.""" return self._edge_width @edge_width.setter def edge_width(self, edge_width): self._edge_width = edge_width @property def z_index(self): """int: z order priority of shape. Shapes with higher z order displayed ontop of others. """ return self._z_index @z_index.setter def z_index(self, z_index): self._z_index = z_index def _set_meshes(self, data, closed=True, face=True, edge=True): """Sets the face and edge meshes from a set of points. Parameters ---------- data : np.ndarray Nx2 or Nx3 array specifying the shape to be triangulated closed : bool Bool which determines if the edge is closed or not face : bool Bool which determines if the face need to be traingulated edge : bool Bool which determines if the edge need to be traingulated """ if edge: centers, offsets, triangles = triangulate_edge(data, closed=closed) self._edge_vertices = centers self._edge_offsets = offsets self._edge_triangles = triangles else: self._edge_vertices = np.empty((0, self.ndisplay)) self._edge_offsets = np.empty((0, self.ndisplay)) self._edge_triangles = np.empty((0, 3), dtype=np.uint32) if face: idx = np.concatenate( [[True], ~np.all(data[1:] == data[:-1], axis=-1)] ) clean_data = data[idx].copy() if not is_collinear(clean_data[:, -2:]): if clean_data.shape[1] == 2: vertices, triangles = triangulate_face(clean_data) elif len(np.unique(clean_data[:, 0])) == 1: val = np.unique(clean_data[:, 0]) vertices, triangles = triangulate_face(clean_data[:, -2:]) exp = np.expand_dims(np.repeat(val, len(vertices)), axis=1) vertices = np.concatenate([exp, vertices], axis=1) else: triangles = [] vertices = [] if len(triangles) > 0: self._face_vertices = vertices self._face_triangles = triangles else: self._face_vertices = np.empty((0, self.ndisplay)) self._face_triangles = np.empty((0, 3), dtype=np.uint32) else: self._face_vertices = np.empty((0, self.ndisplay)) self._face_triangles = np.empty((0, 3), dtype=np.uint32) else: self._face_vertices = np.empty((0, self.ndisplay)) self._face_triangles = np.empty((0, 3), dtype=np.uint32) def transform(self, transform): """Performs a linear transform on the shape Parameters ---------- transform : np.ndarray 2x2 array specifying linear transform. """ self._box = self._box @ transform.T self._data[:, self.dims_displayed] = ( self._data[:, self.dims_displayed] @ transform.T ) self._face_vertices = self._face_vertices @ transform.T points = self.data_displayed centers, offsets, triangles = triangulate_edge( points, closed=self._closed ) self._edge_vertices = centers self._edge_offsets = offsets self._edge_triangles = triangles def shift(self, shift): """Performs a 2D shift on the shape Parameters ---------- shift : np.ndarray length 2 array specifying shift of shapes. """ shift = np.array(shift) self._face_vertices = self._face_vertices + shift self._edge_vertices = self._edge_vertices + shift self._box = self._box + shift self._data[:, self.dims_displayed] = self.data_displayed + shift def scale(self, scale, center=None): """Performs a scaling on the shape Parameters ---------- scale : float, list scalar or list specifying rescaling of shape. center : list length 2 list specifying coordinate of center of scaling. """ if isinstance(scale, (list, np.ndarray)): transform = np.array([[scale[0], 0], [0, scale[1]]]) else: transform = np.array([[scale, 0], [0, scale]]) if center is None: self.transform(transform) else: self.shift(-center) self.transform(transform) self.shift(center) def rotate(self, angle, center=None): """Performs a rotation on the shape Parameters ---------- angle : float angle specifying rotation of shape in degrees. CCW is positive. center : list length 2 list specifying coordinate of fixed point of the rotation. """ theta = np.radians(angle) transform = np.array( [[np.cos(theta), np.sin(theta)], [-np.sin(theta), np.cos(theta)]] ) if center is None: self.transform(transform) else: self.shift(-center) self.transform(transform) self.shift(center) def flip(self, axis, center=None): """Performs a flip on the shape, either horizontal or vertical. Parameters ---------- axis : int integer specifying axis of flip. `0` flips horizontal, `1` flips vertical. center : list length 2 list specifying coordinate of center of flip axes. """ if axis == 0: transform = np.array([[1, 0], [0, -1]]) elif axis == 1: transform = np.array([[-1, 0], [0, 1]]) else: raise ValueError( trans._( 'Axis not recognized, must be one of "{{0, 1}}"', deferred=True, ) ) if center is None: self.transform(transform) else: self.shift(-center) self.transform(transform) self.shift(-center) def to_mask(self, mask_shape=None, zoom_factor=1, offset=(0, 0)): """Convert the shape vertices to a boolean mask. Set points to `True` if they are lying inside the shape if the shape is filled, or if they are lying along the boundary of the shape if the shape is not filled. Negative points or points outside the mask_shape after the zoom and offset are clipped. Parameters ---------- mask_shape : (D,) array Shape of mask to be generated. If non specified, takes the max of the displayed vertices. zoom_factor : float Premultiplier applied to coordinates before generating mask. Used for generating as downsampled mask. offset : 2-tuple Offset subtracted from coordinates before multiplying by the zoom_factor. Used for putting negative coordinates into the mask. Returns ------- mask : np.ndarray Boolean array with `True` for points inside the shape """ if mask_shape is None: mask_shape = np.round(self.data_displayed.max(axis=0)).astype( 'int' ) if len(mask_shape) == 2: embedded = False shape_plane = mask_shape elif len(mask_shape) == self.data.shape[1]: embedded = True shape_plane = [mask_shape[d] for d in self.dims_displayed] else: raise ValueError( trans._( "mask shape length must either be 2 or the same as the dimensionality of the shape, expected {expected} got {received}.", deferred=True, expected=self.data.shape[1], received=len(mask_shape), ) ) if self._use_face_vertices: data = self._face_vertices else: data = self.data_displayed data = data[:, -len(shape_plane) :] if self._filled: mask_p = poly_to_mask(shape_plane, (data - offset) * zoom_factor) else: mask_p = path_to_mask(shape_plane, (data - offset) * zoom_factor) # If the mask is to be embedded in a larger array, compute array # and embed as a slice. if embedded: mask = np.zeros(mask_shape, dtype=bool) slice_key = [0] * len(mask_shape) j = 0 for i in range(len(mask_shape)): if i in self.dims_displayed: slice_key[i] = slice(None) else: slice_key[i] = slice( self.slice_key[0, j], self.slice_key[1, j] + 1 ) j += 1 displayed_order = argsort(self.dims_displayed) mask[tuple(slice_key)] = mask_p.transpose(displayed_order) else: mask = mask_p return mask napari-0.5.0a1/napari/layers/shapes/_shapes_mouse_bindings.py000066400000000000000000000456011437041365600243440ustar00rootroot00000000000000from copy import copy import numpy as np from napari.layers.shapes._shapes_constants import Box, Mode from napari.layers.shapes._shapes_models import ( Ellipse, Line, Path, Polygon, Rectangle, ) from napari.layers.shapes._shapes_utils import point_to_lines def highlight(layer, event): """Highlight hovered shapes.""" layer._set_highlight() def select(layer, event): """Select shapes or vertices either in select or direct select mode. Once selected shapes can be moved or resized, and vertices can be moved depending on the mode. Holding shift when resizing a shape will preserve the aspect ratio. """ shift = 'Shift' in event.modifiers # on press value = layer.get_value(event.position, world=True) layer._moving_value = copy(value) shape_under_cursor, vertex_under_cursor = value if vertex_under_cursor is None: if shift and shape_under_cursor is not None: if shape_under_cursor in layer.selected_data: layer.selected_data.remove(shape_under_cursor) else: if len(layer.selected_data): # one or more shapes already selected layer.selected_data.add(shape_under_cursor) else: # first shape being selected layer.selected_data = {shape_under_cursor} elif shape_under_cursor is not None: if shape_under_cursor not in layer.selected_data: layer.selected_data = {shape_under_cursor} else: layer.selected_data = set() layer._set_highlight() # we don't update the thumbnail unless a shape has been moved update_thumbnail = False # Set _drag_start value here to prevent an offset when mouse_move happens # https://github.com/napari/napari/pull/4999 _set_drag_start(layer, layer.world_to_data(event.position)) yield # on move while event.type == 'mouse_move': coordinates = layer.world_to_data(event.position) # ToDo: Need to pass moving_coordinates to allow fixed aspect ratio # keybinding to work, this should be dropped layer._moving_coordinates = coordinates # Drag any selected shapes if len(layer.selected_data) == 0: _drag_selection_box(layer, coordinates) else: _move(layer, coordinates) # if a shape is being moved, update the thumbnail if layer._is_moving: update_thumbnail = True yield # only emit data once dragging has finished if layer._is_moving: layer.events.data(value=layer.data) # on release shift = 'Shift' in event.modifiers if not layer._is_moving and not layer._is_selecting and not shift: if shape_under_cursor is not None: layer.selected_data = {shape_under_cursor} else: layer.selected_data = set() elif layer._is_selecting: layer.selected_data = layer._data_view.shapes_in_box(layer._drag_box) layer._is_selecting = False layer._set_highlight() layer._is_moving = False layer._drag_start = None layer._drag_box = None layer._fixed_vertex = None layer._moving_value = (None, None) layer._set_highlight() if update_thumbnail: layer._update_thumbnail() def add_line(layer, event): """Add a line.""" size = layer._vertex_size * layer.scale_factor / 4 full_size = np.zeros(layer.ndim, dtype=float) for i in layer._slice_input.displayed: full_size[i] = size coordinates = layer.world_to_data(event.position) layer._moving_coordinates = coordinates corner = np.array(coordinates) data = np.array([corner, corner + full_size]) yield from _add_line_rectangle_ellipse( layer, event, data=data, shape_type='line' ) def add_ellipse(layer, event): """Add an ellipse.""" size = layer._vertex_size * layer.scale_factor / 4 size_h = np.zeros(layer.ndim, dtype=float) size_h[layer._slice_input.displayed[0]] = size size_v = np.zeros(layer.ndim, dtype=float) size_v[layer._slice_input.displayed[1]] = size coordinates = layer.world_to_data(event.position) corner = np.array(coordinates) data = np.array( [corner, corner + size_v, corner + size_h + size_v, corner + size_h] ) yield from _add_line_rectangle_ellipse( layer, event, data=data, shape_type='ellipse' ) def add_rectangle(layer, event): """Add a rectangle.""" size = layer._vertex_size * layer.scale_factor / 4 size_h = np.zeros(layer.ndim, dtype=float) size_h[layer._slice_input.displayed[0]] = size size_v = np.zeros(layer.ndim, dtype=float) size_v[layer._slice_input.displayed[1]] = size coordinates = layer.world_to_data(event.position) corner = np.array(coordinates) data = np.array( [corner, corner + size_v, corner + size_h + size_v, corner + size_h] ) yield from _add_line_rectangle_ellipse( layer, event, data=data, shape_type='rectangle' ) def _add_line_rectangle_ellipse(layer, event, data, shape_type): """Helper function for adding a line, rectangle or ellipse.""" # on press # Start drawing rectangle / ellipse / line layer.add(data, shape_type=shape_type) layer.selected_data = {layer.nshapes - 1} layer._value = (layer.nshapes - 1, 4) layer._moving_value = copy(layer._value) layer.refresh() yield # on move while event.type == 'mouse_move': # Drag any selected shapes coordinates = layer.world_to_data(event.position) layer._moving_coordinates = coordinates _move(layer, coordinates) yield # on release layer._finish_drawing() def finish_drawing_shape(layer, event): """ finish drawing the current shape """ layer._finish_drawing() def add_path_polygon(layer, event): """Add a path or polygon.""" # on press coordinates = layer.world_to_data(event.position) if layer._is_creating is False: # Start drawing a path data = np.array([coordinates, coordinates]) layer.add(data, shape_type='path') layer.selected_data = {layer.nshapes - 1} layer._value = (layer.nshapes - 1, 1) layer._moving_value = copy(layer._value) layer._is_creating = True layer._set_highlight() else: # Add to an existing path or polygon index = layer._moving_value[0] if layer._mode == Mode.ADD_POLYGON: new_type = Polygon else: new_type = None vertices = layer._data_view.shapes[index].data vertices = np.concatenate((vertices, [coordinates]), axis=0) # Change the selected vertex value = layer.get_value(event.position, world=True) layer._value = (value[0], value[1] + 1) layer._moving_value = copy(layer._value) layer._data_view.edit(index, vertices, new_type=new_type) layer._selected_box = layer.interaction_box(layer.selected_data) def add_path_polygon_creating(layer, event): """While a path or polygon move next vertex to be added.""" if layer._is_creating: coordinates = layer.world_to_data(event.position) _move(layer, coordinates) def vertex_insert(layer, event): """Insert a vertex into a selected shape. The vertex will get inserted in between the vertices of the closest edge from all the edges in selected shapes. Vertices cannot be inserted into Ellipses. """ # Determine all the edges in currently selected shapes all_edges = np.empty((0, 2, 2)) all_edges_shape = np.empty((0, 2), dtype=int) for index in layer.selected_data: shape_type = type(layer._data_view.shapes[index]) if shape_type == Ellipse: # Adding vertex to ellipse not implemented pass else: vertices = layer._data_view.displayed_vertices[ layer._data_view.displayed_index == index ] # Find which edge new vertex should inserted along closed = shape_type != Path n = len(vertices) if closed: lines = np.array( [[vertices[i], vertices[(i + 1) % n]] for i in range(n)] ) else: lines = np.array( [[vertices[i], vertices[i + 1]] for i in range(n - 1)] ) all_edges = np.append(all_edges, lines, axis=0) indices = np.array( [np.repeat(index, len(lines)), list(range(len(lines)))] ).T all_edges_shape = np.append(all_edges_shape, indices, axis=0) if len(all_edges) == 0: # No appropriate edges were found return # Determine the closet edge to the current cursor coordinate coordinates = layer.world_to_data(event.position) coord = [coordinates[i] for i in layer._slice_input.displayed] ind, loc = point_to_lines(coord, all_edges) index = all_edges_shape[ind][0] ind = all_edges_shape[ind][1] + 1 shape_type = type(layer._data_view.shapes[index]) if shape_type == Line: # Adding vertex to line turns it into a path new_type = Path elif shape_type == Rectangle: # Adding vertex to rectangle turns it into a polygon new_type = Polygon else: new_type = None closed = shape_type != Path vertices = layer._data_view.shapes[index].data if not closed: if int(ind) == 1 and loc < 0: ind = 0 elif int(ind) == len(vertices) - 1 and loc > 1: ind = ind + 1 # Insert new vertex at appropriate place in vertices of target shape vertices = np.insert(vertices, ind, [coordinates], axis=0) with layer.events.set_data.blocker(): layer._data_view.edit(index, vertices, new_type=new_type) layer._selected_box = layer.interaction_box(layer.selected_data) layer.refresh() def vertex_remove(layer, event): """Remove a vertex from a selected shape. If a vertex is clicked on remove it from the shape it is in. If this cause the shape to shrink to a size that no longer is valid remove the whole shape. """ value = layer.get_value(event.position, world=True) shape_under_cursor, vertex_under_cursor = value if vertex_under_cursor is None: # No vertex was clicked on so return return # Have clicked on a current vertex so remove shape_type = type(layer._data_view.shapes[shape_under_cursor]) if shape_type == Ellipse: # Removing vertex from ellipse not implemented return vertices = layer._data_view.shapes[shape_under_cursor].data if len(vertices) <= 2: # If only 2 vertices present, remove whole shape with layer.events.set_data.blocker(): if shape_under_cursor in layer.selected_data: layer.selected_data.remove(shape_under_cursor) layer._data_view.remove(shape_under_cursor) shapes = layer.selected_data layer._selected_box = layer.interaction_box(shapes) elif shape_type == Polygon and len(vertices) == 3: # If only 3 vertices of a polygon present remove with layer.events.set_data.blocker(): if shape_under_cursor in layer.selected_data: layer.selected_data.remove(shape_under_cursor) layer._data_view.remove(shape_under_cursor) shapes = layer.selected_data layer._selected_box = layer.interaction_box(shapes) else: if shape_type == Rectangle: # Deleting vertex from a rectangle creates a polygon new_type = Polygon else: new_type = None # Remove clicked on vertex vertices = np.delete(vertices, vertex_under_cursor, axis=0) with layer.events.set_data.blocker(): layer._data_view.edit( shape_under_cursor, vertices, new_type=new_type ) shapes = layer.selected_data layer._selected_box = layer.interaction_box(shapes) layer.refresh() def _drag_selection_box(layer, coordinates): """Drag a selection box. Parameters ---------- layer : napari.layers.Shapes Shapes layer. coordinates : tuple Position of mouse cursor in data coordinates. """ # If something selected return if len(layer.selected_data) > 0: return coord = [coordinates[i] for i in layer._slice_input.displayed] # Create or extend a selection box layer._is_selecting = True if layer._drag_start is None: layer._drag_start = coord layer._drag_box = np.array([layer._drag_start, coord]) layer._set_highlight() def _set_drag_start(layer, coordinates): coord = [coordinates[i] for i in layer._slice_input.displayed] if layer._drag_start is None and len(layer.selected_data) > 0: center = layer._selected_box[Box.CENTER] layer._drag_start = coord - center return coord def _move(layer, coordinates): """Moves object at given mouse position and set of indices. Parameters ---------- layer : napari.layers.Shapes Shapes layer. coordinates : tuple Position of mouse cursor in data coordinates. """ # If nothing selected return if len(layer.selected_data) == 0: return vertex = layer._moving_value[1] if layer._mode in ( [Mode.SELECT, Mode.ADD_RECTANGLE, Mode.ADD_ELLIPSE, Mode.ADD_LINE] ): coord = _set_drag_start(layer, coordinates) layer._moving_coordinates = coordinates layer._is_moving = True if vertex is None: # Check where dragging box from to move whole object center = layer._selected_box[Box.CENTER] shift = coord - center - layer._drag_start for index in layer.selected_data: layer._data_view.shift(index, shift) layer._selected_box = layer._selected_box + shift layer.refresh() elif vertex < Box.LEN: # Corner / edge vertex is being dragged so resize object box = layer._selected_box if layer._fixed_vertex is None: layer._fixed_index = (vertex + 4) % Box.LEN layer._fixed_vertex = box[layer._fixed_index] handle_offset = box[Box.HANDLE] - box[Box.CENTER] if np.linalg.norm(handle_offset) == 0: handle_offset = [1, 1] handle_offset_norm = handle_offset / np.linalg.norm(handle_offset) rot = np.array( [ [handle_offset_norm[0], -handle_offset_norm[1]], [handle_offset_norm[1], handle_offset_norm[0]], ] ) inv_rot = np.linalg.inv(rot) fixed = layer._fixed_vertex new = list(coord) c = box[Box.CENTER] if layer._fixed_aspect and layer._fixed_index % 2 == 0: # corner new = (box[vertex] - c) / np.linalg.norm( box[vertex] - c ) * np.linalg.norm(new - c) + c if layer._fixed_index % 2 == 0: # corner selected scale = (inv_rot @ (new - fixed)) / ( inv_rot @ (box[vertex] - fixed) ) elif layer._fixed_index % 4 == 3: # top or bottom selected scale = np.array( [ (inv_rot @ (new - fixed))[0] / (inv_rot @ (box[vertex] - fixed))[0], 1, ] ) else: # left or right selected scale = np.array( [ 1, (inv_rot @ (new - fixed))[1] / (inv_rot @ (box[vertex] - fixed))[1], ] ) # prevent box from shrinking below a threshold size size = [ np.linalg.norm(box[Box.TOP_CENTER] - c), np.linalg.norm(box[Box.LEFT_CENTER] - c), ] threshold = layer._vertex_size * layer.scale_factor / 2 scale[abs(scale * size) < threshold] = 1 # check orientation of box if abs(handle_offset_norm[0]) == 1: for index in layer.selected_data: layer._data_view.scale( index, scale, center=layer._fixed_vertex ) layer._scale_box(scale, center=layer._fixed_vertex) else: scale_mat = np.array([[scale[0], 0], [0, scale[1]]]) transform = rot @ scale_mat @ inv_rot for index in layer.selected_data: layer._data_view.shift(index, -layer._fixed_vertex) layer._data_view.transform(index, transform) layer._data_view.shift(index, layer._fixed_vertex) layer._transform_box(transform, center=layer._fixed_vertex) layer.refresh() elif vertex == 8: # Rotation handle is being dragged so rotate object handle = layer._selected_box[Box.HANDLE] layer._fixed_vertex = layer._selected_box[Box.CENTER] offset = handle - layer._fixed_vertex layer._drag_start = -np.degrees(np.arctan2(offset[0], -offset[1])) new_offset = coord - layer._fixed_vertex new_angle = -np.degrees(np.arctan2(new_offset[0], -new_offset[1])) fixed_offset = handle - layer._fixed_vertex fixed_angle = -np.degrees( np.arctan2(fixed_offset[0], -fixed_offset[1]) ) if np.linalg.norm(new_offset) < 1: angle = 0 elif layer._fixed_aspect: angle = np.round(new_angle / 45) * 45 - fixed_angle else: angle = new_angle - fixed_angle for index in layer.selected_data: layer._data_view.rotate( index, angle, center=layer._fixed_vertex ) layer._rotate_box(angle, center=layer._fixed_vertex) layer.refresh() elif layer._mode in [Mode.DIRECT, Mode.ADD_PATH, Mode.ADD_POLYGON]: if vertex is not None: layer._moving_coordinates = coordinates layer._is_moving = True index = layer._moving_value[0] shape_type = type(layer._data_view.shapes[index]) if shape_type == Ellipse: # DIRECT vertex moving of ellipse not implemented pass else: if shape_type == Rectangle: new_type = Polygon else: new_type = None vertices = layer._data_view.shapes[index].data vertices[vertex] = coordinates layer._data_view.edit(index, vertices, new_type=new_type) shapes = layer.selected_data layer._selected_box = layer.interaction_box(shapes) layer.refresh() napari-0.5.0a1/napari/layers/shapes/_shapes_utils.py000066400000000000000000001054451437041365600225020ustar00rootroot00000000000000from typing import Tuple import numpy as np from skimage.draw import line, polygon2mask from vispy.geometry import PolygonData from vispy.visuals.tube import _frenet_frames from napari.layers.utils.layer_utils import segment_normal from napari.utils.translations import trans try: # see https://github.com/vispy/vispy/issues/1029 from triangle import triangulate except ModuleNotFoundError: triangulate = None def inside_boxes(boxes): """Checks which boxes contain the origin. Boxes need not be axis aligned Parameters ---------- boxes : (N, 8, 2) array Array of N boxes that should be checked Returns ------- inside : (N,) array of bool True if corresponding box contains the origin. """ AB = boxes[:, 0] - boxes[:, 6] AM = boxes[:, 0] BC = boxes[:, 6] - boxes[:, 4] BM = boxes[:, 6] ABAM = np.multiply(AB, AM).sum(1) ABAB = np.multiply(AB, AB).sum(1) BCBM = np.multiply(BC, BM).sum(1) BCBC = np.multiply(BC, BC).sum(1) c1 = 0 <= ABAM c2 = ABAM <= ABAB c3 = 0 <= BCBM c4 = BCBM <= BCBC inside = np.all(np.array([c1, c2, c3, c4]), axis=0) return inside def triangles_intersect_box(triangles, corners): """Determines which triangles intersect an axis aligned box. Parameters ---------- triangles : (N, 3, 2) array Array of vertices of triangles to be tested corners : (2, 2) array Array specifying corners of a box Returns ------- intersects : (N,) array of bool Array with `True` values for triangles intersecting the box """ vertices_inside = triangle_vertices_inside_box(triangles, corners) edge_intersects = triangle_edges_intersect_box(triangles, corners) intersects = np.logical_or(vertices_inside, edge_intersects) return intersects def triangle_vertices_inside_box(triangles, corners): """Determines which triangles have vertices inside an axis aligned box. Parameters ---------- triangles : (N, 3, 2) array Array of vertices of triangles to be tested corners : (2, 2) array Array specifying corners of a box Returns ------- inside : (N,) array of bool Array with `True` values for triangles with vertices inside the box """ box = create_box(corners)[[0, 4]] vertices_inside = np.empty(triangles.shape[:-1], dtype=bool) for i in range(3): # check if each triangle vertex is inside the box below_top = np.all(box[1] >= triangles[:, i, :], axis=1) above_bottom = np.all(triangles[:, i, :] >= box[0], axis=1) vertices_inside[:, i] = np.logical_and(below_top, above_bottom) inside = np.any(vertices_inside, axis=1) return inside def triangle_edges_intersect_box(triangles, corners): """Determines which triangles have edges that intersect the edges of an axis aligned box. Parameters ---------- triangles : (N, 3, 2) array Array of vertices of triangles to be tested corners : (2, 2) array Array specifying corners of a box Returns ------- intersects : (N,) array of bool Array with `True` values for triangles with edges that intersect the edges of the box. """ box = create_box(corners)[[0, 2, 4, 6]] intersects = np.zeros([len(triangles), 12], dtype=bool) for i in range(3): # check if each triangle edge p1 = triangles[:, i, :] q1 = triangles[:, (i + 1) % 3, :] for j in range(4): # Check the four edges of the box p2 = box[j] q2 = box[(j + 1) % 3] intersects[:, i * 3 + j] = [ lines_intersect(p1[k], q1[k], p2, q2) for k in range(len(p1)) ] return np.any(intersects, axis=1) def lines_intersect(p1, q1, p2, q2): """Determines if line segment p1q1 intersects line segment p2q2 Parameters ---------- p1 : (2,) array Array of first point of first line segment q1 : (2,) array Array of second point of first line segment p2 : (2,) array Array of first point of second line segment q2 : (2,) array Array of second point of second line segment Returns ------- intersects : bool Bool indicating if line segment p1q1 intersects line segment p2q2 """ # Determine four orientations o1 = orientation(p1, q1, p2) o2 = orientation(p1, q1, q2) o3 = orientation(p2, q2, p1) o4 = orientation(p2, q2, q1) # Test general case if (o1 != o2) and (o3 != o4): return True # Test special cases # p1, q1 and p2 are collinear and p2 lies on segment p1q1 if o1 == 0 and on_segment(p1, p2, q1): return True # p1, q1 and q2 are collinear and q2 lies on segment p1q1 if o2 == 0 and on_segment(p1, q2, q1): return True # p2, q2 and p1 are collinear and p1 lies on segment p2q2 if o3 == 0 and on_segment(p2, p1, q2): return True # p2, q2 and q1 are collinear and q1 lies on segment p2q2 if o4 == 0 and on_segment(p2, q1, q2): return True # Doesn't fall into any special cases return False def on_segment(p, q, r): """Checks if q is on the segment from p to r Parameters ---------- p : (2,) array Array of first point of segment q : (2,) array Array of point to check if on segment r : (2,) array Array of second point of segment Returns ------- on : bool Bool indicating if q is on segment from p to r """ if ( q[0] <= max(p[0], r[0]) and q[0] >= min(p[0], r[0]) and q[1] <= max(p[1], r[1]) and q[1] >= min(p[1], r[1]) ): on = True else: on = False return on def orientation(p, q, r): """Determines oritentation of ordered triplet (p, q, r) Parameters ---------- p : (2,) array Array of first point of triplet q : (2,) array Array of second point of triplet r : (2,) array Array of third point of triplet Returns ------- val : int One of (-1, 0, 1). 0 if p, q, r are collinear, 1 if clockwise, and -1 if counterclockwise. """ val = (q[1] - p[1]) * (r[0] - q[0]) - (q[0] - p[0]) * (r[1] - q[1]) val = np.sign(val) return val def is_collinear(points): """Determines if a list of 2D points are collinear. Parameters ---------- points : (N, 2) array Points to be tested for collinearity Returns ------- val : bool True is all points are collinear, False otherwise. """ if len(points) < 3: return True # The collinearity test takes three points, the first two are the first # two in the list, and then the third is iterated through in the loop for p in points[2:]: if orientation(points[0], points[1], p) != 0: return False return True def point_to_lines(point, lines): """Calculate the distance between a point and line segments and returns the index of the closest line. First calculates the distance to the infinite line, then checks if the projected point lies between the line segment endpoints. If not, calculates distance to the endpoints Parameters ---------- point : np.ndarray 1x2 array of specifying the point lines : np.ndarray Nx2x2 array of line segments Returns ------- index : int Integer index of the closest line location : float Normalized location of intersection of the distance normal to the line closest. Less than 0 means an intersection before the line segment starts. Between 0 and 1 means an intersection inside the line segment. Greater than 1 means an intersection after the line segment ends """ # shift and normalize vectors lines_vectors = lines[:, 1] - lines[:, 0] point_vectors = point - lines[:, 0] end_point_vectors = point - lines[:, 1] norm_lines = np.linalg.norm(lines_vectors, axis=1, keepdims=True) reject = (norm_lines == 0).squeeze() norm_lines[reject] = 1 unit_lines = lines_vectors / norm_lines # calculate distance to line line_dist = abs(np.cross(unit_lines, point_vectors)) # calculate scale line_loc = (unit_lines * point_vectors).sum(axis=1) / norm_lines.squeeze() # for points not falling inside segment calculate distance to appropriate # endpoint line_dist[line_loc < 0] = np.linalg.norm( point_vectors[line_loc < 0], axis=1 ) line_dist[line_loc > 1] = np.linalg.norm( end_point_vectors[line_loc > 1], axis=1 ) line_dist[reject] = np.linalg.norm(point_vectors[reject], axis=1) line_loc[reject] = 0.5 # calculate closet line index = np.argmin(line_dist) location = line_loc[index] return index, location def create_box(data): """Creates the axis aligned interaction box of a list of points Parameters ---------- data : np.ndarray Nx2 array of points whose interaction box is to be found Returns ------- box : np.ndarray 9x2 array of vertices of the interaction box. The first 8 points are the corners and midpoints of the box in clockwise order starting in the upper-left corner. The last point is the center of the box """ min_val = [data[:, 0].min(axis=0), data[:, 1].min(axis=0)] max_val = [data[:, 0].max(axis=0), data[:, 1].max(axis=0)] tl = np.array([min_val[0], min_val[1]]) tr = np.array([max_val[0], min_val[1]]) br = np.array([max_val[0], max_val[1]]) bl = np.array([min_val[0], max_val[1]]) box = np.array( [ tl, (tl + tr) / 2, tr, (tr + br) / 2, br, (br + bl) / 2, bl, (bl + tl) / 2, (tl + tr + br + bl) / 4, ] ) return box def rectangle_to_box(data): """Converts the four corners of a rectangle into a interaction box like representation. If the rectangle is not axis aligned the resulting box representation will not be axis aligned either Parameters ---------- data : np.ndarray 4xD array of corner points to be converted to a box like representation Returns ------- box : np.ndarray 9xD array of vertices of the interaction box. The first 8 points are the corners and midpoints of the box in clockwise order starting in the upper-left corner. The last point is the center of the box """ if not data.shape[0] == 4: raise ValueError( trans._( "Data shape does not match expected `[4, D]` shape specifying corners for the rectangle", deferred=True, ) ) box = np.array( [ data[0], (data[0] + data[1]) / 2, data[1], (data[1] + data[2]) / 2, data[2], (data[2] + data[3]) / 2, data[3], (data[3] + data[0]) / 2, data.mean(axis=0), ] ) return box def find_corners(data): """Finds the four corners of the interaction box defined by an array of points Parameters ---------- data : np.ndarray Nx2 array of points whose interaction box is to be found Returns ------- corners : np.ndarray 4x2 array of corners of the bounding box """ min_val = data.min(axis=0) max_val = data.max(axis=0) tl = np.array([min_val[0], min_val[1]]) tr = np.array([max_val[0], min_val[1]]) br = np.array([max_val[0], max_val[1]]) bl = np.array([min_val[0], max_val[1]]) corners = np.array([tl, tr, br, bl]) return corners def center_radii_to_corners(center, radii): """Expands a center and radii into a four corner rectangle Parameters ---------- center : np.ndarray | list Length 2 array or list of the center coordinates radii : np.ndarray | list Length 2 array or list of the two radii Returns ------- corners : np.ndarray 4x2 array of corners of the bounding box """ data = np.array([center + radii, center - radii]) corners = find_corners(data) return corners def triangulate_ellipse(corners, num_segments=100): """Determines the triangulation of a path. The resulting `offsets` can multiplied by a `width` scalar and be added to the resulting `centers` to generate the vertices of the triangles for the triangulation, i.e. `vertices = centers + width*offsets`. Using the `centers` and `offsets` representation thus allows for the computed triangulation to be independent of the line width. Parameters ---------- corners : np.ndarray 4xD array of four bounding corners of the ellipse. The ellipse will still be computed properly even if the rectangle determined by the corners is not axis aligned. D in {2,3} num_segments : int Integer determining the number of segments to use when triangulating the ellipse Returns ------- vertices : np.ndarray Mx2/Mx3 array coordinates of vertices for triangulating an ellipse. Includes the center vertex of the ellipse, followed by `num_segments` vertices around the boundary of the ellipse (M = `num_segments`+1) triangles : np.ndarray Px3 array of the indices of the vertices for the triangles of the triangulation. Has length (P) given by `num_segments`, (P = M-1 = num_segments) Notes ----- Despite it's name the ellipse will have num_segments-1 segments on their outline. That is to say num_segments=7 will lead to ellipses looking like hexagons. The behavior of this function is not well defined if the ellipse is degenerate in the current plane/volume you are currently observing. """ if not corners.shape[0] == 4: raise ValueError( trans._( "Data shape does not match expected `[4, D]` shape specifying corners for the ellipse", deferred=True, ) ) assert corners.shape in {(4, 2), (4, 3)} center = corners.mean(axis=0) adjusted = corners - center # Take to consecutive corners difference # that give us the 1/2 minor and major axes. ax1 = (adjusted[1] - adjusted[0]) / 2 ax2 = (adjusted[2] - adjusted[1]) / 2 # Compute the transformation matrix from the unit circle # to our current ellipse. # ... it's easy just the 1/2 minor/major axes for the two column # note that our transform shape will depends on wether we are 2D-> 2D (matrix, 2 by 2), # or 2D -> 3D (matrix 2 by 3). transform = np.stack((ax1, ax2)) if corners.shape == (4, 2): assert transform.shape == (2, 2) else: assert transform.shape == (2, 3) # we discretize the unit circle always in 2D. v2d = np.zeros((num_segments + 1, 2), dtype=np.float32) theta = np.linspace(0, np.deg2rad(360), num_segments) v2d[1:, 0] = np.cos(theta) v2d[1:, 1] = np.sin(theta) # ! vertices shape can be 2,M or 3,M depending on the transform. vertices = np.matmul(v2d, transform) # Shift back to center vertices = vertices + center triangles = ( np.arange(num_segments) + np.array([[0], [1], [2]]) ).T * np.array([0, 1, 1]) triangles[-1, 2] = 1 return vertices, triangles def triangulate_face(data): """Determines the triangulation of the face of a shape. Parameters ---------- data : np.ndarray Nx2 array of vertices of shape to be triangulated Returns ------- vertices : np.ndarray Mx2 array vertices of the triangles. triangles : np.ndarray Px3 array of the indices of the vertices that will form the triangles of the triangulation """ if triangulate is not None: len_data = len(data) edges = np.empty((len_data, 2), dtype=np.uint32) edges[:, 0] = np.arange(len_data) edges[:, 1] = np.arange(1, len_data + 1) # connect last with first vertex edges[-1, 1] = 0 res = triangulate(dict(vertices=data, segments=edges), "p") vertices, triangles = res['vertices'], res['triangles'] else: vertices, triangles = PolygonData(vertices=data).triangulate() triangles = triangles.astype(np.uint32) return vertices, triangles def triangulate_edge(path, closed=False): """Determines the triangulation of a path. The resulting `offsets` can multiplied by a `width` scalar and be added to the resulting `centers` to generate the vertices of the triangles for the triangulation, i.e. `vertices = centers + width*offsets`. Using the `centers` and `offsets` representation thus allows for the computed triangulation to be independent of the line width. Parameters ---------- path : np.ndarray Nx2 or Nx3 array of central coordinates of path to be triangulated closed : bool Bool which determines if the path is closed or not. Returns ------- centers : np.ndarray Mx2 or Mx3 array central coordinates of path triangles. offsets : np.ndarray Mx2 or Mx3 array of the offsets to the central coordinates that need to be scaled by the line width and then added to the centers to generate the actual vertices of the triangulation triangles : np.ndarray Px3 array of the indices of the vertices that will form the triangles of the triangulation """ path = np.asanyarray(path) # Remove any equal adjacent points if len(path) > 2: idx = np.concatenate([[True], ~np.all(path[1:] == path[:-1], axis=-1)]) clean_path = path[idx].copy() if clean_path.shape[0] == 1: clean_path = np.concatenate((clean_path, clean_path), axis=0) else: clean_path = path if clean_path.shape[-1] == 2: centers, offsets, triangles = generate_2D_edge_meshes( clean_path, closed=closed ) else: centers, offsets, triangles = generate_tube_meshes( clean_path, closed=closed ) return centers, offsets, triangles def _mirror_point(x, y): return 2 * y - x def _sign_nonzero(x): y = np.sign(x).astype(int) y[y == 0] = 1 return y def _sign_cross(x, y): """sign of cross product (faster for 2d)""" if x.shape[1] == y.shape[1] == 2: return _sign_nonzero(x[:, 0] * y[:, 1] - x[:, 1] * y[:, 0]) elif x.shape[1] == y.shape[1] == 3: return _sign_nonzero(np.cross(x, y)) else: raise ValueError(x.shape[1], y.shape[1]) def generate_2D_edge_meshes(path, closed=False, limit=3, bevel=False): """Determines the triangulation of a path in 2D. The resulting `offsets` can be multiplied by a `width` scalar and be added to the resulting `centers` to generate the vertices of the triangles for the triangulation, i.e. `vertices = centers + width*offsets`. Using the `centers` and `offsets` representation thus allows for the computed triangulation to be independent of the line width. Parameters ---------- path : np.ndarray Nx2 or Nx3 array of central coordinates of path to be triangulated closed : bool Bool which determines if the path is closed or not limit : float Miter limit which determines when to switch from a miter join to a bevel join bevel : bool Bool which if True causes a bevel join to always be used. If False a bevel join will only be used when the miter limit is exceeded Returns ------- centers : np.ndarray Mx2 or Mx3 array central coordinates of path triangles. offsets : np.ndarray Mx2 or Mx3 array of the offsets to the central coordinates that need to be scaled by the line width and then added to the centers to generate the actual vertices of the triangulation triangles : np.ndarray Px3 array of the indices of the vertices that will form the triangles of the triangulation """ path = np.asarray(path, dtype=float) # add first vertex to the end if closed if closed: path = np.concatenate((path, [path[0]])) # extend path by adding a vertex at beginning and end # to get the mean normals correct if closed: _ext_point1 = path[-2] _ext_point2 = path[1] else: _ext_point1 = _mirror_point(path[1], path[0]) _ext_point2 = _mirror_point(path[-2], path[-1]) full_path = np.concatenate(([_ext_point1], path, [_ext_point2]), axis=0) # full_normals[:-1], full_normals[1:] are normals left and right of each path vertex full_normals = segment_normal(full_path[:-1], full_path[1:]) # miters per vertex are the average of left and right normals miters = 0.5 * (full_normals[:-1] + full_normals[1:]) # scale miters such that their dot product with normals is 1 _mf_dot = np.expand_dims( np.einsum('ij,ij->i', miters, full_normals[:-1]), -1 ) miters = np.divide( miters, _mf_dot, where=np.abs(_mf_dot) > 1e-10, ) miter_lengths_squared = (miters**2).sum(axis=1) # miter_signs -> +1 if edges turn clockwise, -1 if anticlockwise # used later to discern bevel positions miter_signs = _sign_cross(full_normals[1:], full_normals[:-1]) miters = 0.5 * miters # generate centers/offsets centers = np.repeat(path, 2, axis=0) offsets = np.repeat(miters, 2, axis=0) offsets[::2] *= -1 triangles0 = np.tile(np.array([[0, 1, 3], [0, 3, 2]]), (len(path) - 1, 1)) triangles = triangles0 + 2 * np.repeat( np.arange(len(path) - 1)[:, np.newaxis], 2, 0 ) # get vertex indices that are to be beveled idx_bevel = np.where( np.bitwise_or(bevel, miter_lengths_squared > (limit**2)) )[0] if len(idx_bevel) > 0: # only the 'outwards sticking' offsets should be changed # TODO: This is not entirely true as in extreme cases both can go to infinity idx_offset = (miter_signs[idx_bevel] < 0).astype(int) idx_bevel_full = 2 * idx_bevel + idx_offset sign_bevel = np.expand_dims(miter_signs[idx_bevel], -1) # adjust offset of outer "left" vertex offsets[idx_bevel_full] = ( -0.5 * full_normals[:-1][idx_bevel] * sign_bevel ) # special cases for the last vertex _nonspecial = idx_bevel != len(path) - 1 idx_bevel = idx_bevel[_nonspecial] idx_bevel_full = idx_bevel_full[_nonspecial] sign_bevel = sign_bevel[_nonspecial] idx_offset = idx_offset[_nonspecial] # create new "right" bevel vertices to be added later centers_bevel = path[idx_bevel] offsets_bevel = -0.5 * full_normals[1:][idx_bevel] * sign_bevel n_centers = len(centers) # change vertices of triangles to the newly added right vertices triangles[2 * idx_bevel, idx_offset] = len(centers) + np.arange( len(idx_bevel) ) triangles[ 2 * idx_bevel + (1 - idx_offset), idx_offset ] = n_centers + np.arange(len(idx_bevel)) # add center triangle triangles0 = np.tile(np.array([[0, 1, 2]]), (len(idx_bevel), 1)) triangles_bevel = np.array( [ 2 * idx_bevel + idx_offset, 2 * idx_bevel + (1 - idx_offset), n_centers + np.arange(len(idx_bevel)), ] ).T # add all new centers, offsets, and triangles centers = np.concatenate([centers, centers_bevel]) offsets = np.concatenate([offsets, offsets_bevel]) triangles = np.concatenate([triangles, triangles_bevel]) # extracting vectors (~4x faster than np.moveaxis) a, b, c = tuple((centers + offsets)[triangles][:, i] for i in range(3)) # flip negative oriented triangles flip_idx = _sign_cross(b - a, c - a) < 0 triangles[flip_idx] = np.flip(triangles[flip_idx], axis=-1) return centers, offsets, triangles def generate_tube_meshes(path, closed=False, tube_points=10): """Generates list of mesh vertices and triangles from a path Adapted from vispy.visuals.TubeVisual https://github.com/vispy/vispy/blob/main/vispy/visuals/tube.py Parameters ---------- path : (N, 3) array Vertices specifying the path. closed : bool Bool which determines if the path is closed or not. tube_points : int The number of points in the circle-approximating polygon of the tube's cross section. Returns ------- centers : (M, 3) array Vertices of all triangles for the lines offsets : (M, D) array offsets of all triangles for the lines triangles : (P, 3) array Vertex indices that form the mesh triangles """ points = np.array(path).astype(float) if closed and not np.all(points[0] == points[-1]): points = np.concatenate([points, [points[0]]], axis=0) tangents, normals, binormals = _frenet_frames(points, closed) segments = len(points) - 1 # get the positions of each vertex grid = np.zeros((len(points), tube_points, 3)) grid_off = np.zeros((len(points), tube_points, 3)) for i in range(len(points)): pos = points[i] normal = normals[i] binormal = binormals[i] # Add a vertex for each point on the circle v = np.arange(tube_points, dtype=float) / tube_points * 2 * np.pi cx = -1.0 * np.cos(v) cy = np.sin(v) grid[i] = pos grid_off[i] = cx[:, np.newaxis] * normal + cy[:, np.newaxis] * binormal # construct the mesh indices = [] for i in range(segments): for j in range(tube_points): ip = (i + 1) % segments if closed else i + 1 jp = (j + 1) % tube_points index_a = i * tube_points + j index_b = ip * tube_points + j index_c = ip * tube_points + jp index_d = i * tube_points + jp indices.append([index_a, index_b, index_d]) indices.append([index_b, index_c, index_d]) triangles = np.array(indices, dtype=np.uint32) centers = grid.reshape(grid.shape[0] * grid.shape[1], 3) offsets = grid_off.reshape(grid_off.shape[0] * grid_off.shape[1], 3) return centers, offsets, triangles def path_to_mask(mask_shape, vertices): """Converts a path to a boolean mask with `True` for points lying along each edge. Parameters ---------- mask_shape : array (2,) Shape of mask to be generated. vertices : array (N, 2) Vertices of the path. Returns ------- mask : np.ndarray Boolean array with `True` for points along the path """ mask_shape = np.asarray(mask_shape, dtype=int) mask = np.zeros(mask_shape, dtype=bool) vertices = np.round(np.clip(vertices, 0, mask_shape - 1)).astype(int) # remove identical, consecutive vertices duplicates = np.all(np.diff(vertices, axis=0) == 0, axis=-1) duplicates = np.concatenate(([False], duplicates)) vertices = vertices[~duplicates] iis, jjs = [], [] for v1, v2 in zip(vertices, vertices[1:]): ii, jj = line(*v1, *v2) iis.extend(ii.tolist()) jjs.extend(jj.tolist()) mask[iis, jjs] = 1 return mask def poly_to_mask(mask_shape, vertices): """Converts a polygon to a boolean mask with `True` for points lying inside the shape. Uses the bounding box of the vertices to reduce computation time. Parameters ---------- mask_shape : np.ndarray | tuple 1x2 array of shape of mask to be generated. vertices : np.ndarray Nx2 array of the vertices of the polygon. Returns ------- mask : np.ndarray Boolean array with `True` for points inside the polygon """ return polygon2mask(mask_shape, vertices) def grid_points_in_poly(shape, vertices): """Converts a polygon to a boolean mask with `True` for points lying inside the shape. Loops through all indices in the grid Parameters ---------- shape : np.ndarray | tuple 1x2 array of shape of mask to be generated. vertices : np.ndarray Nx2 array of the vertices of the polygon. Returns ------- mask : np.ndarray Boolean array with `True` for points inside the polygon """ points = np.array( [(x, y) for x in range(shape[0]) for y in range(shape[1])], dtype=int ) inside = points_in_poly(points, vertices) mask = inside.reshape(shape) return mask def points_in_poly(points, vertices): """Tests points for being inside a polygon using the ray casting algorithm Parameters ---------- points : np.ndarray Mx2 array of points to be tested vertices : np.ndarray Nx2 array of the vertices of the polygon. Returns ------- inside : np.ndarray Length M boolean array with `True` for points inside the polygon """ n_verts = len(vertices) inside = np.zeros(len(points), dtype=bool) j = n_verts - 1 for i in range(n_verts): # Determine if a horizontal ray emanating from the point crosses the # line defined by vertices i-1 and vertices i. cond_1 = np.logical_and( vertices[i, 1] <= points[:, 1], points[:, 1] < vertices[j, 1] ) cond_2 = np.logical_and( vertices[j, 1] <= points[:, 1], points[:, 1] < vertices[i, 1] ) cond_3 = np.logical_or(cond_1, cond_2) d = vertices[j] - vertices[i] # Prevents floating point imprecision from generating false positives tolerance = 1e-12 d = np.where(abs(d) < tolerance, 0, d) if d[1] == 0: # If y vertices are aligned avoid division by zero cond_4 = 0 < d[0] * (points[:, 1] - vertices[i, 1]) else: cond_4 = points[:, 0] < ( d[0] * (points[:, 1] - vertices[i, 1]) / d[1] + vertices[i, 0] ) cond_5 = np.logical_and(cond_3, cond_4) inside[cond_5] = 1 - inside[cond_5] j = i # If the number of crossings is even then the point is outside the polygon, # if the number of crossings is odd then the point is inside the polygon return inside def extract_shape_type(data, shape_type=None): """Separates shape_type from data if present, and returns both. Parameters ---------- data : Array | Tuple(Array,str) | List[Array | Tuple(Array, str)] | Tuple(List[Array], str) list or array of vertices belonging to each shape, optionally containing shape type strings shape_type : str | None metadata shape type string, or None if none was passed Returns ------- data : Array | List[Array] list or array of vertices belonging to each shape shape_type : List[str] | None type of each shape in data, or None if none was passed """ # Tuple for one shape or list of shapes with shape_type if isinstance(data, Tuple): shape_type = data[1] data = data[0] # List of (vertices, shape_type) tuples elif len(data) != 0 and all(isinstance(datum, Tuple) for datum in data): shape_type = [datum[1] for datum in data] data = [datum[0] for datum in data] return data, shape_type def get_default_shape_type(current_type): """If all shapes in current_type are of identical shape type, return this shape type, else "polygon" as lowest common denominator type. Parameters ---------- current_type : list of str list of current shape types Returns ------- default_type : str default shape type """ default = "polygon" if not current_type: return default first_type = current_type[0] if all(shape_type == first_type for shape_type in current_type): return first_type return default def get_shape_ndim(data): """Checks whether data is a list of the same type of shape, one shape, or a list of different shapes and returns the dimensionality of the shape/s. Parameters ---------- data : (N, ) list of array List of shape data, where each element is an (N, D) array of the N vertices of a shape in D dimensions. Returns ------- ndim : int Dimensionality of the shape/s in data """ # list of all the same shapes if np.array(data, dtype=object).ndim == 3: ndim = np.array(data).shape[2] # just one shape elif np.array(data[0]).ndim == 1: ndim = np.array(data).shape[1] # list of different shapes else: ndim = np.array(data[0]).shape[1] return ndim def number_of_shapes(data): """Determine number of shapes in the data. Parameters ---------- data : list or np.ndarray Can either be no shapes, if empty, a single shape or a list of shapes. Returns ------- n_shapes : int Number of new shapes """ if len(data) == 0: # If no new shapes n_shapes = 0 elif np.array(data[0]).ndim == 1: # If a single array for a shape n_shapes = 1 else: n_shapes = len(data) return n_shapes def validate_num_vertices( data, shape_type, min_vertices=None, valid_vertices=None ): """Raises error if a shape in data has invalid number of vertices. Checks whether all shapes in data have a valid number of vertices for the given shape type and vertex information. Rectangles and ellipses can have either 2 or 4 vertices per shape, lines can have only 2, while paths and polygons have a minimum number of vertices, but no maximum. One of valid_vertices or min_vertices must be passed to the function. Parameters ---------- data : Array | Tuple(Array,str) | List[Array | Tuple(Array, str)] | Tuple(List[Array], str) List of shape data, where each element is either an (N, D) array of the N vertices of a shape in D dimensions or a tuple containing an array of the N vertices and the shape_type string. Can be an 3-dimensional array if each shape has the same number of vertices. shape_type : str Type of shape being validated (for detailed error message) min_vertices : int or None Minimum number of vertices for the shape type, by default None valid_vertices : Tuple(int) or None Valid number of vertices for the shape type in data, by default None Raises ------ ValueError Raised if a shape is found with invalid number of vertices """ n_shapes = number_of_shapes(data) # single array of vertices if n_shapes == 1 and len(np.array(data).shape) == 2: # wrap in extra dimension so we can iterate through shape not vertices data = [data] for shape in data: if (valid_vertices and len(shape) not in valid_vertices) or ( min_vertices and len(shape) < min_vertices ): raise ValueError( trans._( "{shape_type} {shape} has invalid number of vertices: {shape_length}.", deferred=True, shape_type=shape_type, shape=shape, shape_length=len(shape), ) ) napari-0.5.0a1/napari/layers/shapes/_tests/000077500000000000000000000000001437041365600205565ustar00rootroot00000000000000napari-0.5.0a1/napari/layers/shapes/_tests/test_shape_list.py000066400000000000000000000034371437041365600243310ustar00rootroot00000000000000import numpy as np import pytest from napari.layers.shapes._shape_list import ShapeList from napari.layers.shapes._shapes_models import Path, Polygon, Rectangle def test_empty_shape_list(): """Test instantiating empty ShapeList.""" shape_list = ShapeList() assert len(shape_list.shapes) == 0 def test_adding_to_shape_list(): """Test adding shapes to ShapeList.""" np.random.seed(0) data = 20 * np.random.random((4, 2)) shape = Rectangle(data) shape_list = ShapeList() shape_list.add(shape) assert len(shape_list.shapes) == 1 assert shape_list.shapes[0] == shape def test_nD_shapes(): """Test adding shapes to ShapeList.""" np.random.seed(0) shape_list = ShapeList() data = 20 * np.random.random((6, 3)) data[:, 0] = 0 shape_a = Polygon(data) shape_list.add(shape_a) data = 20 * np.random.random((6, 3)) data[:, 0] = 1 shape_b = Path(data) shape_list.add(shape_b) assert len(shape_list.shapes) == 2 assert shape_list.shapes[0] == shape_a assert shape_list.shapes[1] == shape_b assert shape_list._vertices.shape[1] == 2 assert shape_list._mesh.vertices.shape[1] == 2 shape_list.ndisplay = 3 assert shape_list._vertices.shape[1] == 3 assert shape_list._mesh.vertices.shape[1] == 3 @pytest.mark.parametrize("attribute", ['edge', 'face']) def test_bad_color_array(attribute): """Test adding shapes to ShapeList.""" np.random.seed(0) data = 20 * np.random.random((4, 2)) shape = Rectangle(data) shape_list = ShapeList() shape_list.add(shape) # test setting color with a color array of the wrong shape bad_color_array = np.array([[0, 0, 0, 1], [1, 1, 1, 1]]) with pytest.raises(ValueError): setattr(shape_list, f'{attribute}_color', bad_color_array) napari-0.5.0a1/napari/layers/shapes/_tests/test_shapes.py000066400000000000000000002206431437041365600234610ustar00rootroot00000000000000from copy import copy from itertools import cycle, islice import numpy as np import pandas as pd import pytest from pydantic import ValidationError from napari._tests.utils import check_layer_world_data_extent from napari.components import ViewerModel from napari.layers import Shapes from napari.layers.utils._text_constants import Anchor from napari.layers.utils.color_encoding import ConstantColorEncoding from napari.utils.colormaps.standardize_color import transform_color def _make_cycled_properties(values, length): """Helper function to make property values Parameters ---------- values The values to be cycled. length : int The length of the resulting property array Returns ------- cycled_properties : np.ndarray The property array comprising the cycled values. """ cycled_properties = np.array(list(islice(cycle(values), 0, length))) return cycled_properties def test_empty_shapes(): shp = Shapes() assert shp.ndim == 2 def test_update_thumbnail_empty_shapes(): """Test updating the thumbnail with an empty shapes layer.""" layer = Shapes() layer._allow_thumbnail_update = True layer._update_thumbnail() properties_array = {'shape_type': _make_cycled_properties(['A', 'B'], 10)} properties_list = {'shape_type': list(_make_cycled_properties(['A', 'B'], 10))} @pytest.mark.parametrize("properties", [properties_array, properties_list]) def test_properties(properties): shape = (10, 4, 2) np.random.seed(0) data = 20 * np.random.random(shape) layer = Shapes(data, properties=copy(properties)) np.testing.assert_equal(layer.properties, properties) current_prop = {'shape_type': np.array(['B'])} assert layer.current_properties == current_prop # test removing shapes layer.selected_data = {0, 1} layer.remove_selected() remove_properties = properties['shape_type'][2::] assert len(layer.properties['shape_type']) == (shape[0] - 2) assert np.all(layer.properties['shape_type'] == remove_properties) # test selection of properties layer.selected_data = {0} selected_annotation = layer.current_properties['shape_type'] assert len(selected_annotation) == 1 assert selected_annotation[0] == 'A' # test adding shapes with properties new_data = np.random.random((1, 4, 2)) new_shape_type = ['rectangle'] layer.add(new_data, shape_type=new_shape_type) add_properties = np.concatenate((remove_properties, ['A']), axis=0) assert np.all(layer.properties['shape_type'] == add_properties) # test copy/paste layer.selected_data = {0, 1} layer._copy_data() assert np.all(layer._clipboard['features']['shape_type'] == ['A', 'B']) layer._paste_data() paste_properties = np.concatenate((add_properties, ['A', 'B']), axis=0) assert np.all(layer.properties['shape_type'] == paste_properties) # test updating a property layer.mode = 'select' layer.selected_data = {0} new_property = {'shape_type': np.array(['B'])} layer.current_properties = new_property updated_properties = layer.properties assert updated_properties['shape_type'][0] == 'B' @pytest.mark.parametrize("attribute", ['edge', 'face']) def test_adding_properties(attribute): """Test adding properties to an existing layer""" shape = (10, 4, 2) np.random.seed(0) data = 20 * np.random.random(shape) layer = Shapes(data) # add properties properties = {'shape_type': _make_cycled_properties(['A', 'B'], shape[0])} layer.properties = properties np.testing.assert_equal(layer.properties, properties) # add properties as a dataframe properties_df = pd.DataFrame(properties) layer.properties = properties_df np.testing.assert_equal(layer.properties, properties) # add properties as a dictionary with list values properties_list = { 'shape_type': list(_make_cycled_properties(['A', 'B'], shape[0])) } layer.properties = properties_list assert isinstance(layer.properties['shape_type'], np.ndarray) # removing a property that was the _*_color_property should give a warning setattr(layer, f'_{attribute}_color_property', 'shape_type') properties_2 = { 'not_shape_type': _make_cycled_properties(['A', 'B'], shape[0]) } with pytest.warns(RuntimeWarning): layer.properties = properties_2 def test_colormap_scale_change(): data = 20 * np.random.random((10, 4, 2)) properties = {'a': np.linspace(0, 1, 10), 'b': np.linspace(0, 100000, 10)} layer = Shapes(data, properties=properties, edge_color='b') assert not np.allclose( layer.edge_color[0], layer.edge_color[1], atol=0.001 ) layer.edge_color = 'a' # note that VisPy colormaps linearly interpolate by default, so # non-rescaled colors are not identical, but they are closer than 24-bit # color precision can distinguish! assert not np.allclose( layer.edge_color[0], layer.edge_color[1], atol=0.001 ) def test_data_setter_with_properties(): """Test layer data on a layer with properties via the data setter""" shape = (10, 4, 2) np.random.seed(0) data = 20 * np.random.random(shape) properties = {'shape_type': _make_cycled_properties(['A', 'B'], shape[0])} layer = Shapes(data, properties=properties) # test setting to data with fewer shapes n_new_shapes = 4 new_data = 20 * np.random.random((n_new_shapes, 4, 2)) layer.data = new_data assert len(layer.properties['shape_type']) == n_new_shapes # test setting to data with more shapes n_new_shapes_2 = 6 new_data_2 = 20 * np.random.random((n_new_shapes_2, 4, 2)) layer.data = new_data_2 assert len(layer.properties['shape_type']) == n_new_shapes_2 # test setting to data with same shapes new_data_3 = 20 * np.random.random((n_new_shapes_2, 4, 2)) layer.data = new_data_3 assert len(layer.properties['shape_type']) == n_new_shapes_2 def test_properties_dataframe(): """Test if properties can be provided as a DataFrame""" shape = (10, 4, 2) np.random.seed(0) data = 20 * np.random.random(shape) properties = {'shape_type': _make_cycled_properties(['A', 'B'], shape[0])} properties_df = pd.DataFrame(properties) properties_df = properties_df.astype(properties['shape_type'].dtype) layer = Shapes(data, properties=properties_df) np.testing.assert_equal(layer.properties, properties) def test_setting_current_properties(): shape = (2, 4, 2) np.random.seed(0) data = 20 * np.random.random(shape) properties = { 'annotation': ['paw', 'leg'], 'confidence': [0.5, 0.75], 'annotator': ['jane', 'ash'], 'model': ['worst', 'best'], } layer = Shapes(data, properties=copy(properties)) current_properties = { 'annotation': ['leg'], 'confidence': 1, 'annotator': 'ash', 'model': np.array(['best']), } layer.current_properties = current_properties expected_current_properties = { 'annotation': np.array(['leg']), 'confidence': np.array([1]), 'annotator': np.array(['ash']), 'model': np.array(['best']), } coerced_current_properties = layer.current_properties for k in coerced_current_properties: value = coerced_current_properties[k] assert isinstance(value, np.ndarray) np.testing.assert_equal(value, expected_current_properties[k]) def test_empty_layer_with_text_property_choices(): """Test initializing an empty layer with text defined""" default_properties = {'shape_type': np.array([1.5], dtype=float)} text_kwargs = {'string': 'shape_type', 'color': 'red'} layer = Shapes( property_choices=default_properties, text=text_kwargs, ) assert layer.text.values.size == 0 np.testing.assert_allclose(layer.text.color.constant, [1, 0, 0, 1]) # add a shape and check that the appropriate text value was added layer.add(np.random.random((1, 4, 2))) np.testing.assert_equal(layer.text.values, ['1.5']) np.testing.assert_allclose(layer.text.color.constant, [1, 0, 0, 1]) def test_empty_layer_with_text_formatted(): """Test initializing an empty layer with text defined""" default_properties = {'shape_type': np.array([1.5], dtype=float)} layer = Shapes( property_choices=default_properties, text='shape_type: {shape_type:.2f}', ) assert layer.text.values.size == 0 # add a shape and check that the appropriate text value was added layer.add(np.random.random((1, 4, 2))) np.testing.assert_equal(layer.text.values, ['shape_type: 1.50']) @pytest.mark.parametrize("properties", [properties_array, properties_list]) def test_text_from_property_value(properties): """Test setting text from a property value""" shape = (10, 4, 2) np.random.seed(0) data = 20 * np.random.random(shape) layer = Shapes(data, properties=copy(properties), text='shape_type') np.testing.assert_equal(layer.text.values, properties['shape_type']) @pytest.mark.parametrize("properties", [properties_array, properties_list]) def test_text_from_property_fstring(properties): """Test setting text with an f-string from the property value""" shape = (10, 4, 2) np.random.seed(0) data = 20 * np.random.random(shape) layer = Shapes( data, properties=copy(properties), text='type: {shape_type}' ) expected_text = ['type: ' + v for v in properties['shape_type']] np.testing.assert_equal(layer.text.values, expected_text) # test updating the text layer.text = 'type-ish: {shape_type}' expected_text_2 = ['type-ish: ' + v for v in properties['shape_type']] np.testing.assert_equal(layer.text.values, expected_text_2) # copy/paste layer.selected_data = {0} layer._copy_data() layer._paste_data() expected_text_3 = expected_text_2 + ['type-ish: A'] np.testing.assert_equal(layer.text.values, expected_text_3) # add shape layer.selected_data = {0} new_shape = np.random.random((1, 4, 2)) layer.add(new_shape) expected_text_4 = expected_text_3 + ['type-ish: A'] np.testing.assert_equal(layer.text.values, expected_text_4) @pytest.mark.parametrize("properties", [properties_array, properties_list]) def test_set_text_with_kwarg_dict(properties): text_kwargs = { 'string': 'type: {shape_type}', 'color': ConstantColorEncoding(constant=[0, 0, 0, 1]), 'rotation': 10, 'translation': [5, 5], 'anchor': Anchor.UPPER_LEFT, 'size': 10, 'visible': True, } shape = (10, 4, 2) np.random.seed(0) data = 20 * np.random.random(shape) layer = Shapes(data, properties=copy(properties), text=text_kwargs) expected_text = ['type: ' + v for v in properties['shape_type']] np.testing.assert_equal(layer.text.values, expected_text) for property, value in text_kwargs.items(): if property == 'string': continue layer_value = getattr(layer._text, property) np.testing.assert_equal(layer_value, value) @pytest.mark.parametrize("properties", [properties_array, properties_list]) def test_text_error(properties): """creating a layer with text as the wrong type should raise an error""" shape = (10, 4, 2) np.random.seed(0) data = 20 * np.random.random(shape) # try adding text as the wrong type with pytest.raises(ValidationError): Shapes(data, properties=copy(properties), text=123) def test_select_properties_object_dtype(): """selecting points when they have a property of object dtype should not fail""" # pandas uses object as dtype for strings by default properties = pd.DataFrame({'color': ['red', 'green']}) pl = Shapes(np.ones((2, 2, 2)), properties=properties) selection = {0, 1} pl.selected_data = selection assert pl.selected_data == selection def test_refresh_text(): """Test refreshing the text after setting new properties""" shape = (10, 4, 2) np.random.seed(0) data = 20 * np.random.random(shape) properties = {'shape_type': ['A'] * shape[0]} layer = Shapes(data, properties=copy(properties), text='shape_type') new_properties = {'shape_type': ['B'] * shape[0]} layer.properties = new_properties np.testing.assert_equal(layer.text.values, new_properties['shape_type']) def test_nd_text(): """Test slicing of text coords with nD shapes""" shapes_data = [ [[0, 10, 10, 10], [0, 10, 20, 20], [0, 10, 10, 20], [0, 10, 20, 10]], [[1, 20, 30, 30], [1, 20, 50, 50], [1, 20, 50, 30], [1, 20, 30, 50]], ] properties = {'shape_type': ['A', 'B']} text_kwargs = {'string': 'shape_type', 'anchor': 'center'} layer = Shapes(shapes_data, properties=properties, text=text_kwargs) assert layer.ndim == 4 layer._slice_dims(point=[0, 10, 0, 0], ndisplay=2) np.testing.assert_equal(layer._indices_view, [0]) np.testing.assert_equal(layer._view_text_coords[0], [[15, 15]]) layer._slice_dims(point=[1, 0, 0, 0], ndisplay=3) np.testing.assert_equal(layer._indices_view, [1]) np.testing.assert_equal(layer._view_text_coords[0], [[20, 40, 40]]) @pytest.mark.parametrize("properties", [properties_array, properties_list]) def test_data_setter_with_text(properties): """Test layer data on a layer with text via the data setter""" shape = (10, 4, 2) np.random.seed(0) data = 20 * np.random.random(shape) layer = Shapes(data, properties=copy(properties), text='shape_type') # test setting to data with fewer shapes n_new_shapes = 4 new_data = 20 * np.random.random((n_new_shapes, 4, 2)) layer.data = new_data assert len(layer.text.values) == n_new_shapes # test setting to data with more shapes n_new_shapes_2 = 6 new_data_2 = 20 * np.random.random((n_new_shapes_2, 4, 2)) layer.data = new_data_2 assert len(layer.text.values) == n_new_shapes_2 # test setting to data with same shapes new_data_3 = 20 * np.random.random((n_new_shapes_2, 4, 2)) layer.data = new_data_3 assert len(layer.text.values) == n_new_shapes_2 @pytest.mark.parametrize( "shape", [ # single & multiple four corner rectangles (1, 4, 2), (10, 4, 2), # single & multiple two corner rectangles (1, 2, 2), (10, 2, 2), ], ) def test_rectangles(shape): """Test instantiating Shapes layer with a random 2D rectangles.""" # Test instantiating with data np.random.seed(0) data = 20 * np.random.random(shape) layer = Shapes(data) assert layer.nshapes == shape[0] # 4 corner rectangle(s) passed, assert vertices the same if shape[1] == 4: assert np.all([layer.data[i] == data[i] for i in range(layer.nshapes)]) # 2 corner rectangle(s) passed, assert 4 vertices in layer else: assert [len(rect) == 4 for rect in layer.data] assert layer.ndim == shape[2] assert np.all([s == 'rectangle' for s in layer.shape_type]) # Test adding via add_rectangles method layer2 = Shapes() layer2.add_rectangles(data) assert layer.nshapes == layer2.nshapes assert np.allclose(layer2.data, layer.data) assert np.all([s == 'rectangle' for s in layer2.shape_type]) def test_add_rectangles_raises_errors(): """Test input validation for add_rectangles method""" layer = Shapes() np.random.seed(0) # single rectangle, 3 vertices data = 20 * np.random.random((1, 3, 2)) with pytest.raises(ValueError): layer.add_rectangles(data) # multiple rectangles, 5 vertices data = 20 * np.random.random((5, 5, 2)) with pytest.raises(ValueError): layer.add_rectangles(data) def test_rectangles_with_shape_type(): """Test instantiating rectangles with shape_type in data""" # Test (rectangle, shape_type) tuple shape = (1, 4, 2) np.random.seed(0) vertices = 20 * np.random.random(shape) data = (vertices, "rectangle") layer = Shapes(data) assert layer.nshapes == shape[0] assert np.all(layer.data[0] == data[0]) assert layer.ndim == shape[2] assert np.all([s == 'rectangle' for s in layer.shape_type]) # Test (list of rectangles, shape_type) tuple shape = (10, 4, 2) vertices = 20 * np.random.random(shape) data = (vertices, "rectangle") layer = Shapes(data) assert layer.nshapes == shape[0] assert np.all([np.all(ld == d) for ld, d in zip(layer.data, vertices)]) assert layer.ndim == shape[2] assert np.all([s == 'rectangle' for s in layer.shape_type]) # Test list of (rectangle, shape_type) tuples data = [(vertices[i], "rectangle") for i in range(shape[0])] layer = Shapes(data) assert layer.nshapes == shape[0] assert np.all([np.all(ld == d) for ld, d in zip(layer.data, vertices)]) assert layer.ndim == shape[2] assert np.all([s == 'rectangle' for s in layer.shape_type]) def test_rectangles_roundtrip(): """Test a full roundtrip with rectangles data.""" shape = (10, 4, 2) np.random.seed(0) data = 20 * np.random.random(shape) layer = Shapes(data) new_layer = Shapes(layer.data) assert np.all([nd == d for nd, d in zip(new_layer.data, layer.data)]) def test_integer_rectangle(): """Test instantiating rectangles with integer data.""" shape = (10, 2, 2) np.random.seed(1) data = np.random.randint(20, size=shape) layer = Shapes(data) assert layer.nshapes == shape[0] assert np.all([len(ld) == 4 for ld in layer.data]) assert layer.ndim == shape[2] assert np.all([s == 'rectangle' for s in layer.shape_type]) def test_negative_rectangle(): """Test instantiating rectangles with negative data.""" shape = (10, 4, 2) np.random.seed(0) data = 20 * np.random.random(shape) - 10 layer = Shapes(data) assert layer.nshapes == shape[0] assert np.all([np.all(ld == d) for ld, d in zip(layer.data, data)]) assert layer.ndim == shape[2] assert np.all([s == 'rectangle' for s in layer.shape_type]) def test_empty_rectangle(): """Test instantiating rectangles with empty data.""" shape = (0, 0, 2) np.random.seed(0) data = 20 * np.random.random(shape) layer = Shapes(data) assert layer.nshapes == shape[0] assert np.all([np.all(ld == d) for ld, d in zip(layer.data, data)]) assert layer.ndim == shape[2] assert np.all([s == 'rectangle' for s in layer.shape_type]) def test_3D_rectangles(): """Test instantiating Shapes layer with 3D planar rectangles.""" # Test a single four corner rectangle np.random.seed(0) planes = np.tile(np.arange(10).reshape((10, 1, 1)), (1, 4, 1)) corners = np.random.uniform(0, 10, size=(10, 4, 2)) data = np.concatenate((planes, corners), axis=2) layer = Shapes(data) assert layer.nshapes == len(data) assert np.all([np.all(ld == d) for ld, d in zip(layer.data, data)]) assert layer.ndim == 3 assert np.all([s == 'rectangle' for s in layer.shape_type]) # test adding with add_rectangles layer2 = Shapes() layer2.add_rectangles(data) assert layer2.nshapes == layer.nshapes assert np.all( [np.all(ld == ld2) for ld, ld2 in zip(layer.data, layer2.data)] ) assert np.all([s == 'rectangle' for s in layer2.shape_type]) @pytest.mark.parametrize( "shape", [ # single & multiple four corner ellipses (1, 4, 2), (10, 4, 2), # single & multiple center, radii ellipses (1, 2, 2), (10, 2, 2), ], ) def test_ellipses(shape): """Test instantiating Shapes layer with random 2D ellipses.""" # Test instantiating with data np.random.seed(0) data = 20 * np.random.random(shape) layer = Shapes(data, shape_type='ellipse') assert layer.nshapes == shape[0] # 4 corner bounding box passed, assert vertices the same if shape[1] == 4: assert np.all([layer.data[i] == data[i] for i in range(layer.nshapes)]) # (center, radii) passed, assert 4 vertices in layer else: assert [len(rect) == 4 for rect in layer.data] assert layer.ndim == shape[2] assert np.all([s == 'ellipse' for s in layer.shape_type]) # Test adding via add_ellipses method layer2 = Shapes() layer2.add_ellipses(data) assert layer.nshapes == layer2.nshapes assert np.allclose(layer2.data, layer.data) assert np.all([s == 'ellipse' for s in layer2.shape_type]) def test_add_ellipses_raises_error(): """Test input validation for add_ellipses method""" layer = Shapes() np.random.seed(0) # single ellipse, 3 vertices data = 20 * np.random.random((1, 3, 2)) with pytest.raises(ValueError): layer.add_ellipses(data) # multiple ellipses, 5 vertices data = 20 * np.random.random((5, 5, 2)) with pytest.raises(ValueError): layer.add_ellipses(data) def test_ellipses_with_shape_type(): """Test instantiating ellipses with shape_type in data""" # Test single four corner (vertices, shape_type) tuple shape = (1, 4, 2) np.random.seed(0) vertices = 20 * np.random.random(shape) data = (vertices, "ellipse") layer = Shapes(data) assert layer.nshapes == shape[0] assert np.all(layer.data[0] == data[0]) assert layer.ndim == shape[2] assert np.all([s == 'ellipse' for s in layer.shape_type]) # Test multiple four corner (list of vertices, shape_type) tuple shape = (10, 4, 2) np.random.seed(0) vertices = 20 * np.random.random(shape) data = (vertices, "ellipse") layer = Shapes(data) assert layer.nshapes == shape[0] assert np.all([np.all(ld == d) for ld, d in zip(layer.data, vertices)]) assert layer.ndim == shape[2] assert np.all([s == 'ellipse' for s in layer.shape_type]) # Test list of four corner (vertices, shape_type) tuples shape = (10, 4, 2) np.random.seed(0) vertices = 20 * np.random.random(shape) data = [(vertices[i], "ellipse") for i in range(shape[0])] layer = Shapes(data) assert layer.nshapes == shape[0] assert np.all([np.all(ld == d) for ld, d in zip(layer.data, vertices)]) assert layer.ndim == shape[2] assert np.all([s == 'ellipse' for s in layer.shape_type]) # Test single (center-radii, shape_type) ellipse shape = (1, 2, 2) np.random.seed(0) data = (20 * np.random.random(shape), "ellipse") layer = Shapes(data) assert layer.nshapes == 1 assert len(layer.data[0]) == 4 assert layer.ndim == shape[2] assert np.all([s == 'ellipse' for s in layer.shape_type]) # Test (list of center-radii, shape_type) tuple shape = (10, 2, 2) np.random.seed(0) center_radii = 20 * np.random.random(shape) data = (center_radii, "ellipse") layer = Shapes(data) assert layer.nshapes == shape[0] assert np.all([len(ld) == 4 for ld in layer.data]) assert layer.ndim == shape[2] assert np.all([s == 'ellipse' for s in layer.shape_type]) # Test list of (center-radii, shape_type) tuples shape = (10, 2, 2) np.random.seed(0) center_radii = 20 * np.random.random(shape) data = [(center_radii[i], "ellipse") for i in range(shape[0])] layer = Shapes(data) assert layer.nshapes == shape[0] assert np.all([len(ld) == 4 for ld in layer.data]) assert layer.ndim == shape[2] assert np.all([s == 'ellipse' for s in layer.shape_type]) def test_4D_ellispse(): """Test instantiating Shapes layer with 4D planar ellipse.""" # Test a single 4D ellipse np.random.seed(0) data = [ [ [3, 5, 108, 108], [3, 5, 108, 148], [3, 5, 148, 148], [3, 5, 148, 108], ] ] layer = Shapes(data, shape_type='ellipse') assert layer.nshapes == len(data) assert np.all([np.all(ld == d) for ld, d in zip(layer.data, data)]) assert layer.ndim == 4 assert np.all([s == 'ellipse' for s in layer.shape_type]) # test adding via add_ellipses layer2 = Shapes(ndim=4) layer2.add_ellipses(data) assert layer.nshapes == layer2.nshapes assert np.all( [np.all(ld == ld2) for ld, ld2 in zip(layer.data, layer2.data)] ) assert layer.ndim == 4 assert np.all([s == 'ellipse' for s in layer2.shape_type]) def test_ellipses_roundtrip(): """Test a full roundtrip with ellipss data.""" shape = (10, 4, 2) np.random.seed(0) data = 20 * np.random.random(shape) layer = Shapes(data, shape_type='ellipse') new_layer = Shapes(layer.data, shape_type='ellipse') assert np.all([nd == d for nd, d in zip(new_layer.data, layer.data)]) @pytest.mark.parametrize('shape', [(1, 2, 2), (10, 2, 2)]) def test_lines(shape): """Test instantiating Shapes layer with a random 2D lines.""" # Test instantiating with data np.random.seed(0) data = 20 * np.random.random(shape) layer = Shapes(data, shape_type='line') assert layer.nshapes == shape[0] assert np.all([np.all(ld == d) for ld, d in zip(layer.data, data)]) assert layer.ndim == shape[2] assert np.all([s == 'line' for s in layer.shape_type]) # Test adding using add_lines layer2 = Shapes() layer2.add_lines(data) assert layer.nshapes == layer2.nshapes assert np.allclose(layer2.data, layer.data) assert np.all([s == 'line' for s in layer2.shape_type]) def test_add_lines_raises_error(): """Test adding lines with incorrect vertices raise error""" # single line shape = (1, 3, 2) data = 20 * np.random.random(shape) layer = Shapes() with pytest.raises(ValueError): layer.add_lines(data) # multiple lines data = [ 20 * np.random.random((np.random.randint(3, 10), 2)) for _ in range(10) ] with pytest.raises(ValueError): layer.add_lines(data) def test_lines_with_shape_type(): """Test instantiating lines with shape_type""" # Test (single line, shape_type) tuple shape = (1, 2, 2) np.random.seed(0) end_points = 20 * np.random.random(shape) data = (end_points, 'line') layer = Shapes(data) assert layer.nshapes == shape[0] assert np.all(layer.data[0] == end_points[0]) assert layer.ndim == shape[2] assert np.all([s == 'line' for s in layer.shape_type]) # Test (multiple lines, shape_type) tuple shape = (10, 2, 2) np.random.seed(0) end_points = 20 * np.random.random(shape) data = (end_points, "line") layer = Shapes(data) assert layer.nshapes == shape[0] assert np.all([np.all(ld == d) for ld, d in zip(layer.data, end_points)]) assert layer.ndim == shape[2] assert np.all([s == 'line' for s in layer.shape_type]) # Test list of (line, shape_type) tuples shape = (10, 2, 2) np.random.seed(0) end_points = 20 * np.random.random(shape) data = [(end_points[i], "line") for i in range(shape[0])] layer = Shapes(data) assert layer.nshapes == shape[0] assert np.all([np.all(ld == d) for ld, d in zip(layer.data, end_points)]) assert layer.ndim == shape[2] assert np.all([s == 'line' for s in layer.shape_type]) def test_lines_roundtrip(): """Test a full roundtrip with line data.""" shape = (10, 2, 2) np.random.seed(0) data = 20 * np.random.random(shape) layer = Shapes(data, shape_type='line') new_layer = Shapes(layer.data, shape_type='line') assert np.all([nd == d for nd, d in zip(new_layer.data, layer.data)]) @pytest.mark.parametrize( "shape", [ # single path, six points (6, 2), ] + [ # multiple 2D paths with different numbers of points (np.random.randint(2, 12), 2) for _ in range(10) ], ) def test_paths(shape): """Test instantiating Shapes layer with random 2D paths.""" # Test instantiating with data data = [20 * np.random.random(shape)] layer = Shapes(data, shape_type='path') assert layer.nshapes == len(data) assert np.all([np.all(ld == d) for ld, d in zip(layer.data, data)]) assert layer.ndim == 2 assert np.all([s == 'path' for s in layer.shape_type]) # Test adding to layer via add_paths layer2 = Shapes() layer2.add_paths(data) assert layer.nshapes == layer2.nshapes assert np.allclose(layer2.data, layer.data) assert np.all([s == 'path' for s in layer2.shape_type]) def test_add_paths_raises_error(): """Test adding paths with incorrect vertices raise error""" # single path shape = (1, 1, 2) data = 20 * np.random.random(shape) layer = Shapes() with pytest.raises(ValueError): layer.add_paths(data) # multiple paths data = 20 * np.random.random((10, 1, 2)) with pytest.raises(ValueError): layer.add_paths(data) def test_paths_with_shape_type(): """Test instantiating paths with shape_type in data""" # Test (single path, shape_type) tuple shape = (1, 6, 2) np.random.seed(0) path_points = 20 * np.random.random(shape) data = (path_points, "path") layer = Shapes(data) assert layer.nshapes == shape[0] assert np.all(layer.data[0] == path_points[0]) assert layer.ndim == shape[2] assert np.all([s == 'path' for s in layer.shape_type]) # Test (list of paths, shape_type) tuple path_points = [ 20 * np.random.random((np.random.randint(2, 12), 2)) for i in range(10) ] data = (path_points, "path") layer = Shapes(data) assert layer.nshapes == len(path_points) assert np.all([np.all(ld == d) for ld, d in zip(layer.data, path_points)]) assert layer.ndim == 2 assert np.all([s == 'path' for s in layer.shape_type]) # Test list of (path, shape_type) tuples data = [(path_points[i], "path") for i in range(len(path_points))] layer = Shapes(data) assert layer.nshapes == len(data) assert np.all([np.all(ld == d) for ld, d in zip(layer.data, path_points)]) assert layer.ndim == 2 assert np.all([s == 'path' for s in layer.shape_type]) def test_paths_roundtrip(): """Test a full roundtrip with path data.""" np.random.seed(0) data = [ 20 * np.random.random((np.random.randint(2, 12), 2)) for i in range(10) ] layer = Shapes(data, shape_type='path') new_layer = Shapes(layer.data, shape_type='path') assert np.all( [np.all(nd == d) for nd, d in zip(new_layer.data, layer.data)] ) @pytest.mark.parametrize( "shape", [ # single 2D polygon, six points (6, 2), ] + [ # multiple 2D polygons with different numbers of points (np.random.randint(3, 12), 2) for _ in range(10) ], ) def test_polygons(shape): """Test instantiating Shapes layer with a random 2D polygons.""" # Test instantiating with data data = [20 * np.random.random(shape)] layer = Shapes(data, shape_type='polygon') assert layer.nshapes == len(data) assert np.all([np.all(ld == d) for ld, d in zip(layer.data, data)]) assert layer.ndim == 2 assert np.all([s == 'polygon' for s in layer.shape_type]) # Test adding via add_polygons layer2 = Shapes() layer2.add_polygons(data) assert layer.nshapes == layer2.nshapes assert np.allclose(layer2.data, layer.data) assert np.all([s == 'polygon' for s in layer2.shape_type]) def test_add_polygons_raises_error(): """Test input validation for add_polygons method""" layer = Shapes() np.random.seed(0) # single polygon, 2 vertices data = 20 * np.random.random((1, 2, 2)) with pytest.raises(ValueError): layer.add_polygons(data) # multiple polygons, only some with 2 vertices data = [20 * np.random.random((5, 2)) for _ in range(5)] + [ 20 * np.random.random((2, 2)) for _ in range(2) ] with pytest.raises(ValueError): layer.add_polygons(data) def test_polygons_with_shape_type(): """Test 2D polygons with shape_type in data""" # Test single (polygon, shape_type) tuple shape = (1, 6, 2) np.random.seed(0) vertices = 20 * np.random.random(shape) data = (vertices, 'polygon') layer = Shapes(data) assert layer.nshapes == shape[0] assert np.all(layer.data[0] == vertices[0]) assert layer.ndim == shape[2] assert np.all([s == 'polygon' for s in layer.shape_type]) # Test (list of polygons, shape_type) tuple polygons = [ 20 * np.random.random((np.random.randint(2, 12), 2)) for i in range(10) ] data = (polygons, 'polygon') layer = Shapes(data) assert layer.nshapes == len(polygons) assert np.all([np.all(ld == d) for ld, d in zip(layer.data, polygons)]) assert layer.ndim == 2 assert np.all([s == 'polygon' for s in layer.shape_type]) # Test list of (polygon, shape_type) tuples data = [(polygons[i], 'polygon') for i in range(len(polygons))] layer = Shapes(data) assert layer.nshapes == len(polygons) assert np.all([np.all(ld == d) for ld, d in zip(layer.data, polygons)]) assert layer.ndim == 2 assert np.all([s == 'polygon' for s in layer.shape_type]) def test_polygon_roundtrip(): """Test a full roundtrip with polygon data.""" np.random.seed(0) data = [ 20 * np.random.random((np.random.randint(2, 12), 2)) for i in range(10) ] layer = Shapes(data, shape_type='polygon') new_layer = Shapes(layer.data, shape_type='polygon') assert np.all( [np.all(nd == d) for nd, d in zip(new_layer.data, layer.data)] ) def test_mixed_shapes(): """Test instantiating Shapes layer with a mix of random 2D shapes.""" # Test multiple polygons with different numbers of points np.random.seed(0) shape_vertices = [ 20 * np.random.random((np.random.randint(2, 12), 2)) for i in range(5) ] + list(np.random.random((5, 4, 2))) shape_type = ['polygon'] * 5 + ['rectangle'] * 3 + ['ellipse'] * 2 layer = Shapes(shape_vertices, shape_type=shape_type) assert layer.nshapes == len(shape_vertices) assert np.all( [np.all(ld == d) for ld, d in zip(layer.data, shape_vertices)] ) assert layer.ndim == 2 assert np.all([s == so for s, so in zip(layer.shape_type, shape_type)]) # Test roundtrip with mixed data new_layer = Shapes(layer.data, shape_type=layer.shape_type) assert np.all( [np.all(nd == d) for nd, d in zip(new_layer.data, layer.data)] ) assert np.all( [ns == s for ns, s in zip(new_layer.shape_type, layer.shape_type)] ) def test_mixed_shapes_with_shape_type(): """Test adding mixed shapes with shape_type in data""" np.random.seed(0) shape_vertices = [ 20 * np.random.random((np.random.randint(2, 12), 2)) for i in range(5) ] + list(np.random.random((5, 4, 2))) shape_type = ['polygon'] * 5 + ['rectangle'] * 3 + ['ellipse'] * 2 # Test multiple (shape, shape_type) tuples data = list(zip(shape_vertices, shape_type)) layer = Shapes(data) assert layer.nshapes == len(shape_vertices) assert np.all( [np.all(ld == d) for ld, d in zip(layer.data, shape_vertices)] ) assert layer.ndim == 2 assert np.all([s == so for s, so in zip(layer.shape_type, shape_type)]) def test_data_shape_type_overwrites_meta(): """Test shape type passed through data property overwrites metadata shape type""" shape = (10, 4, 2) np.random.seed(0) vertices = 20 * np.random.random(shape) data = (vertices, "ellipse") layer = Shapes(data, shape_type='rectangle') assert np.all([s == 'ellipse' for s in layer.shape_type]) data = [(vertices[i], "ellipse") for i in range(shape[0])] layer = Shapes(data, shape_type='rectangle') assert np.all([s == 'ellipse' for s in layer.shape_type]) def test_changing_shapes(): """Test changing Shapes data.""" shape_a = (10, 4, 2) shape_b = (20, 4, 2) np.random.seed(0) vertices_a = 20 * np.random.random(shape_a) vertices_b = 20 * np.random.random(shape_b) layer = Shapes(vertices_a) assert layer.nshapes == shape_a[0] layer.data = vertices_b assert layer.nshapes == shape_b[0] assert np.all([np.all(ld == d) for ld, d in zip(layer.data, vertices_b)]) assert layer.ndim == shape_b[2] assert np.all([s == 'rectangle' for s in layer.shape_type]) # setting data with shape type data_a = (vertices_a, "ellipse") layer.data = data_a assert layer.nshapes == shape_a[0] assert np.all([np.all(ld == d) for ld, d in zip(layer.data, vertices_a)]) assert layer.ndim == shape_a[2] assert np.all([s == 'ellipse' for s in layer.shape_type]) # setting data with fewer shapes smaller_data = vertices_a[:5] current_edge_color = layer._data_view.edge_color current_edge_width = layer._data_view.edge_widths current_face_color = layer._data_view.face_color current_z = layer._data_view.z_indices layer.data = smaller_data assert layer.nshapes == smaller_data.shape[0] assert np.allclose(layer._data_view.edge_color, current_edge_color[:5]) assert np.allclose(layer._data_view.face_color, current_face_color[:5]) assert np.allclose(layer._data_view.edge_widths, current_edge_width[:5]) assert np.allclose(layer._data_view.z_indices, current_z[:5]) # setting data with added shapes current_edge_color = layer._data_view.edge_color current_edge_width = layer._data_view.edge_widths current_face_color = layer._data_view.face_color current_z = layer._data_view.z_indices bigger_data = vertices_b layer.data = bigger_data assert layer.nshapes == bigger_data.shape[0] assert np.allclose(layer._data_view.edge_color[:5], current_edge_color) assert np.allclose(layer._data_view.face_color[:5], current_face_color) assert np.allclose(layer._data_view.edge_widths[:5], current_edge_width) assert np.allclose(layer._data_view.z_indices[:5], current_z) def test_changing_shape_type(): """Test changing shape type""" np.random.seed(0) rectangles = 20 * np.random.random((10, 4, 2)) layer = Shapes(rectangles, shape_type='rectangle') layer.shape_type = "ellipse" assert np.all([s == 'ellipse' for s in layer.shape_type]) def test_adding_shapes(): """Test adding shapes.""" # Start with polygons with different numbers of points np.random.seed(0) data = [ 20 * np.random.random((np.random.randint(2, 12), 2)) for i in range(5) ] # shape_type = ['polygon'] * 5 + ['rectangle'] * 3 + ['ellipse'] * 2 layer = Shapes(data, shape_type='polygon') new_data = np.random.random((5, 4, 2)) new_shape_type = ['rectangle'] * 3 + ['ellipse'] * 2 layer.add(new_data, shape_type=new_shape_type) all_data = data + list(new_data) all_shape_type = ['polygon'] * 5 + new_shape_type assert layer.nshapes == len(all_data) assert np.all([np.all(ld == d) for ld, d in zip(layer.data, all_data)]) assert layer.ndim == 2 assert np.all([s == so for s, so in zip(layer.shape_type, all_shape_type)]) # test adding data with shape_type new_vertices = np.random.random((5, 4, 2)) new_shape_type2 = ['ellipse'] * 3 + ['rectangle'] * 2 new_data2 = list(zip(new_vertices, new_shape_type2)) layer.add(new_data2) all_vertices = all_data + list(new_vertices) all_shape_type = all_shape_type + new_shape_type2 assert layer.nshapes == len(all_vertices) assert np.all([np.all(ld == d) for ld, d in zip(layer.data, all_vertices)]) assert layer.ndim == 2 assert np.all([s == so for s, so in zip(layer.shape_type, all_shape_type)]) def test_adding_shapes_to_empty(): """Test adding shapes to empty.""" data = np.empty((0, 0, 2)) np.random.seed(0) layer = Shapes(np.empty((0, 0, 2))) assert len(layer.data) == 0 data = [ 20 * np.random.random((np.random.randint(2, 12), 2)) for i in range(5) ] + list(np.random.random((5, 4, 2))) shape_type = ['path'] * 5 + ['rectangle'] * 3 + ['ellipse'] * 2 layer.add(data, shape_type=shape_type) assert layer.nshapes == len(data) assert np.all([np.all(ld == d) for ld, d in zip(layer.data, data)]) assert layer.ndim == 2 assert np.all([s == so for s, so in zip(layer.shape_type, shape_type)]) def test_selecting_shapes(): """Test selecting shapes.""" data = 20 * np.random.random((10, 4, 2)) np.random.seed(0) layer = Shapes(data) layer.selected_data = {0, 1} assert layer.selected_data == {0, 1} layer.selected_data = {9} assert layer.selected_data == {9} layer.selected_data = set() assert layer.selected_data == set() def test_removing_all_shapes_empty_list(): """Test removing all shapes with an empty list.""" data = 20 * np.random.random((10, 4, 2)) np.random.seed(0) layer = Shapes(data) assert layer.nshapes == 10 layer.data = [] assert layer.nshapes == 0 def test_removing_all_shapes_empty_array(): """Test removing all shapes with an empty list.""" data = 20 * np.random.random((10, 4, 2)) np.random.seed(0) layer = Shapes(data) assert layer.nshapes == 10 layer.data = np.empty((0, 2)) assert layer.nshapes == 0 def test_removing_selected_shapes(): """Test removing selected shapes.""" np.random.seed(0) data = [ 20 * np.random.random((np.random.randint(2, 12), 2)) for i in range(5) ] + list(np.random.random((5, 4, 2))) shape_type = ['polygon'] * 5 + ['rectangle'] * 3 + ['ellipse'] * 2 layer = Shapes(data, shape_type=shape_type) # With nothing selected no points should be removed layer.remove_selected() assert len(layer.data) == len(data) # Select three shapes and remove them layer.selected_data = {1, 7, 8} layer.remove_selected() keep = [0] + list(range(2, 7)) + [9] data_keep = [data[i] for i in keep] shape_type_keep = [shape_type[i] for i in keep] assert len(layer.data) == len(data_keep) assert len(layer.selected_data) == 0 assert np.all([np.all(ld == d) for ld, d in zip(layer.data, data_keep)]) assert layer.ndim == 2 assert np.all( [s == so for s, so in zip(layer.shape_type, shape_type_keep)] ) def test_changing_modes(): """Test changing modes.""" np.random.seed(0) data = 20 * np.random.random((10, 4, 2)) layer = Shapes(data) assert layer.mode == 'pan_zoom' assert layer.interactive is True layer.mode = 'select' assert layer.mode == 'select' assert layer.interactive is False layer.mode = 'direct' assert layer.mode == 'direct' assert layer.interactive is False layer.mode = 'vertex_insert' assert layer.mode == 'vertex_insert' assert layer.interactive is False layer.mode = 'vertex_remove' assert layer.mode == 'vertex_remove' assert layer.interactive is False layer.mode = 'add_rectangle' assert layer.mode == 'add_rectangle' assert layer.interactive is False layer.mode = 'add_ellipse' assert layer.mode == 'add_ellipse' assert layer.interactive is False layer.mode = 'add_line' assert layer.mode == 'add_line' assert layer.interactive is False layer.mode = 'add_path' assert layer.mode == 'add_path' assert layer.interactive is False layer.mode = 'add_polygon' assert layer.mode == 'add_polygon' assert layer.interactive is False layer.mode = 'pan_zoom' assert layer.mode == 'pan_zoom' assert layer.interactive is True def test_name(): """Test setting layer name.""" np.random.seed(0) data = 20 * np.random.random((10, 4, 2)) layer = Shapes(data) assert layer.name == 'Shapes' layer = Shapes(data, name='random') assert layer.name == 'random' layer.name = 'shps' assert layer.name == 'shps' def test_visiblity(): """Test setting layer visibility.""" np.random.seed(0) data = 20 * np.random.random((10, 4, 2)) layer = Shapes(data) assert layer.visible is True layer.visible = False assert layer.visible is False layer = Shapes(data, visible=False) assert layer.visible is False layer.visible = True assert layer.visible is True def test_opacity(): """Test setting opacity.""" shape = (10, 4, 2) np.random.seed(0) data = 20 * np.random.random(shape) layer = Shapes(data) # Check default opacity value of 0.7 assert layer.opacity == 0.7 # Select data and change opacity of selection layer.selected_data = {0, 1} assert layer.opacity == 0.7 layer.opacity = 0.5 assert layer.opacity == 0.5 # Add new shape and test its width new_shape = np.random.random((1, 4, 2)) layer.selected_data = set() layer.add(new_shape) assert layer.opacity == 0.5 # Instantiate with custom opacity layer2 = Shapes(data, opacity=0.2) assert layer2.opacity == 0.2 # Check removing data shouldn't change opacity layer2.selected_data = {0, 2} layer2.remove_selected() assert len(layer2.data) == shape[0] - 2 assert layer2.opacity == 0.2 def test_blending(): """Test setting layer blending.""" np.random.seed(0) data = 20 * np.random.random((10, 4, 2)) layer = Shapes(data) assert layer.blending == 'translucent' layer.blending = 'additive' assert layer.blending == 'additive' layer = Shapes(data, blending='additive') assert layer.blending == 'additive' layer.blending = 'opaque' assert layer.blending == 'opaque' @pytest.mark.parametrize("attribute", ['edge', 'face']) def test_switch_color_mode(attribute): """Test switching between color modes""" shape = (10, 4, 2) np.random.seed(0) data = 20 * np.random.random(shape) # create a continuous property with a known value in the last element continuous_prop = np.random.random((shape[0],)) continuous_prop[-1] = 1 properties = { 'shape_truthiness': continuous_prop, 'shape_type': _make_cycled_properties(['A', 'B'], shape[0]), } initial_color = [1, 0, 0, 1] color_cycle = ['red', 'blue'] color_kwarg = f'{attribute}_color' colormap_kwarg = f'{attribute}_colormap' color_cycle_kwarg = f'{attribute}_color_cycle' args = { color_kwarg: initial_color, colormap_kwarg: 'gray', color_cycle_kwarg: color_cycle, } layer = Shapes(data, properties=properties, **args) layer_color_mode = getattr(layer, f'{attribute}_color_mode') layer_color = getattr(layer, f'{attribute}_color') assert layer_color_mode == 'direct' np.testing.assert_allclose( layer_color, np.repeat([initial_color], shape[0], axis=0) ) # there should not be an edge_color_property color_property = getattr(layer, f'_{attribute}_color_property') assert color_property == '' # transitioning to colormap should raise a warning # because there isn't an edge color property yet and # the first property in shapes.properties is being automatically selected with pytest.warns(UserWarning): setattr(layer, f'{attribute}_color_mode', 'colormap') color_property = getattr(layer, f'_{attribute}_color_property') assert color_property == next(iter(properties)) layer_color = getattr(layer, f'{attribute}_color') np.testing.assert_allclose(layer_color[-1], [1, 1, 1, 1]) # switch to color cycle setattr(layer, f'{attribute}_color_mode', 'cycle') setattr(layer, f'{attribute}_color', 'shape_type') color = getattr(layer, f'{attribute}_color') layer_color = transform_color(color_cycle * int(shape[0] / 2)) np.testing.assert_allclose(color, layer_color) # switch back to direct, edge_colors shouldn't change setattr(layer, f'{attribute}_color_mode', 'direct') new_edge_color = getattr(layer, f'{attribute}_color') np.testing.assert_allclose(new_edge_color, color) @pytest.mark.parametrize("attribute", ['edge', 'face']) def test_color_direct(attribute: str): """Test setting face/edge color directly.""" shape = (10, 4, 2) np.random.seed(0) data = 20 * np.random.random(shape) layer_kwargs = {f'{attribute}_color': 'black'} layer = Shapes(data, **layer_kwargs) color_array = transform_color(['black'] * shape[0]) current_color = getattr(layer, f'current_{attribute}_color') layer_color = getattr(layer, f'{attribute}_color') assert current_color == 'black' assert len(layer.edge_color) == shape[0] np.testing.assert_allclose(color_array, layer_color) # With no data selected changing color has no effect setattr(layer, f'current_{attribute}_color', 'blue') current_color = getattr(layer, f'current_{attribute}_color') assert current_color == 'blue' np.testing.assert_allclose(color_array, layer_color) # Select data and change edge color of selection selected_data = {0, 1} layer.selected_data = {0, 1} current_color = getattr(layer, f'current_{attribute}_color') assert current_color == 'black' setattr(layer, f'current_{attribute}_color', 'green') colorarray_green = transform_color(['green'] * len(layer.selected_data)) color_array[list(selected_data)] = colorarray_green layer_color = getattr(layer, f'{attribute}_color') np.testing.assert_allclose(color_array, layer_color) # Add new shape and test its color new_shape = np.random.random((1, 4, 2)) layer.selected_data = set() setattr(layer, f'current_{attribute}_color', 'blue') layer.add(new_shape) color_array = np.vstack([color_array, transform_color('blue')]) layer_color = getattr(layer, f'{attribute}_color') assert len(layer_color) == shape[0] + 1 np.testing.assert_allclose(color_array, layer_color) # Check removing data adjusts colors correctly layer.selected_data = {0, 2} layer.remove_selected() assert len(layer.data) == shape[0] - 1 layer_color = getattr(layer, f'{attribute}_color') assert len(layer_color) == shape[0] - 1 np.testing.assert_allclose( layer_color, np.vstack((color_array[1], color_array[3:])), ) # set the color directly setattr(layer, f'{attribute}_color', 'black') color_array = np.tile([[0, 0, 0, 1]], (len(layer.data), 1)) layer_color = getattr(layer, f'{attribute}_color') np.testing.assert_allclose(color_array, layer_color) @pytest.mark.parametrize("attribute", ['edge', 'face']) def test_single_shape_properties(attribute): """Test creating single shape with properties""" shape = (4, 2) np.random.seed(0) data = 20 * np.random.random(shape) layer_kwargs = {f'{attribute}_color': 'red'} layer = Shapes(data, **layer_kwargs) layer_color = getattr(layer, f'{attribute}_color') assert len(layer_color) == 1 np.testing.assert_allclose([1, 0, 0, 1], layer_color[0]) color_cycle_str = ['red', 'blue'] color_cycle_rgb = [[1, 0, 0], [0, 0, 1]] color_cycle_rgba = [[1, 0, 0, 1], [0, 0, 1, 1]] @pytest.mark.parametrize("attribute", ['edge', 'face']) @pytest.mark.parametrize( "color_cycle", [color_cycle_str, color_cycle_rgb, color_cycle_rgba], ) def test_color_cycle(attribute, color_cycle): """Test setting edge/face color with a color cycle list""" # create Shapes using list color cycle shape = (10, 4, 2) np.random.seed(0) data = 20 * np.random.random(shape) properties = {'shape_type': _make_cycled_properties(['A', 'B'], shape[0])} shapes_kwargs = { 'properties': properties, f'{attribute}_color': 'shape_type', f'{attribute}_color_cycle': color_cycle, } layer = Shapes(data, **shapes_kwargs) np.testing.assert_equal(layer.properties, properties) color_array = transform_color( list(islice(cycle(color_cycle), 0, shape[0])) ) layer_color = getattr(layer, f'{attribute}_color') np.testing.assert_allclose(layer_color, color_array) # Add new shape and test its color new_shape = np.random.random((1, 4, 2)) layer.selected_data = {0} layer.add(new_shape) layer_color = getattr(layer, f'{attribute}_color') assert len(layer_color) == shape[0] + 1 np.testing.assert_allclose( layer_color, np.vstack((color_array, transform_color('red'))), ) # Check removing data adjusts colors correctly layer.selected_data = {0, 2} layer.remove_selected() assert len(layer.data) == shape[0] - 1 layer_color = getattr(layer, f'{attribute}_color') assert len(layer_color) == shape[0] - 1 np.testing.assert_allclose( layer_color, np.vstack((color_array[1], color_array[3:], transform_color('red'))), ) # refresh colors layer.refresh_colors(update_color_mapping=True) # test adding a shape with a new property value layer.selected_data = {} current_properties = layer.current_properties current_properties['shape_type'] = np.array(['new']) layer.current_properties = current_properties new_shape_2 = np.random.random((1, 4, 2)) layer.add(new_shape_2) color_cycle_map = getattr(layer, f'{attribute}_color_cycle_map') assert 'new' in color_cycle_map np.testing.assert_allclose( color_cycle_map['new'], np.squeeze(transform_color(color_cycle[0])) ) @pytest.mark.parametrize("attribute", ['edge', 'face']) def test_add_color_cycle_to_empty_layer(attribute): """Test adding a shape to an empty layer when edge/face color is a color cycle See: https://github.com/napari/napari/pull/1069 """ default_properties = {'shape_type': np.array(['A'])} color_cycle = ['red', 'blue'] shapes_kwargs = { 'property_choices': default_properties, f'{attribute}_color': 'shape_type', f'{attribute}_color_cycle': color_cycle, } layer = Shapes(**shapes_kwargs) # verify the current_edge_color is correct expected_color = transform_color(color_cycle[0]) current_color = getattr(layer, f'_current_{attribute}_color') np.testing.assert_allclose(current_color, expected_color) # add a shape np.random.seed(0) new_shape = 20 * np.random.random((1, 4, 2)) layer.add(new_shape) props = {'shape_type': np.array(['A'])} expected_color = np.array([[1, 0, 0, 1]]) np.testing.assert_equal(layer.properties, props) attribute_color = getattr(layer, f'{attribute}_color') np.testing.assert_allclose(attribute_color, expected_color) # add a shape with a new property layer.selected_data = [] layer.current_properties = {'shape_type': np.array(['B'])} new_shape_2 = 20 * np.random.random((1, 4, 2)) layer.add(new_shape_2) new_color = np.array([0, 0, 1, 1]) expected_color = np.vstack((expected_color, new_color)) new_properties = {'shape_type': np.array(['A', 'B'])} attribute_color = getattr(layer, f'{attribute}_color') np.testing.assert_allclose(attribute_color, expected_color) np.testing.assert_equal(layer.properties, new_properties) @pytest.mark.parametrize("attribute", ['edge', 'face']) def test_adding_value_color_cycle(attribute): """Test that adding values to properties used to set a color cycle and then calling Shapes.refresh_colors() performs the update and adds the new value to the face/edge_color_cycle_map. """ shape = (10, 4, 2) np.random.seed(0) data = 20 * np.random.random(shape) properties = {'shape_type': _make_cycled_properties(['A', 'B'], shape[0])} color_cycle = ['red', 'blue'] shapes_kwargs = { 'properties': properties, f'{attribute}_color': 'shape_type', f'{attribute}_color_cycle': color_cycle, } layer = Shapes(data, **shapes_kwargs) # make shape 0 shape_type C shape_types = layer.properties['shape_type'] shape_types[0] = 'C' layer.properties['shape_type'] = shape_types layer.refresh_colors(update_color_mapping=False) color_cycle_map = getattr(layer, f'{attribute}_color_cycle_map') color_map_keys = [*color_cycle_map] assert 'C' in color_map_keys @pytest.mark.parametrize("attribute", ['edge', 'face']) def test_color_colormap(attribute): """Test setting edge/face color with a colormap""" # create Shapes using with a colormap shape = (10, 4, 2) np.random.seed(0) data = 20 * np.random.random(shape) properties = {'shape_type': _make_cycled_properties([0, 1.5], shape[0])} shapes_kwargs = { 'properties': properties, f'{attribute}_color': 'shape_type', f'{attribute}_colormap': 'gray', } layer = Shapes(data, **shapes_kwargs) np.testing.assert_equal(layer.properties, properties) color_mode = getattr(layer, f'{attribute}_color_mode') assert color_mode == 'colormap' color_array = transform_color(['black', 'white'] * int(shape[0] / 2)) attribute_color = getattr(layer, f'{attribute}_color') assert np.all(attribute_color == color_array) # change the color cycle - face_color should not change setattr(layer, f'{attribute}_color_cycle', ['red', 'blue']) attribute_color = getattr(layer, f'{attribute}_color') assert np.all(attribute_color == color_array) # Add new shape and test its color new_shape = np.random.random((1, 4, 2)) layer.selected_data = {0} layer.add(new_shape) attribute_color = getattr(layer, f'{attribute}_color') assert len(attribute_color) == shape[0] + 1 np.testing.assert_allclose( attribute_color, np.vstack((color_array, transform_color('black'))), ) # Check removing data adjusts colors correctly layer.selected_data = {0, 2} layer.remove_selected() assert len(layer.data) == shape[0] - 1 attribute_color = getattr(layer, f'{attribute}_color') assert len(attribute_color) == shape[0] - 1 np.testing.assert_allclose( attribute_color, np.vstack( ( color_array[1], color_array[3:], transform_color('black'), ) ), ) # adjust the clims setattr(layer, f'{attribute}_contrast_limits', (0, 3)) layer.refresh_colors(update_color_mapping=False) attribute_color = getattr(layer, f'{attribute}_color') np.testing.assert_allclose(attribute_color[-2], [0.5, 0.5, 0.5, 1]) # change the colormap new_colormap = 'viridis' setattr(layer, f'{attribute}_colormap', new_colormap) attribute_colormap = getattr(layer, f'{attribute}_colormap') assert attribute_colormap.name == new_colormap @pytest.mark.parametrize("attribute", ['edge', 'face']) def test_colormap_without_properties(attribute): """Setting the colormode to colormap should raise an exception""" shape = (10, 4, 2) np.random.seed(0) data = 20 * np.random.random(shape) layer = Shapes(data) with pytest.raises(ValueError): setattr(layer, f'{attribute}_color_mode', 'colormap') @pytest.mark.parametrize("attribute", ['edge', 'face']) def test_colormap_with_categorical_properties(attribute): """Setting the colormode to colormap should raise an exception""" shape = (10, 4, 2) np.random.seed(0) data = 20 * np.random.random(shape) properties = {'shape_type': _make_cycled_properties(['A', 'B'], shape[0])} layer = Shapes(data, properties=properties) with pytest.raises(TypeError): with pytest.warns(UserWarning): setattr(layer, f'{attribute}_color_mode', 'colormap') @pytest.mark.parametrize("attribute", ['edge', 'face']) def test_add_colormap(attribute): """Test directly adding a vispy Colormap object""" shape = (10, 4, 2) np.random.seed(0) data = 20 * np.random.random(shape) annotations = {'shape_type': _make_cycled_properties([0, 1.5], shape[0])} color_kwarg = f'{attribute}_color' colormap_kwarg = f'{attribute}_colormap' args = {color_kwarg: 'shape_type', colormap_kwarg: 'viridis'} layer = Shapes(data, properties=annotations, **args) setattr(layer, f'{attribute}_colormap', 'gray') layer_colormap = getattr(layer, f'{attribute}_colormap') assert layer_colormap.name == 'gray' def test_edge_width(): """Test setting edge width.""" shape = (10, 4, 2) np.random.seed(0) data = 20 * np.random.random(shape) layer = Shapes(data) assert layer.current_edge_width == 1 assert len(layer.edge_width) == shape[0] assert layer.edge_width == [1] * shape[0] # With no data selected changing edge width has no effect layer.current_edge_width = 2 assert layer.current_edge_width == 2 assert layer.edge_width == [1] * shape[0] # Select data and change edge color of selection layer.selected_data = {0, 1} assert layer.current_edge_width == 1 layer.current_edge_width = 3 assert layer.edge_width == [3] * 2 + [1] * (shape[0] - 2) # Add new shape and test its width new_shape = np.random.random((1, 4, 2)) layer.selected_data = set() layer.current_edge_width = 4 layer.add(new_shape) assert layer.edge_width == [3] * 2 + [1] * (shape[0] - 2) + [4] # Instantiate with custom edge width layer = Shapes(data, edge_width=5) assert layer.current_edge_width == 5 # Instantiate with custom edge width list width_list = [2, 3] * 5 layer = Shapes(data, edge_width=width_list) assert layer.current_edge_width == 1 assert layer.edge_width == width_list # Add new shape and test its color layer.current_edge_width = 4 layer.add(new_shape) assert len(layer.edge_width) == shape[0] + 1 assert layer.edge_width == width_list + [4] # Check removing data adjusts colors correctly layer.selected_data = {0, 2} layer.remove_selected() assert len(layer.data) == shape[0] - 1 assert len(layer.edge_width) == shape[0] - 1 assert layer.edge_width == [width_list[1]] + width_list[3:] + [4] # Test setting edge width with number layer.edge_width = 4 assert all([width == 4 for width in layer.edge_width]) # Test setting edge width with list new_widths = [2] * 5 + [3] * 4 layer.edge_width = new_widths assert layer.edge_width == new_widths # Test setting with incorrect size list throws error new_widths = [2, 3] with pytest.raises(ValueError): layer.edge_width = new_widths def test_z_index(): """Test setting z-index during instantiation.""" shape = (10, 4, 2) np.random.seed(0) data = 20 * np.random.random(shape) layer = Shapes(data) assert layer.z_index == [0] * shape[0] # Instantiate with custom z-index layer = Shapes(data, z_index=4) assert layer.z_index == [4] * shape[0] # Instantiate with custom z-index list z_index_list = [2, 3] * 5 layer = Shapes(data, z_index=z_index_list) assert layer.z_index == z_index_list # Add new shape and its z-index new_shape = np.random.random((1, 4, 2)) layer.add(new_shape) assert len(layer.z_index) == shape[0] + 1 assert layer.z_index == z_index_list + [4] # Check removing data adjusts colors correctly layer.selected_data = {0, 2} layer.remove_selected() assert len(layer.data) == shape[0] - 1 assert len(layer.z_index) == shape[0] - 1 assert layer.z_index == [z_index_list[1]] + z_index_list[3:] + [4] # Test setting index with number layer.z_index = 4 assert all([idx == 4 for idx in layer.z_index]) # Test setting index with list new_z_indices = [2] * 5 + [3] * 4 layer.z_index = new_z_indices assert layer.z_index == new_z_indices # Test setting with incorrect size list throws error new_z_indices = [2, 3] with pytest.raises(ValueError): layer.z_index = new_z_indices def test_move_to_front(): """Test moving shapes to front.""" shape = (10, 4, 2) np.random.seed(0) data = 20 * np.random.random(shape) z_index_list = [2, 3] * 5 layer = Shapes(data, z_index=z_index_list) assert layer.z_index == z_index_list # Move selected shapes to front layer.selected_data = {0, 2} layer.move_to_front() assert layer.z_index == [4] + [z_index_list[1]] + [4] + z_index_list[3:] def test_move_to_back(): """Test moving shapes to back.""" shape = (10, 4, 2) np.random.seed(0) data = 20 * np.random.random(shape) z_index_list = [2, 3] * 5 layer = Shapes(data, z_index=z_index_list) assert layer.z_index == z_index_list # Move selected shapes to front layer.selected_data = {0, 2} layer.move_to_back() assert layer.z_index == [1] + [z_index_list[1]] + [1] + z_index_list[3:] def test_interaction_box(): """Test the creation of the interaction box.""" shape = (10, 4, 2) np.random.seed(0) data = 20 * np.random.random(shape) layer = Shapes(data) assert layer._selected_box is None layer.selected_data = {0} assert len(layer._selected_box) == 10 layer.selected_data = {0, 1} assert len(layer._selected_box) == 10 layer.selected_data = set() assert layer._selected_box is None def test_copy_and_paste(): """Test copying and pasting selected shapes.""" shape = (10, 4, 2) np.random.seed(0) data = 20 * np.random.random(shape) layer = Shapes(data) # Clipboard starts empty assert layer._clipboard == {} # Pasting empty clipboard doesn't change data layer._paste_data() assert len(layer.data) == 10 # Copying with nothing selected leave clipboard empty layer._copy_data() assert layer._clipboard == {} # Copying and pasting with two shapes selected adds to clipboard and data layer.selected_data = {0, 1} layer._copy_data() layer._paste_data() assert len(layer._clipboard) > 0 assert len(layer.data) == shape[0] + 2 assert np.all( [np.all(a == b) for a, b in zip(layer.data[:2], layer.data[-2:])] ) # Pasting again adds two more shapes to data layer._paste_data() assert len(layer.data) == shape[0] + 4 assert np.all( [np.all(a == b) for a, b in zip(layer.data[:2], layer.data[-2:])] ) # Unselecting everything and copying and pasting will empty the clipboard # and add no new data layer.selected_data = set() layer._copy_data() layer._paste_data() assert layer._clipboard == {} assert len(layer.data) == shape[0] + 4 def test_value(): """Test getting the value of the data at the current coordinates.""" shape = (10, 4, 2) np.random.seed(0) data = 20 * np.random.random(shape) data[-1, :] = [[0, 0], [0, 10], [10, 0], [10, 10]] layer = Shapes(data) value = layer.get_value((0,) * 2) assert value == (9, None) layer.mode = 'select' layer.selected_data = {9} value = layer.get_value((0,) * 2) assert value == (9, 7) layer = Shapes(data + 5) value = layer.get_value((0,) * 2) assert value == (None, None) @pytest.mark.parametrize( 'position,view_direction,dims_displayed,world,scale,expected', [ ((0, 5, 15, 15), [0, 1, 0, 0], [1, 2, 3], False, (1, 1, 1, 1), 2), ((0, 5, 15, 15), [0, -1, 0, 0], [1, 2, 3], False, (1, 1, 1, 1), 0), ((0, 5, 0, 0), [0, 1, 0, 0], [1, 2, 3], False, (1, 1, 1, 1), None), ((0, 5, 15, 15), [0, 1, 0, 0], [1, 2, 3], True, (1, 1, 2, 1), None), ((0, 5, 15, 15), [0, -1, 0, 0], [1, 2, 3], True, (1, 1, 2, 1), None), ((0, 5, 21, 15), [0, 1, 0, 0], [1, 2, 3], True, (1, 1, 2, 1), 2), ((0, 5, 21, 15), [0, -1, 0, 0], [1, 2, 3], True, (1, 1, 2, 1), 0), ((0, 5, 0, 0), [0, 1, 0, 0], [1, 2, 3], True, (1, 1, 2, 1), None), ], ) def test_value_3d( position, view_direction, dims_displayed, world, scale, expected ): """Test get_value in 3D with and without scale""" data = np.array( [ [ [0, 10, 10, 10], [0, 10, 10, 30], [0, 10, 30, 30], [0, 10, 30, 10], ], [[0, 7, 10, 10], [0, 7, 10, 30], [0, 7, 30, 30], [0, 7, 30, 10]], [[0, 5, 10, 10], [0, 5, 10, 30], [0, 5, 30, 30], [0, 5, 30, 10]], ] ) layer = Shapes(data, scale=scale) layer._slice_dims([0, 0, 0, 0], ndisplay=3) value, _ = layer.get_value( position, view_direction=view_direction, dims_displayed=dims_displayed, world=world, ) if expected is None: assert value is None else: assert value == expected def test_message(): """Test converting values and coords to message.""" shape = (10, 4, 2) np.random.seed(0) data = 20 * np.random.random(shape) layer = Shapes(data) msg = layer.get_status((0,) * 2) assert type(msg) == dict def test_message_3d(): """Test converting values and coords to message in 3D.""" shape = (10, 4, 3) np.random.seed(0) data = 20 * np.random.random(shape) layer = Shapes(data) msg = layer.get_status( (0, 0, 0), view_direction=[1, 0, 0], dims_displayed=[0, 1, 2] ) assert type(msg) == dict def test_thumbnail(): """Test the image thumbnail for square data.""" shape = (10, 4, 2) np.random.seed(0) data = 20 * np.random.random(shape) data[-1, :] = [[0, 0], [0, 20], [20, 0], [20, 20]] layer = Shapes(data) layer._update_thumbnail() assert layer.thumbnail.shape == layer._thumbnail_shape def test_to_masks(): """Test the mask generation.""" shape = (10, 4, 2) np.random.seed(0) data = 20 * np.random.random(shape) layer = Shapes(data) masks = layer.to_masks() assert masks.ndim == 3 assert len(masks) == shape[0] masks = layer.to_masks(mask_shape=[20, 20]) assert masks.shape == (shape[0], 20, 20) def test_to_masks_default_shape(): """Test that labels data generation preserves origin at (0, 0). See https://github.com/napari/napari/issues/3401 """ shape = (10, 4, 2) np.random.seed(0) data = 20 * np.random.random(shape) + [50, 100] layer = Shapes(data) masks = layer.to_masks() assert len(masks) == 10 assert 50 <= masks[0].shape[0] <= 71 assert 100 <= masks[0].shape[1] <= 121 def test_to_labels(): """Test the labels generation.""" shape = (10, 4, 2) np.random.seed(0) data = 20 * np.random.random(shape) layer = Shapes(data) labels = layer.to_labels() assert labels.ndim == 2 assert len(np.unique(labels)) <= 11 labels = layer.to_labels(labels_shape=[20, 20]) assert labels.shape == (20, 20) assert len(np.unique(labels)) <= 11 def test_to_labels_default_shape(): """Test that labels data generation preserves origin at (0, 0). See https://github.com/napari/napari/issues/3401 """ shape = (10, 4, 2) np.random.seed(0) data = 20 * np.random.random(shape) + [50, 100] layer = Shapes(data) labels = layer.to_labels() assert labels.ndim == 2 assert 1 < len(np.unique(labels)) <= 11 assert 50 <= labels.shape[0] <= 71 assert 100 <= labels.shape[1] <= 121 def test_to_labels_3D(): """Test label generation for 3D data""" data = [ [[0, 100, 100], [0, 100, 200], [0, 200, 200], [0, 200, 100]], [[1, 125, 125], [1, 125, 175], [1, 175, 175], [1, 175, 125]], [[2, 100, 100], [2, 100, 200], [2, 200, 200], [2, 200, 100]], ] labels_shape = (3, 300, 300) layer = Shapes(np.array(data), shape_type='polygon') labels = layer.to_labels(labels_shape=labels_shape) assert np.all(labels.shape == labels_shape) assert np.all(np.unique(labels) == [0, 1, 2, 3]) def test_add_single_shape_consistent_properties(): """Test adding a single shape ensures correct number of added properties""" data = [ np.array([[100, 200], [200, 300]]), np.array([[300, 400], [400, 500]]), ] properties = {'index': [1, 2]} layer = Shapes( np.array(data), shape_type='rectangle', properties=properties ) layer.add(np.array([[500, 600], [700, 800]])) assert len(layer.properties['index']) == 3 assert layer.properties['index'][2] == 2 def test_add_shapes_consistent_properties(): """Test adding multiple shapes ensures correct number of added properties""" data = [ np.array([[100, 200], [200, 300]]), np.array([[300, 400], [400, 500]]), ] properties = {'index': [1, 2]} layer = Shapes( np.array(data), shape_type='rectangle', properties=properties ) layer.add( [ np.array([[500, 600], [700, 800]]), np.array([[700, 800], [800, 900]]), ] ) assert len(layer.properties['index']) == 4 assert layer.properties['index'][2] == 2 assert layer.properties['index'][3] == 2 def test_world_data_extent(): """Test extent after applying transforms.""" data = [(7, -5, 0), (-2, 0, 15), (4, 30, 12)] layer = Shapes([data, np.add(data, [2, -3, 0])], shape_type='polygon') min_val = (-2, -8, 0) max_val = (9, 30, 15) extent = np.array((min_val, max_val)) check_layer_world_data_extent(layer, extent, (3, 1, 1), (10, 20, 5), False) def test_set_data_3d(): """Test to reproduce https://github.com/napari/napari/issues/4527""" lines = [ np.array([[0, 0, 0], [500, 0, 0]]), np.array([[0, 0, 0], [0, 300, 0]]), np.array([[0, 0, 0], [0, 0, 200]]), ] shapes = Shapes(lines, shape_type='line') shapes._slice_dims(ndisplay=3) shapes.data = lines def test_editing_4d(): viewer = ViewerModel() viewer.add_shapes( ndim=4, name='rois', edge_color='red', face_color=np.array([0, 0, 0, 0]), edge_width=1, ) viewer.layers['rois'].add( [ np.array( [ [1, 4, 1.7, 4.9], [1, 4, 1.7, 13.1], [1, 4, 13.5, 13.1], [1, 4, 13.5, 4.9], ] ) ] ) # check if set data doe not end with an exception # https://github.com/napari/napari/issues/5379 viewer.layers['rois'].data = [ np.around(x) for x in viewer.layers['rois'].data ] napari-0.5.0a1/napari/layers/shapes/_tests/test_shapes_key_bindings.py000066400000000000000000000065741437041365600262130ustar00rootroot00000000000000import numpy as np from napari.layers import Shapes from napari.layers.shapes import _shapes_key_bindings as key_bindings def test_lock_aspect_ratio(): # Test a single four corner rectangle layer = Shapes(20 * np.random.random((1, 4, 2))) layer._moving_coordinates = (0, 0, 0) layer._is_moving = True # need to go through the generator _ = list(key_bindings.hold_to_lock_aspect_ratio(layer)) def test_lock_aspect_ratio_selected_box(): # Test a single four corner rectangle layer = Shapes(20 * np.random.random((1, 4, 2))) # select a shape layer._selected_box = layer.interaction_box(0) layer._moving_coordinates = (0, 0, 0) layer._is_moving = True # need to go through the generator _ = list(key_bindings.hold_to_lock_aspect_ratio(layer)) def test_lock_aspect_ratio_selected_box_zeros(): # Test a single four corner rectangle that has zero size layer = Shapes(20 * np.zeros((1, 4, 2))) # select a shape layer._selected_box = layer.interaction_box(0) layer._moving_coordinates = (0, 0, 0) layer._is_moving = True # need to go through the generator _ = list(key_bindings.hold_to_lock_aspect_ratio(layer)) def test_activate_modes(): # Test a single four corner rectangle layer = Shapes(20 * np.random.random((1, 4, 2))) # need to go through the generator key_bindings.activate_add_rectangle_mode(layer) assert layer.mode == 'add_rectangle' key_bindings.activate_add_ellipse_mode(layer) assert layer.mode == 'add_ellipse' key_bindings.activate_add_line_mode(layer) assert layer.mode == 'add_line' key_bindings.activate_add_path_mode(layer) assert layer.mode == 'add_path' key_bindings.activate_add_polygon_mode(layer) assert layer.mode == 'add_polygon' key_bindings.activate_direct_mode(layer) assert layer.mode == 'direct' key_bindings.activate_select_mode(layer) assert layer.mode == 'select' key_bindings.activate_shapes_pan_zoom_mode(layer) assert layer.mode == 'pan_zoom' key_bindings.activate_vertex_insert_mode(layer) assert layer.mode == 'vertex_insert' key_bindings.activate_vertex_remove_mode(layer) assert layer.mode == 'vertex_remove' def test_copy_paste(): # Test on three four corner rectangle layer = Shapes(20 * np.random.random((3, 4, 2))) layer.mode = 'direct' assert len(layer.data) == 3 assert layer._clipboard == {} layer.selected_data = {0, 1} key_bindings.copy_selected_shapes(layer) assert len(layer.data) == 3 assert len(layer._clipboard) > 0 key_bindings.paste_shape(layer) assert len(layer.data) == 5 assert len(layer._clipboard) > 0 def test_select_all(): # Test on three four corner rectangle layer = Shapes(20 * np.random.random((3, 4, 2))) layer.mode = 'direct' assert len(layer.data) == 3 assert len(layer.selected_data) == 0 key_bindings.select_all_shapes(layer) assert len(layer.selected_data) == 3 def test_delete(): # Test on three four corner rectangle layer = Shapes(20 * np.random.random((3, 4, 2))) layer.mode = 'direct' assert len(layer.data) == 3 layer.selected_data = {0, 1} key_bindings.delete_selected_shapes(layer) assert len(layer.data) == 1 def test_finish(): # Test on three four corner rectangle layer = Shapes(20 * np.random.random((3, 4, 2))) key_bindings.finish_drawing_shape(layer) napari-0.5.0a1/napari/layers/shapes/_tests/test_shapes_mouse_bindings.py000066400000000000000000000631101437041365600265400ustar00rootroot00000000000000import collections import numpy as np import pytest from napari.layers import Shapes from napari.layers.shapes.shapes import Mode from napari.utils._proxies import ReadOnlyWrapper from napari.utils.interactions import ( mouse_double_click_callbacks, mouse_move_callbacks, mouse_press_callbacks, mouse_release_callbacks, ) @pytest.fixture def Event(): """Create a subclass for simulating vispy mouse events. Returns ------- Event : Type A new tuple subclass named Event that can be used to create a NamedTuple object with fields "type", "is_dragging", and "modifiers". """ return collections.namedtuple( 'Event', field_names=['type', 'is_dragging', 'modifiers', 'position'] ) @pytest.fixture def create_known_shapes_layer(): """Create shapes layer with known coordinates Returns ------- layer : napari.layers.Shapes Shapes layer. n_shapes : int Number of shapes in the shapes layer known_non_shape : list Data coordinates that are known to contain no shapes. Useful during testing when needing to guarantee no shape is clicked on. """ data = [[[1, 3], [8, 4]], [[10, 10], [15, 4]]] known_non_shape = [20, 30] n_shapes = len(data) layer = Shapes(data) assert layer.ndim == 2 assert len(layer.data) == n_shapes assert len(layer.selected_data) == 0 return layer, n_shapes, known_non_shape def test_not_adding_or_selecting_shape(create_known_shapes_layer, Event): """Don't add or select a shape by clicking on one in pan_zoom mode.""" layer, n_shapes, _ = create_known_shapes_layer layer.mode = 'pan_zoom' # Simulate click event = ReadOnlyWrapper( Event( type='mouse_press', is_dragging=False, modifiers=[], position=(0, 0), ) ) mouse_press_callbacks(layer, event) # Simulate release event = ReadOnlyWrapper( Event( type='mouse_release', is_dragging=False, modifiers=[], position=(0, 0), ) ) mouse_release_callbacks(layer, event) # Check no new shape added and non selected assert len(layer.data) == n_shapes assert len(layer.selected_data) == 0 @pytest.mark.parametrize('shape_type', ['rectangle', 'ellipse', 'line']) def test_add_simple_shape(shape_type, create_known_shapes_layer, Event): """Add simple shape by clicking in add mode.""" layer, n_shapes, known_non_shape = create_known_shapes_layer # Add shape at location where non exists layer.mode = 'add_' + shape_type # Simulate click event = ReadOnlyWrapper( Event( type='mouse_press', is_dragging=False, modifiers=[], position=known_non_shape, ) ) mouse_press_callbacks(layer, event) known_non_shape_end = [40, 60] # Simulate drag end event = ReadOnlyWrapper( Event( type='mouse_move', is_dragging=True, modifiers=[], position=known_non_shape_end, ) ) mouse_move_callbacks(layer, event) # Simulate release event = ReadOnlyWrapper( Event( type='mouse_release', is_dragging=False, modifiers=[], position=known_non_shape_end, ) ) mouse_release_callbacks(layer, event) # Check new shape added at coordinates assert len(layer.data) == n_shapes + 1 np.testing.assert_allclose(layer.data[-1][0], known_non_shape) new_shape_max = np.max(layer.data[-1], axis=0) np.testing.assert_allclose(new_shape_max, known_non_shape_end) assert layer.shape_type[-1] == shape_type @pytest.mark.parametrize('shape_type', ['path', 'polygon']) def test_add_complex_shape(shape_type, create_known_shapes_layer, Event): """Add simple shape by clicking in add mode.""" layer, n_shapes, known_non_shape = create_known_shapes_layer desired_shape = [[20, 30], [10, 50], [60, 40], [80, 20]] # Add shape at location where non exists layer.mode = 'add_' + shape_type for coord in desired_shape: # Simulate move, click, and release event = ReadOnlyWrapper( Event( type='mouse_move', is_dragging=False, modifiers=[], position=coord, ) ) mouse_move_callbacks(layer, event) event = ReadOnlyWrapper( Event( type='mouse_press', is_dragging=False, modifiers=[], position=coord, ) ) mouse_press_callbacks(layer, event) event = ReadOnlyWrapper( Event( type='mouse_release', is_dragging=False, modifiers=[], position=coord, ) ) mouse_release_callbacks(layer, event) # finish drawing end_click = ReadOnlyWrapper( Event( type='mouse_double_click', is_dragging=False, modifiers=[], position=coord, ) ) assert layer.mouse_double_click_callbacks mouse_double_click_callbacks(layer, end_click) # Check new shape added at coordinates assert len(layer.data) == n_shapes + 1 assert layer.data[-1].shape, desired_shape.shape np.testing.assert_allclose(layer.data[-1], desired_shape) assert layer.shape_type[-1] == shape_type def test_vertex_insert(create_known_shapes_layer, Event): """Add vertex to shape.""" layer, n_shapes, known_non_shape = create_known_shapes_layer n_coord = len(layer.data[0]) layer.mode = 'vertex_insert' layer.selected_data = {0} # Simulate click event = ReadOnlyWrapper( Event( type='mouse_press', is_dragging=False, modifiers=[], position=known_non_shape, ) ) mouse_press_callbacks(layer, event) # Simulate drag end event = ReadOnlyWrapper( Event( type='mouse_move', is_dragging=True, modifiers=[], position=known_non_shape, ) ) mouse_move_callbacks(layer, event) # Check new shape added at coordinates assert len(layer.data) == n_shapes assert len(layer.data[0]) == n_coord + 1 np.testing.assert_allclose( np.min(abs(layer.data[0] - known_non_shape), axis=0), [0, 0] ) def test_vertex_remove(create_known_shapes_layer, Event): """Remove vertex from shape.""" layer, n_shapes, known_non_shape = create_known_shapes_layer n_coord = len(layer.data[0]) layer.mode = 'vertex_remove' layer.selected_data = {0} position = tuple(layer.data[0][0]) # Simulate click event = ReadOnlyWrapper( Event( type='mouse_press', is_dragging=False, modifiers=[], position=position, ) ) mouse_press_callbacks(layer, event) # Simulate drag end event = ReadOnlyWrapper( Event( type='mouse_move', is_dragging=True, modifiers=[], position=position, ) ) mouse_move_callbacks(layer, event) # Check new shape added at coordinates assert len(layer.data) == n_shapes assert len(layer.data[0]) == n_coord - 1 @pytest.mark.parametrize('mode', ['select', 'direct']) def test_select_shape(mode, create_known_shapes_layer, Event): """Select a shape by clicking on one in select mode.""" layer, n_shapes, _ = create_known_shapes_layer layer.mode = mode position = tuple(layer.data[0][0]) # Simulate click event = ReadOnlyWrapper( Event( type='mouse_press', is_dragging=False, modifiers=[], position=position, ) ) mouse_press_callbacks(layer, event) # Simulate release event = ReadOnlyWrapper( Event( type='mouse_release', is_dragging=False, modifiers=[], position=position, ) ) mouse_release_callbacks(layer, event) # Check clicked shape selected assert len(layer.selected_data) == 1 assert layer.selected_data == {0} def test_drag_shape(create_known_shapes_layer, Event): """Select and drag vertex.""" layer, n_shapes, _ = create_known_shapes_layer layer.mode = 'select' # Zoom in so as to not select any vertices layer.scale_factor = 0.01 orig_data = layer.data[0].copy() assert len(layer.selected_data) == 0 position = tuple(np.mean(layer.data[0], axis=0)) # Check shape under cursor value = layer.get_value(position, world=True) assert value == (0, None) # Simulate click event = ReadOnlyWrapper( Event( type='mouse_press', is_dragging=False, modifiers=[], position=position, ) ) mouse_press_callbacks(layer, event) # Simulate release event = ReadOnlyWrapper( Event( type='mouse_release', is_dragging=False, modifiers=[], position=position, ) ) mouse_release_callbacks(layer, event) assert len(layer.selected_data) == 1 assert layer.selected_data == {0} # Check shape but not vertex under cursor value = layer.get_value(event.position, world=True) assert value == (0, None) # Simulate click event = ReadOnlyWrapper( Event( type='mouse_press', is_dragging=True, modifiers=[], position=position, ) ) mouse_press_callbacks(layer, event) # start drag event event = ReadOnlyWrapper( Event( type='mouse_move', is_dragging=True, modifiers=[], position=position, ) ) mouse_move_callbacks(layer, event) position = tuple(np.add(position, [10, 5])) # Simulate move, click, and release event = ReadOnlyWrapper( Event( type='mouse_move', is_dragging=True, modifiers=[], position=position, ) ) mouse_move_callbacks(layer, event) # Simulate release event = ReadOnlyWrapper( Event( type='mouse_release', is_dragging=True, modifiers=[], position=position, ) ) mouse_release_callbacks(layer, event) # Check clicked shape selected assert len(layer.selected_data) == 1 assert layer.selected_data == {0} np.testing.assert_allclose(layer.data[0], orig_data + [10, 5]) def test_rotate_shape(create_known_shapes_layer, Event): """Select and drag handle to rotate shape.""" layer, n_shapes, _ = create_known_shapes_layer layer.mode = 'select' layer.selected_data = {1} # get the position of the rotation handle position = tuple(layer._selected_box[9]) # get the vertexes original_data = layer.data[1].copy() # Simulate click event = ReadOnlyWrapper( Event( type='mouse_press', is_dragging=True, modifiers=[], position=position, ) ) mouse_press_callbacks(layer, event) # start drag event event = ReadOnlyWrapper( Event( type='mouse_move', is_dragging=True, modifiers=[], position=position, ) ) mouse_move_callbacks(layer, event) # drag in the handle to bottom midpoint vertex to rotate 180 degrees position = tuple(tuple(layer._selected_box[3])) # Simulate move, click, and release event = ReadOnlyWrapper( Event( type='mouse_move', is_dragging=True, modifiers=[], position=position, ) ) mouse_move_callbacks(layer, event) # Simulate release event = ReadOnlyWrapper( Event( type='mouse_release', is_dragging=True, modifiers=[], position=position, ) ) mouse_release_callbacks(layer, event) # Check shape was rotated np.testing.assert_allclose(layer.data[1][2], original_data[0]) def test_drag_vertex(create_known_shapes_layer, Event): """Select and drag vertex.""" layer, n_shapes, _ = create_known_shapes_layer layer.mode = 'direct' layer.selected_data = {0} position = tuple(layer.data[0][0]) # Simulate click event = ReadOnlyWrapper( Event( type='mouse_press', is_dragging=False, modifiers=[], position=position, ) ) mouse_press_callbacks(layer, event) position = [0, 0] # Simulate move, click, and release event = ReadOnlyWrapper( Event( type='mouse_move', is_dragging=True, modifiers=[], position=position, ) ) mouse_move_callbacks(layer, event) # Simulate release event = ReadOnlyWrapper( Event( type='mouse_release', is_dragging=True, modifiers=[], position=position, ) ) mouse_release_callbacks(layer, event) # Check clicked shape selected assert len(layer.selected_data) == 1 assert layer.selected_data == {0} np.testing.assert_allclose(layer.data[0][-1], [0, 0]) @pytest.mark.parametrize( 'mode', [ 'select', 'direct', 'add_rectangle', 'add_ellipse', 'add_line', 'add_polygon', 'add_path', 'vertex_insert', 'vertex_remove', ], ) def test_after_in_add_mode_shape(mode, create_known_shapes_layer, Event): """Don't add or select a shape by clicking on one in pan_zoom mode.""" layer, n_shapes, _ = create_known_shapes_layer layer.mode = mode layer.mode = 'pan_zoom' position = tuple(layer.data[0][0]) # Simulate click event = ReadOnlyWrapper( Event( type='mouse_press', is_dragging=False, modifiers=[], position=position, ) ) mouse_press_callbacks(layer, event) # Simulate release event = ReadOnlyWrapper( Event( type='mouse_release', is_dragging=False, modifiers=[], position=position, ) ) mouse_release_callbacks(layer, event) # Check no new shape added and non selected assert len(layer.data) == n_shapes assert len(layer.selected_data) == 0 @pytest.mark.parametrize('mode', ['select', 'direct']) def test_unselect_select_shape(mode, create_known_shapes_layer, Event): """Select a shape by clicking on one in select mode.""" layer, n_shapes, _ = create_known_shapes_layer layer.mode = mode position = tuple(layer.data[0][0]) layer.selected_data = {1} # Simulate click event = ReadOnlyWrapper( Event( type='mouse_press', is_dragging=False, modifiers=[], position=position, ) ) mouse_press_callbacks(layer, event) # Simulate release event = ReadOnlyWrapper( Event( type='mouse_release', is_dragging=False, modifiers=[], position=position, ) ) mouse_release_callbacks(layer, event) # Check clicked shape selected assert len(layer.selected_data) == 1 assert layer.selected_data == {0} @pytest.mark.parametrize('mode', ['select', 'direct']) def test_not_selecting_shape(mode, create_known_shapes_layer, Event): """Don't select a shape by not clicking on one in select mode.""" layer, n_shapes, known_non_shape = create_known_shapes_layer layer.mode = mode # Simulate click event = ReadOnlyWrapper( Event( type='mouse_press', is_dragging=False, modifiers=[], position=known_non_shape, ) ) mouse_press_callbacks(layer, event) # Simulate release event = ReadOnlyWrapper( Event( type='mouse_release', is_dragging=False, modifiers=[], position=known_non_shape, ) ) mouse_release_callbacks(layer, event) # Check clicked shape selected assert len(layer.selected_data) == 0 @pytest.mark.parametrize('mode', ['select', 'direct']) def test_unselecting_shapes(mode, create_known_shapes_layer, Event): """Unselect shapes by not clicking on one in select mode.""" layer, n_shapes, known_non_shape = create_known_shapes_layer layer.mode = mode layer.selected_data = {0, 1} assert len(layer.selected_data) == 2 # Simulate click event = ReadOnlyWrapper( Event( type='mouse_press', is_dragging=False, modifiers=[], position=known_non_shape, ) ) mouse_press_callbacks(layer, event) # Simulate release event = ReadOnlyWrapper( Event( type='mouse_release', is_dragging=False, modifiers=[], position=known_non_shape, ) ) mouse_release_callbacks(layer, event) # Check clicked shape selected assert len(layer.selected_data) == 0 @pytest.mark.parametrize('mode', ['select', 'direct']) def test_selecting_shapes_with_drag(mode, create_known_shapes_layer, Event): """Select all shapes when drag box includes all of them.""" layer, n_shapes, known_non_shape = create_known_shapes_layer layer.mode = mode # Simulate click event = ReadOnlyWrapper( Event( type='mouse_press', is_dragging=False, modifiers=[], position=known_non_shape, ) ) mouse_press_callbacks(layer, event) # Simulate drag start event = ReadOnlyWrapper( Event( type='mouse_move', is_dragging=True, modifiers=[], position=known_non_shape, ) ) mouse_move_callbacks(layer, event) # Simulate drag end event = ReadOnlyWrapper( Event( type='mouse_move', is_dragging=True, modifiers=[], position=(0, 0) ) ) mouse_move_callbacks(layer, event) # Simulate release event = ReadOnlyWrapper( Event( type='mouse_release', is_dragging=True, modifiers=[], position=(0, 0), ) ) mouse_release_callbacks(layer, event) # Check all shapes selected as drag box contains them assert len(layer.selected_data) == n_shapes @pytest.mark.parametrize('mode', ['select', 'direct']) def test_selecting_no_shapes_with_drag(mode, create_known_shapes_layer, Event): """Select all shapes when drag box includes all of them.""" layer, n_shapes, known_non_shape = create_known_shapes_layer layer.mode = mode # Simulate click event = ReadOnlyWrapper( Event( type='mouse_press', is_dragging=False, modifiers=[], position=known_non_shape, ) ) mouse_press_callbacks(layer, event) # Simulate drag start event = ReadOnlyWrapper( Event( type='mouse_move', is_dragging=True, modifiers=[], position=known_non_shape, ) ) mouse_move_callbacks(layer, event) # Simulate drag end event = ReadOnlyWrapper( Event( type='mouse_move', is_dragging=True, modifiers=[], position=(50, 60), ) ) mouse_move_callbacks(layer, event) # Simulate release event = ReadOnlyWrapper( Event( type='mouse_release', is_dragging=True, modifiers=[], position=(50, 60), ) ) mouse_release_callbacks(layer, event) # Check no shapes selected as drag box doesn't contain them assert len(layer.selected_data) == 0 @pytest.mark.parametrize( 'attr', ('_move_modes', '_drag_modes', '_cursor_modes') ) def test_all_modes_covered(attr): """ Test that all dictionaries modes have all the keys, this simplify the handling logic As we do not need to test whether a key is in a dict or not. """ mode_dict = getattr(Shapes, attr) assert {k.value for k in mode_dict.keys()} == set(Mode.keys()) @pytest.mark.parametrize( 'pre_selection,on_point,modifier', [ (set(), True, []), ({1}, True, []), ], ) def test_drag_start_selection( create_known_shapes_layer, Event, pre_selection, on_point, modifier ): """Check layer drag start and drag box behave as expected.""" layer, n_points, known_non_point = create_known_shapes_layer layer.mode = 'select' layer.selected_data = pre_selection if on_point: initial_position = tuple(layer.data[0].mean(axis=0)) else: initial_position = tuple(known_non_point) zero_pos = [0, 0] value = layer.get_value(initial_position, world=True) assert value[0] == 0 assert layer._drag_start is None assert layer._drag_box is None assert layer.selected_data == pre_selection # Simulate click event = ReadOnlyWrapper( Event( type='mouse_press', position=initial_position, modifiers=modifier, is_dragging=True, ) ) mouse_press_callbacks(layer, event) if modifier: if not on_point: assert layer.selected_data == pre_selection elif 0 in pre_selection: assert layer.selected_data == pre_selection - {0} else: assert layer.selected_data == pre_selection | {0} elif not on_point: assert layer.selected_data == set() elif 0 in pre_selection: assert layer.selected_data == pre_selection else: assert layer.selected_data == {0} if len(layer.selected_data) > 0: center_list = [] for idx in layer.selected_data: center_list.append(layer.data[idx].mean(axis=0)) center = np.mean(center_list, axis=0) else: center = [0, 0] if not modifier: start_position = [ initial_position[0] - center[0], initial_position[1] - center[1], ] else: start_position = initial_position is_point_move = len(layer.selected_data) > 0 and on_point and not modifier np.testing.assert_array_equal(layer._drag_start, start_position) # Simulate drag start on a different position offset_position = [initial_position[0] + 20, initial_position[1] + 20] event = ReadOnlyWrapper( Event( type='mouse_move', is_dragging=True, position=offset_position, modifiers=modifier, ) ) mouse_move_callbacks(layer, event) # Initial mouse_move is already considered a move and not a press. # Therefore, the _drag_start value should be identical and the data or drag_box should reflect # the mouse position. np.testing.assert_array_equal(layer._drag_start, start_position) if is_point_move: if 0 in layer.selected_data: np.testing.assert_array_equal( layer.data[0].mean(axis=0), [offset_position[0], offset_position[1]], ) else: raise AssertionError("Unreachable code") # pragma: no cover else: np.testing.assert_array_equal( layer._drag_box, [initial_position, offset_position] ) # Simulate drag start on new different position offset_position = zero_pos event = ReadOnlyWrapper( Event( type='mouse_move', is_dragging=True, position=offset_position, modifiers=modifier, ) ) mouse_move_callbacks(layer, event) # Initial mouse_move is already considered a move and not a press. # Therefore, the _drag_start value should be identical and the data or drag_box should reflect # the mouse position. np.testing.assert_array_equal(layer._drag_start, start_position) if is_point_move: if 0 in layer.selected_data: np.testing.assert_array_equal( layer.data[0].mean(axis=0), [offset_position[0], offset_position[1]], ) else: raise AssertionError("Unreachable code") # pragma: no cover else: np.testing.assert_array_equal( layer._drag_box, [initial_position, offset_position] ) # Simulate release event = ReadOnlyWrapper( Event( type='mouse_release', is_dragging=True, modifiers=modifier, position=offset_position, ) ) mouse_release_callbacks(layer, event) if on_point and 0 in pre_selection and modifier: assert layer.selected_data == pre_selection - {0} elif on_point and 0 in pre_selection and not modifier: assert layer.selected_data == pre_selection elif on_point and 0 not in pre_selection and modifier: assert layer.selected_data == pre_selection | {0} elif on_point and 0 not in pre_selection and not modifier: assert layer.selected_data == {0} elif 0 in pre_selection and modifier: assert 0 not in layer.selected_data assert layer.selected_data == (set(range(n_points)) - pre_selection) elif 0 in pre_selection and not modifier: assert 0 in layer.selected_data assert layer.selected_data == set(range(n_points)) elif 0 not in pre_selection and modifier: assert 0 in layer.selected_data assert layer.selected_data == (set(range(n_points)) - pre_selection) elif 0 not in pre_selection and not modifier: assert 0 in layer.selected_data assert layer.selected_data == set(range(n_points)) else: assert False, 'Unreachable code' # pragma: no cover assert layer._drag_box is None assert layer._drag_start is None napari-0.5.0a1/napari/layers/shapes/_tests/test_shapes_utils.py000066400000000000000000000225371437041365600247030ustar00rootroot00000000000000import numpy as np import pytest from numpy import array from napari.layers.shapes._shapes_utils import ( generate_2D_edge_meshes, get_default_shape_type, number_of_shapes, ) W_DATA = [[0, 3], [1, 0], [2, 3], [5, 0], [2.5, 5]] def _regen_testcases(): """ In case the below test cases need to be update here is a simple function you can run to regenerate the `cases` variable below. """ exec( """ from napari.layers.shapes._tests.test_shapes_utils import ( generate_2D_edge_meshes, W_DATA, ) mesh_cases = [ (W_DATA, False, 3, False), (W_DATA, True, 3, False), (W_DATA, False, 3, True), (W_DATA, True, 3, True), ] s = '[' for args in mesh_cases: cot = generate_2D_edge_meshes(*args) s = s + str(['W_DATA', *args[1:], cot]) + ',' s += ']' s = s.replace("'W_DATA'", 'W_DATA') print(s) """ ) cases = [ [ W_DATA, False, 3, False, ( array( [ [0.0, 3.0], [0.0, 3.0], [1.0, 0.0], [1.0, 0.0], [2.0, 3.0], [2.0, 3.0], [5.0, 0.0], [5.0, 0.0], [2.5, 5.0], [2.5, 5.0], [1.0, 0.0], [5.0, 0.0], ] ), array( [ [0.47434165, 0.15811388], [-0.47434165, -0.15811388], [-0.0, 1.58113883], [-0.47434165, -0.15811388], [-0.21850801, 0.92561479], [0.21850801, -0.92561479], [-1.82514077, 2.53224755], [-0.35355339, -0.35355339], [-0.4472136, -0.2236068], [0.4472136, 0.2236068], [0.47434165, -0.15811388], [0.4472136, 0.2236068], ] ), array( [ [0, 1, 3], [0, 3, 2], [2, 10, 5], [2, 5, 4], [4, 5, 7], [4, 7, 6], [6, 11, 9], [6, 9, 8], [10, 2, 3], [11, 6, 7], ] ), ), ], [ W_DATA, True, 3, False, ( array( [ [0.0, 3.0], [0.0, 3.0], [1.0, 0.0], [1.0, 0.0], [2.0, 3.0], [2.0, 3.0], [5.0, 0.0], [5.0, 0.0], [2.5, 5.0], [2.5, 5.0], [0.0, 3.0], [0.0, 3.0], [1.0, 0.0], [5.0, 0.0], ] ), array( [ [0.58459244, -0.17263848], [-0.58459244, 0.17263848], [-0.0, 1.58113883], [-0.47434165, -0.15811388], [-0.21850801, 0.92561479], [0.21850801, -0.92561479], [-1.82514077, 2.53224755], [-0.35355339, -0.35355339], [-0.17061484, -0.7768043], [0.17061484, 0.7768043], [0.58459244, -0.17263848], [-0.58459244, 0.17263848], [0.47434165, -0.15811388], [0.4472136, 0.2236068], ] ), array( [ [0, 1, 3], [0, 3, 2], [2, 12, 5], [2, 5, 4], [4, 5, 7], [4, 7, 6], [6, 13, 9], [6, 9, 8], [8, 9, 11], [8, 11, 10], [12, 2, 3], [13, 6, 7], ] ), ), ], [ W_DATA, False, 3, True, ( array( [ [0.0, 3.0], [0.0, 3.0], [1.0, 0.0], [1.0, 0.0], [2.0, 3.0], [2.0, 3.0], [5.0, 0.0], [5.0, 0.0], [2.5, 5.0], [2.5, 5.0], [0.0, 3.0], [1.0, 0.0], [2.0, 3.0], [5.0, 0.0], ] ), array( [ [0.47434165, 0.15811388], [-0.47434165, -0.15811388], [-0.0, 1.58113883], [-0.47434165, -0.15811388], [-0.47434165, 0.15811388], [0.21850801, -0.92561479], [-1.82514077, 2.53224755], [-0.35355339, -0.35355339], [-0.4472136, -0.2236068], [0.4472136, 0.2236068], [0.47434165, 0.15811388], [0.47434165, -0.15811388], [0.35355339, 0.35355339], [0.4472136, 0.2236068], ] ), array( [ [10, 1, 3], [10, 3, 2], [2, 11, 5], [2, 5, 4], [12, 5, 7], [12, 7, 6], [6, 13, 9], [6, 9, 8], [0, 1, 10], [11, 2, 3], [4, 5, 12], [13, 6, 7], ] ), ), ], [ W_DATA, True, 3, True, ( array( [ [0.0, 3.0], [0.0, 3.0], [1.0, 0.0], [1.0, 0.0], [2.0, 3.0], [2.0, 3.0], [5.0, 0.0], [5.0, 0.0], [2.5, 5.0], [2.5, 5.0], [0.0, 3.0], [0.0, 3.0], [0.0, 3.0], [1.0, 0.0], [2.0, 3.0], [5.0, 0.0], [2.5, 5.0], ] ), array( [ [0.58459244, -0.17263848], [-0.31234752, 0.3904344], [-0.0, 1.58113883], [-0.47434165, -0.15811388], [-0.47434165, 0.15811388], [0.21850801, -0.92561479], [-1.82514077, 2.53224755], [-0.35355339, -0.35355339], [-0.17061484, -0.7768043], [0.4472136, 0.2236068], [0.58459244, -0.17263848], [-0.31234752, 0.3904344], [-0.47434165, -0.15811388], [0.47434165, -0.15811388], [0.35355339, 0.35355339], [0.4472136, 0.2236068], [-0.31234752, 0.3904344], ] ), array( [ [0, 12, 3], [0, 3, 2], [2, 13, 5], [2, 5, 4], [14, 5, 7], [14, 7, 6], [6, 15, 9], [6, 9, 8], [8, 16, 11], [8, 11, 10], [12, 0, 1], [13, 2, 3], [4, 5, 14], [15, 6, 7], [16, 8, 9], ] ), ), ], ] @pytest.mark.parametrize( 'path, closed, limit, bevel, expected', cases, ) def test_generate_2D_edge_meshes( path, closed, limit, bevel, expected, ): pass c, o, t = generate_2D_edge_meshes(path, closed, limit, bevel) expected_center, expected_offsets, expected_triangles = expected assert np.allclose(c, expected_center) assert np.allclose(o, expected_offsets) assert (t == expected_triangles).all() def test_no_shapes(): """Test no shapes.""" assert number_of_shapes([]) == 0 assert number_of_shapes(np.empty((0, 4, 2))) == 0 def test_one_shape(): """Test one shape.""" assert number_of_shapes(np.random.random((4, 2))) == 1 def test_many_shapes(): """Test many shapes.""" assert number_of_shapes(np.random.random((8, 4, 2))) == 8 def test_get_default_shape_type(): """Test getting default shape type""" shape_type = ['polygon', 'polygon'] assert get_default_shape_type(shape_type) == 'polygon' shape_type = [] assert get_default_shape_type(shape_type) == 'polygon' shape_type = ['ellipse', 'rectangle'] assert get_default_shape_type(shape_type) == 'polygon' shape_type = ['rectangle', 'rectangle'] assert get_default_shape_type(shape_type) == 'rectangle' shape_type = ['ellipse', 'ellipse'] assert get_default_shape_type(shape_type) == 'ellipse' shape_type = ['polygon'] assert get_default_shape_type(shape_type) == 'polygon' napari-0.5.0a1/napari/layers/shapes/shapes.py000066400000000000000000003402761437041365600211260ustar00rootroot00000000000000import warnings from contextlib import contextmanager from copy import copy, deepcopy from itertools import cycle from typing import Dict, List, Tuple, Union import numpy as np import pandas as pd from vispy.color import get_color_names from napari.layers.base import Layer, no_op from napari.layers.base._base_mouse_bindings import ( highlight_box_handles, transform_with_box, ) from napari.layers.shapes._shape_list import ShapeList from napari.layers.shapes._shapes_constants import ( Box, ColorMode, Mode, ShapeType, shape_classes, ) from napari.layers.shapes._shapes_mouse_bindings import ( add_ellipse, add_line, add_path_polygon, add_path_polygon_creating, add_rectangle, finish_drawing_shape, highlight, select, vertex_insert, vertex_remove, ) from napari.layers.shapes._shapes_utils import ( create_box, extract_shape_type, get_default_shape_type, get_shape_ndim, number_of_shapes, validate_num_vertices, ) from napari.layers.utils.color_manager_utils import ( guess_continuous, map_property, ) from napari.layers.utils.color_transformations import ( normalize_and_broadcast_colors, transform_color_cycle, transform_color_with_defaults, ) from napari.layers.utils.interactivity_utils import ( nd_line_segment_to_displayed_data_ray, ) from napari.layers.utils.layer_utils import _FeatureTable, _unique_element from napari.layers.utils.text_manager import TextManager from napari.utils.colormaps import Colormap, ValidColormapArg, ensure_colormap from napari.utils.colormaps.colormap_utils import ColorType from napari.utils.colormaps.standardize_color import ( hex_to_name, rgb_to_hex, transform_color, ) from napari.utils.events import Event from napari.utils.events.custom_types import Array from napari.utils.misc import ensure_iterable from napari.utils.translations import trans DEFAULT_COLOR_CYCLE = np.array([[1, 0, 1, 1], [0, 1, 0, 1]]) class Shapes(Layer): """Shapes layer. Parameters ---------- data : list or array List of shape data, where each element is an (N, D) array of the N vertices of a shape in D dimensions. Can be an 3-dimensional array if each shape has the same number of vertices. ndim : int Number of dimensions for shapes. When data is not None, ndim must be D. An empty shapes layer can be instantiated with arbitrary ndim. features : dict[str, array-like] or Dataframe-like Features table where each row corresponds to a shape and each column is a feature. properties : dict {str: array (N,)}, DataFrame Properties for each shape. Each property should be an array of length N, where N is the number of shapes. property_choices : dict {str: array (N,)} possible values for each property. text : str, dict Text to be displayed with the shapes. If text is set to a key in properties, the value of that property will be displayed. Multiple properties can be composed using f-string-like syntax (e.g., '{property_1}, {float_property:.2f}). A dictionary can be provided with keyword arguments to set the text values and display properties. See TextManager.__init__() for the valid keyword arguments. For example usage, see /napari/examples/add_shapes_with_text.py. shape_type : string or list String of shape shape_type, must be one of "{'line', 'rectangle', 'ellipse', 'path', 'polygon'}". If a list is supplied it must be the same length as the length of `data` and each element will be applied to each shape otherwise the same value will be used for all shapes. edge_width : float or list Thickness of lines and edges. If a list is supplied it must be the same length as the length of `data` and each element will be applied to each shape otherwise the same value will be used for all shapes. edge_color : str, array-like If string can be any color name recognized by vispy or hex value if starting with `#`. If array-like must be 1-dimensional array with 3 or 4 elements. If a list is supplied it must be the same length as the length of `data` and each element will be applied to each shape otherwise the same value will be used for all shapes. edge_color_cycle : np.ndarray, list Cycle of colors (provided as string name, RGB, or RGBA) to map to edge_color if a categorical attribute is used color the vectors. edge_colormap : str, napari.utils.Colormap Colormap to set edge_color if a continuous attribute is used to set face_color. edge_contrast_limits : None, (float, float) clims for mapping the property to a color map. These are the min and max value of the specified property that are mapped to 0 and 1, respectively. The default value is None. If set the none, the clims will be set to (property.min(), property.max()) face_color : str, array-like If string can be any color name recognized by vispy or hex value if starting with `#`. If array-like must be 1-dimensional array with 3 or 4 elements. If a list is supplied it must be the same length as the length of `data` and each element will be applied to each shape otherwise the same value will be used for all shapes. face_color_cycle : np.ndarray, list Cycle of colors (provided as string name, RGB, or RGBA) to map to face_color if a categorical attribute is used color the vectors. face_colormap : str, napari.utils.Colormap Colormap to set face_color if a continuous attribute is used to set face_color. face_contrast_limits : None, (float, float) clims for mapping the property to a color map. These are the min and max value of the specified property that are mapped to 0 and 1, respectively. The default value is None. If set the none, the clims will be set to (property.min(), property.max()) z_index : int or list Specifier of z order priority. Shapes with higher z order are displayed ontop of others. If a list is supplied it must be the same length as the length of `data` and each element will be applied to each shape otherwise the same value will be used for all shapes. name : str Name of the layer. metadata : dict Layer metadata. scale : tuple of float Scale factors for the layer. translate : tuple of float Translation values for the layer. rotate : float, 3-tuple of float, or n-D array. If a float convert into a 2D rotation matrix using that value as an angle. If 3-tuple convert into a 3D rotation matrix, using a yaw, pitch, roll convention. Otherwise assume an nD rotation. Angles are assumed to be in degrees. They can be converted from radians with np.degrees if needed. shear : 1-D array or n-D array Either a vector of upper triangular values, or an nD shear matrix with ones along the main diagonal. affine : n-D array or napari.utils.transforms.Affine (N+1, N+1) affine transformation matrix in homogeneous coordinates. The first (N, N) entries correspond to a linear transform and the final column is a length N translation vector and a 1 or a napari `Affine` transform object. Applied as an extra transform on top of the provided scale, rotate, and shear values. opacity : float Opacity of the layer visual, between 0.0 and 1.0. blending : str One of a list of preset blending modes that determines how RGB and alpha values of the layer visual get mixed. Allowed values are {'opaque', 'translucent', and 'additive'}. visible : bool Whether the layer visual is currently being displayed. cache : bool Whether slices of out-of-core datasets should be cached upon retrieval. Currently, this only applies to dask arrays. Attributes ---------- data : (N, ) list of array List of shape data, where each element is an (N, D) array of the N vertices of a shape in D dimensions. features : Dataframe-like Features table where each row corresponds to a shape and each column is a feature. feature_defaults : DataFrame-like Stores the default value of each feature in a table with one row. properties : dict {str: array (N,)}, DataFrame Properties for each shape. Each property should be an array of length N, where N is the number of shapes. text : str, dict Text to be displayed with the shapes. If text is set to a key in properties, the value of that property will be displayed. Multiple properties can be composed using f-string-like syntax (e.g., '{property_1}, {float_property:.2f}). For example usage, see /napari/examples/add_shapes_with_text.py. shape_type : (N, ) list of str Name of shape type for each shape. edge_color : str, array-like Color of the shape border. Numeric color values should be RGB(A). face_color : str, array-like Color of the shape face. Numeric color values should be RGB(A). edge_width : (N, ) list of float Edge width for each shape. z_index : (N, ) list of int z-index for each shape. current_edge_width : float Thickness of lines and edges of the next shape to be added or the currently selected shape. current_edge_color : str Color of the edge of the next shape to be added or the currently selected shape. current_face_color : str Color of the face of the next shape to be added or the currently selected shape. selected_data : set List of currently selected shapes. nshapes : int Total number of shapes. mode : Mode Interactive mode. The normal, default mode is PAN_ZOOM, which allows for normal interactivity with the canvas. The SELECT mode allows for entire shapes to be selected, moved and resized. The DIRECT mode allows for shapes to be selected and their individual vertices to be moved. The VERTEX_INSERT and VERTEX_REMOVE modes allow for individual vertices either to be added to or removed from shapes that are already selected. Note that shapes cannot be selected in this mode. The ADD_RECTANGLE, ADD_ELLIPSE, ADD_LINE, ADD_PATH, and ADD_POLYGON modes all allow for their corresponding shape type to be added. Notes ----- _data_dict : Dict of ShapeList Dictionary containing all the shape data indexed by slice tuple _data_view : ShapeList Object containing the currently viewed shape data. _selected_data_history : set Set of currently selected captured on press of . _selected_data_stored : set Set of selected previously displayed. Used to prevent rerendering the same highlighted shapes when no data has changed. _selected_box : None | np.ndarray `None` if no shapes are selected, otherwise a 10x2 array of vertices of the interaction box. The first 8 points are the corners and midpoints of the box. The 9th point is the center of the box, and the last point is the location of the rotation handle that can be used to rotate the box. _drag_start : None | np.ndarray If a drag has been started and is in progress then a length 2 array of the initial coordinates of the drag. `None` otherwise. _drag_box : None | np.ndarray If a drag box is being created to select shapes then this is a 2x2 array of the two extreme corners of the drag. `None` otherwise. _drag_box_stored : None | np.ndarray If a drag box is being created to select shapes then this is a 2x2 array of the two extreme corners of the drag that have previously been rendered. `None` otherwise. Used to prevent rerendering the same drag box when no data has changed. _is_moving : bool Bool indicating if any shapes are currently being moved. _is_selecting : bool Bool indicating if a drag box is currently being created in order to select shapes. _is_creating : bool Bool indicating if any shapes are currently being created. _fixed_aspect : bool Bool indicating if aspect ratio of shapes should be preserved on resizing. _aspect_ratio : float Value of aspect ratio to be preserved if `_fixed_aspect` is `True`. _fixed_vertex : None | np.ndarray If a scaling or rotation is in progress then a length 2 array of the coordinates that are remaining fixed during the move. `None` otherwise. _fixed_index : int If a scaling or rotation is in progress then the index of the vertex of the bounding box that is remaining fixed during the move. `None` otherwise. _update_properties : bool Bool indicating if properties are to allowed to update the selected shapes when they are changed. Blocking this prevents circular loops when shapes are selected and the properties are changed based on that selection _allow_thumbnail_update : bool Flag set to true to allow the thumbnail to be updated. Blocking the thumbnail can be advantageous where responsiveness is critical. _clipboard : dict Dict of shape objects that are to be used during a copy and paste. _colors : list List of supported vispy color names. _vertex_size : float Size of the vertices of the shapes and bounding box in Canvas coordinates. _rotation_handle_length : float Length of the rotation handle of the bounding box in Canvas coordinates. _input_ndim : int Dimensions of shape data. _thumbnail_update_thresh : int If there are more than this number of shapes, the thumbnail won't update during interactive events """ _modeclass = Mode _colors = get_color_names() _vertex_size = 10 _rotation_handle_length = 20 _highlight_color = (0, 0.6, 1) _highlight_width = 1.5 # If more shapes are present then they are randomly subsampled # in the thumbnail _max_shapes_thumbnail = 100 _drag_modes = { Mode.PAN_ZOOM: no_op, Mode.TRANSFORM: transform_with_box, Mode.SELECT: select, Mode.DIRECT: select, Mode.VERTEX_INSERT: vertex_insert, Mode.VERTEX_REMOVE: vertex_remove, Mode.ADD_RECTANGLE: add_rectangle, Mode.ADD_ELLIPSE: add_ellipse, Mode.ADD_LINE: add_line, Mode.ADD_PATH: add_path_polygon, Mode.ADD_POLYGON: add_path_polygon, } _move_modes = { Mode.PAN_ZOOM: no_op, Mode.TRANSFORM: highlight_box_handles, Mode.SELECT: highlight, Mode.DIRECT: highlight, Mode.VERTEX_INSERT: highlight, Mode.VERTEX_REMOVE: highlight, Mode.ADD_RECTANGLE: no_op, Mode.ADD_ELLIPSE: no_op, Mode.ADD_LINE: no_op, Mode.ADD_PATH: add_path_polygon_creating, Mode.ADD_POLYGON: add_path_polygon_creating, } _double_click_modes = { Mode.PAN_ZOOM: no_op, Mode.TRANSFORM: no_op, Mode.SELECT: no_op, Mode.DIRECT: no_op, Mode.VERTEX_INSERT: no_op, Mode.VERTEX_REMOVE: no_op, Mode.ADD_RECTANGLE: no_op, Mode.ADD_ELLIPSE: no_op, Mode.ADD_LINE: no_op, Mode.ADD_PATH: finish_drawing_shape, Mode.ADD_POLYGON: finish_drawing_shape, } _cursor_modes = { Mode.PAN_ZOOM: 'standard', Mode.TRANSFORM: 'standard', Mode.SELECT: 'pointing', Mode.DIRECT: 'pointing', Mode.VERTEX_INSERT: 'cross', Mode.VERTEX_REMOVE: 'cross', Mode.ADD_RECTANGLE: 'cross', Mode.ADD_ELLIPSE: 'cross', Mode.ADD_LINE: 'cross', Mode.ADD_PATH: 'cross', Mode.ADD_POLYGON: 'cross', } _interactive_modes = { Mode.PAN_ZOOM, } def __init__( self, data=None, *, ndim=None, features=None, properties=None, property_choices=None, text=None, shape_type='rectangle', edge_width=1, edge_color='#777777', edge_color_cycle=None, edge_colormap='viridis', edge_contrast_limits=None, face_color='white', face_color_cycle=None, face_colormap='viridis', face_contrast_limits=None, z_index=0, name=None, metadata=None, scale=None, translate=None, rotate=None, shear=None, affine=None, opacity=0.7, blending='translucent', visible=True, cache=True, experimental_clipping_planes=None, ) -> None: if data is None: if ndim is None: ndim = 2 data = np.empty((0, 0, ndim)) else: data, shape_type = extract_shape_type(data, shape_type) data_ndim = get_shape_ndim(data) if ndim is not None and ndim != data_ndim: raise ValueError( trans._( "Shape dimensions must be equal to ndim", deferred=True, ) ) ndim = data_ndim super().__init__( data, ndim=ndim, name=name, metadata=metadata, scale=scale, translate=translate, rotate=rotate, shear=shear, affine=affine, opacity=opacity, blending=blending, visible=visible, cache=cache, experimental_clipping_planes=experimental_clipping_planes, ) self.events.add( edge_width=Event, edge_color=Event, face_color=Event, properties=Event, current_edge_color=Event, current_face_color=Event, current_properties=Event, highlight=Event, features=Event, feature_defaults=Event, ) # Flag set to false to block thumbnail refresh self._allow_thumbnail_update = True self._display_order_stored = [] self._ndisplay_stored = self._slice_input.ndisplay self._feature_table = _FeatureTable.from_layer( features=features, properties=properties, property_choices=property_choices, num_data=number_of_shapes(data), ) # The following shape properties are for the new shapes that will # be drawn. Each shape has a corresponding property with the # value for itself if np.isscalar(edge_width): self._current_edge_width = edge_width else: self._current_edge_width = 1 self._data_view = ShapeList(ndisplay=self._slice_input.ndisplay) self._data_view.slice_key = np.array(self._slice_indices)[ self._slice_input.not_displayed ] self._value = (None, None) self._value_stored = (None, None) self._moving_value = (None, None) self._selected_data = set() self._selected_data_stored = set() self._selected_data_history = set() self._selected_box = None self._drag_start = None self._fixed_vertex = None self._fixed_aspect = False self._aspect_ratio = 1 self._is_moving = False # _moving_coordinates are needed for fixing aspect ratio during # a resize, it stores the last pointer coordinate value that happened # during a mouse move to that pressing/releasing shift # can trigger a redraw of the shape with a fixed aspect ratio. self._moving_coordinates = None self._fixed_index = 0 self._is_selecting = False self._drag_box = None self._drag_box_stored = None self._is_creating = False self._clipboard = {} self._status = self.mode self._init_shapes( data, shape_type=shape_type, edge_width=edge_width, edge_color=edge_color, edge_color_cycle=edge_color_cycle, edge_colormap=edge_colormap, edge_contrast_limits=edge_contrast_limits, face_color=face_color, face_color_cycle=face_color_cycle, face_colormap=face_colormap, face_contrast_limits=face_contrast_limits, z_index=z_index, ) # set the current_* properties if len(data) > 0: self._current_edge_color = self.edge_color[-1] self._current_face_color = self.face_color[-1] elif len(data) == 0 and len(self.properties) > 0: self._initialize_current_color_for_empty_layer(edge_color, 'edge') self._initialize_current_color_for_empty_layer(face_color, 'face') elif len(data) == 0 and len(self.properties) == 0: self._current_edge_color = transform_color_with_defaults( num_entries=1, colors=edge_color, elem_name="edge_color", default="black", ) self._current_face_color = transform_color_with_defaults( num_entries=1, colors=face_color, elem_name="face_color", default="black", ) self._text = TextManager._from_layer( text=text, features=self.features, ) # Trigger generation of view slice and thumbnail self.refresh() def _initialize_current_color_for_empty_layer( self, color: ColorType, attribute: str ): """Initialize current_{edge,face}_color when starting with empty layer. Parameters ---------- color : (N, 4) array or str The value for setting edge or face_color attribute : str in {'edge', 'face'} The name of the attribute to set the color of. Should be 'edge' for edge_color or 'face' for face_color. """ color_mode = getattr(self, f'_{attribute}_color_mode') if color_mode == ColorMode.DIRECT: curr_color = transform_color_with_defaults( num_entries=1, colors=color, elem_name=f'{attribute}_color', default="white", ) elif color_mode == ColorMode.CYCLE: color_cycle = getattr(self, f'_{attribute}_color_cycle') curr_color = transform_color(next(color_cycle)) # add the new color cycle mapping color_property = getattr(self, f'_{attribute}_color_property') prop_value = self.property_choices[color_property][0] color_cycle_map = getattr(self, f'{attribute}_color_cycle_map') color_cycle_map[prop_value] = np.squeeze(curr_color) setattr(self, f'{attribute}_color_cycle_map', color_cycle_map) elif color_mode == ColorMode.COLORMAP: color_property = getattr(self, f'_{attribute}_color_property') prop_value = self.property_choices[color_property][0] colormap = getattr(self, f'{attribute}_colormap') contrast_limits = getattr(self, f'_{attribute}_contrast_limits') curr_color, _ = map_property( prop=prop_value, colormap=colormap, contrast_limits=contrast_limits, ) setattr(self, f'_current_{attribute}_color', curr_color) @property def data(self): """list: Each element is an (N, D) array of the vertices of a shape.""" return self._data_view.data @data.setter def data(self, data): self._finish_drawing() data, shape_type = extract_shape_type(data) n_new_shapes = number_of_shapes(data) # not given a shape_type through data if shape_type is None: shape_type = self.shape_type edge_widths = self._data_view.edge_widths edge_color = self._data_view.edge_color face_color = self._data_view.face_color z_indices = self._data_view.z_indices # fewer shapes, trim attributes if self.nshapes > n_new_shapes: shape_type = shape_type[:n_new_shapes] edge_widths = edge_widths[:n_new_shapes] z_indices = z_indices[:n_new_shapes] edge_color = edge_color[:n_new_shapes] face_color = face_color[:n_new_shapes] # more shapes, add attributes elif self.nshapes < n_new_shapes: n_shapes_difference = n_new_shapes - self.nshapes shape_type = ( shape_type + [get_default_shape_type(shape_type)] * n_shapes_difference ) edge_widths = edge_widths + [1] * n_shapes_difference z_indices = z_indices + [0] * n_shapes_difference edge_color = np.concatenate( ( edge_color, self._get_new_shape_color(n_shapes_difference, 'edge'), ) ) face_color = np.concatenate( ( face_color, self._get_new_shape_color(n_shapes_difference, 'face'), ) ) self._data_view = ShapeList(ndisplay=self._slice_input.ndisplay) self._data_view.slice_key = np.array(self._slice_indices)[ self._slice_input.not_displayed ] self.add( data, shape_type=shape_type, edge_width=edge_widths, edge_color=edge_color, face_color=face_color, z_index=z_indices, ) self._update_dims() self.events.data(value=self.data) self._reset_editable() def _on_selection(self, selected: bool): # this method is slated for removal. don't add anything new. if not selected: self._finish_drawing() @property def features(self): """Dataframe-like features table. It is an implementation detail that this is a `pandas.DataFrame`. In the future, we will target the currently-in-development Data API dataframe protocol [1]. This will enable us to use alternate libraries such as xarray or cuDF for additional features without breaking existing usage of this. If you need to specifically rely on the pandas API, please coerce this to a `pandas.DataFrame` using `features_to_pandas_dataframe`. References ---------- .. [1]: https://data-apis.org/dataframe-protocol/latest/API.html """ return self._feature_table.values @features.setter def features( self, features: Union[Dict[str, np.ndarray], pd.DataFrame], ) -> None: self._feature_table.set_values(features, num_data=self.nshapes) if self._face_color_property and ( self._face_color_property not in self.features ): self._face_color_property = '' warnings.warn( trans._( 'property used for face_color dropped', deferred=True, ), RuntimeWarning, ) if self._edge_color_property and ( self._edge_color_property not in self.features ): self._edge_color_property = '' warnings.warn( trans._( 'property used for edge_color dropped', deferred=True, ), RuntimeWarning, ) self.text.refresh(self.features) self.events.properties() self.events.features() @property def feature_defaults(self): """Dataframe-like with one row of feature default values. See `features` for more details on the type of this property. """ return self._feature_table.defaults @property def properties(self) -> Dict[str, np.ndarray]: """dict {str: np.ndarray (N,)}, DataFrame: Annotations for each shape""" return self._feature_table.properties() @properties.setter def properties(self, properties: Dict[str, Array]): self.features = properties @property def property_choices(self) -> Dict[str, np.ndarray]: return self._feature_table.choices() def _get_ndim(self): """Determine number of dimensions of the layer.""" if self.nshapes == 0: ndim = self.ndim else: ndim = self.data[0].shape[1] return ndim @property def _extent_data(self) -> np.ndarray: """Extent of layer in data coordinates. Returns ------- extent_data : array, shape (2, D) """ if len(self.data) == 0: extrema = np.full((2, self.ndim), np.nan) else: maxs = np.max([np.max(d, axis=0) for d in self.data], axis=0) mins = np.min([np.min(d, axis=0) for d in self.data], axis=0) extrema = np.vstack([mins, maxs]) return extrema @property def nshapes(self): """int: Total number of shapes.""" return len(self._data_view.shapes) @property def current_edge_width(self): """float: Width of shape edges including lines and paths.""" return self._current_edge_width @current_edge_width.setter def current_edge_width(self, edge_width): self._current_edge_width = edge_width if self._update_properties: for i in self.selected_data: self._data_view.update_edge_width(i, edge_width) self.events.edge_width() @property def current_edge_color(self): """str: color of shape edges including lines and paths.""" hex_ = rgb_to_hex(self._current_edge_color)[0] return hex_to_name.get(hex_, hex_) @current_edge_color.setter def current_edge_color(self, edge_color): self._current_edge_color = transform_color(edge_color) if self._update_properties: for i in self.selected_data: self._data_view.update_edge_color(i, self._current_edge_color) self.events.edge_color() self._update_thumbnail() self.events.current_edge_color() @property def current_face_color(self): """str: color of shape faces.""" hex_ = rgb_to_hex(self._current_face_color)[0] return hex_to_name.get(hex_, hex_) @current_face_color.setter def current_face_color(self, face_color): self._current_face_color = transform_color(face_color) if self._update_properties: for i in self.selected_data: self._data_view.update_face_color(i, self._current_face_color) self.events.face_color() self._update_thumbnail() self.events.current_face_color() @property def current_properties(self) -> Dict[str, np.ndarray]: """dict{str: np.ndarray(1,)}: properties for the next added shape.""" return self._feature_table.currents() @current_properties.setter def current_properties(self, current_properties): update_indices = None if ( self._update_properties and len(self.selected_data) > 0 and self._mode in [Mode.SELECT, Mode.PAN_ZOOM] ): update_indices = list(self.selected_data) self._feature_table.set_currents( current_properties, update_indices=update_indices ) if update_indices is not None: self.refresh_colors() self.events.properties() self.events.features() self.events.current_properties() self.events.feature_defaults() @property def shape_type(self): """list of str: name of shape type for each shape.""" return self._data_view.shape_types @shape_type.setter def shape_type(self, shape_type): self._finish_drawing() new_data_view = ShapeList() shape_inputs = zip( self._data_view.data, ensure_iterable(shape_type), self._data_view.edge_widths, self._data_view.edge_color, self._data_view.face_color, self._data_view.z_indices, ) self._add_shapes_to_view(shape_inputs, new_data_view) self._data_view = new_data_view self._update_dims() @property def edge_color(self): """(N x 4) np.ndarray: Array of RGBA face colors for each shape""" return self._data_view.edge_color @edge_color.setter def edge_color(self, edge_color): self._set_color(edge_color, 'edge') self.events.edge_color() self._update_thumbnail() @property def edge_color_cycle(self) -> np.ndarray: """Union[list, np.ndarray] : Color cycle for edge_color. Can be a list of colors defined by name, RGB or RGBA """ return self._edge_color_cycle_values @edge_color_cycle.setter def edge_color_cycle(self, edge_color_cycle: Union[list, np.ndarray]): self._set_color_cycle(edge_color_cycle, 'edge') @property def edge_colormap(self) -> Tuple[str, Colormap]: """Return the colormap to be applied to a property to get the edge color. Returns ------- colormap : napari.utils.Colormap The Colormap object. """ return self._edge_colormap @edge_colormap.setter def edge_colormap(self, colormap: ValidColormapArg): self._edge_colormap = ensure_colormap(colormap) @property def edge_contrast_limits(self) -> Tuple[float, float]: """None, (float, float): contrast limits for mapping the edge_color colormap property to 0 and 1 """ return self._edge_contrast_limits @edge_contrast_limits.setter def edge_contrast_limits( self, contrast_limits: Union[None, Tuple[float, float]] ): self._edge_contrast_limits = contrast_limits @property def edge_color_mode(self) -> str: """str: Edge color setting mode DIRECT (default mode) allows each shape color to be set arbitrarily CYCLE allows the color to be set via a color cycle over an attribute COLORMAP allows color to be set via a color map over an attribute """ return str(self._edge_color_mode) @edge_color_mode.setter def edge_color_mode(self, edge_color_mode: Union[str, ColorMode]): self._set_color_mode(edge_color_mode, 'edge') @property def face_color(self): """(N x 4) np.ndarray: Array of RGBA face colors for each shape""" return self._data_view.face_color @face_color.setter def face_color(self, face_color): self._set_color(face_color, 'face') self.events.face_color() self._update_thumbnail() @property def face_color_cycle(self) -> np.ndarray: """Union[np.ndarray, cycle]: Color cycle for face_color Can be a list of colors defined by name, RGB or RGBA """ return self._face_color_cycle_values @face_color_cycle.setter def face_color_cycle(self, face_color_cycle: Union[np.ndarray, cycle]): self._set_color_cycle(face_color_cycle, 'face') @property def face_colormap(self) -> Tuple[str, Colormap]: """Return the colormap to be applied to a property to get the face color. Returns ------- colormap : napari.utils.Colormap The Colormap object. """ return self._face_colormap @face_colormap.setter def face_colormap(self, colormap: ValidColormapArg): self._face_colormap = ensure_colormap(colormap) @property def face_contrast_limits(self) -> Union[None, Tuple[float, float]]: """None, (float, float) : clims for mapping the face_color colormap property to 0 and 1 """ return self._face_contrast_limits @face_contrast_limits.setter def face_contrast_limits( self, contrast_limits: Union[None, Tuple[float, float]] ): self._face_contrast_limits = contrast_limits @property def face_color_mode(self) -> str: """str: Face color setting mode DIRECT (default mode) allows each shape color to be set arbitrarily CYCLE allows the color to be set via a color cycle over an attribute COLORMAP allows color to be set via a color map over an attribute """ return str(self._face_color_mode) @face_color_mode.setter def face_color_mode(self, face_color_mode): self._set_color_mode(face_color_mode, 'face') def _set_color_mode( self, color_mode: Union[ColorMode, str], attribute: str ): """Set the face_color_mode or edge_color_mode property Parameters ---------- color_mode : str, ColorMode The value for setting edge or face_color_mode. If color_mode is a string, it should be one of: 'direct', 'cycle', or 'colormap' attribute : str in {'edge', 'face'} The name of the attribute to set the color of. Should be 'edge' for edge_colo_moder or 'face' for face_color_mode. """ color_mode = ColorMode(color_mode) if color_mode == ColorMode.DIRECT: setattr(self, f'_{attribute}_color_mode', color_mode) elif color_mode in (ColorMode.CYCLE, ColorMode.COLORMAP): color_property = getattr(self, f'_{attribute}_color_property') if color_property == '': if self.properties: new_color_property = next(iter(self.properties)) setattr( self, f'_{attribute}_color_property', new_color_property, ) warnings.warn( trans._( '_{attribute}_color_property was not set, setting to: {new_color_property}', deferred=True, attribute=attribute, new_color_property=new_color_property, ) ) else: raise ValueError( trans._( 'There must be a valid Shapes.properties to use {color_mode}', deferred=True, color_mode=color_mode, ) ) # ColorMode.COLORMAP can only be applied to numeric properties color_property = getattr(self, f'_{attribute}_color_property') if (color_mode == ColorMode.COLORMAP) and not issubclass( self.properties[color_property].dtype.type, np.number ): raise TypeError( trans._( 'selected property must be numeric to use ColorMode.COLORMAP', deferred=True, ) ) setattr(self, f'_{attribute}_color_mode', color_mode) self.refresh_colors() def _set_color_cycle(self, color_cycle: np.ndarray, attribute: str): """Set the face_color_cycle or edge_color_cycle property Parameters ---------- color_cycle : (N, 4) or (N, 1) array The value for setting edge or face_color_cycle attribute : str in {'edge', 'face'} The name of the attribute to set the color of. Should be 'edge' for edge_color or 'face' for face_color. """ transformed_color_cycle, transformed_colors = transform_color_cycle( color_cycle=color_cycle, elem_name=f'{attribute}_color_cycle', default="white", ) setattr(self, f'_{attribute}_color_cycle_values', transformed_colors) setattr(self, f'_{attribute}_color_cycle', transformed_color_cycle) if self._update_properties is True: color_mode = getattr(self, f'_{attribute}_color_mode') if color_mode == ColorMode.CYCLE: self.refresh_colors(update_color_mapping=True) @property def edge_width(self): """list of float: edge width for each shape.""" return self._data_view.edge_widths @edge_width.setter def edge_width(self, width): """Set edge width of shapes using float or list of float. If list of float, must be of equal length to n shapes Parameters ---------- width : float or list of float width of all shapes, or each shape if list """ if isinstance(width, list): if not len(width) == self.nshapes: raise ValueError( trans._('Length of list does not match number of shapes') ) else: widths = width else: widths = [width for _ in range(self.nshapes)] for i, width in enumerate(widths): self._data_view.update_edge_width(i, width) @property def z_index(self): """list of int: z_index for each shape.""" return self._data_view.z_indices @z_index.setter def z_index(self, z_index): """Set z_index of shape using either int or list of int. When list of int is provided, must be of equal length to n shapes. Parameters ---------- z_index : int or list of int z-index of shapes """ if isinstance(z_index, list): if not len(z_index) == self.nshapes: raise ValueError( trans._('Length of list does not match number of shapes') ) else: z_indices = z_index else: z_indices = [z_index for _ in range(self.nshapes)] for i, z_idx in enumerate(z_indices): self._data_view.update_z_index(i, z_idx) @property def selected_data(self): """set: set of currently selected shapes.""" return self._selected_data @selected_data.setter def selected_data(self, selected_data): self._selected_data = set(selected_data) self._selected_box = self.interaction_box(self._selected_data) # Update properties based on selected shapes if len(selected_data) > 0: selected_data_indices = list(selected_data) selected_face_colors = self._data_view._face_color[ selected_data_indices ] if ( unique_face_color := _unique_element(selected_face_colors) ) is not None: with self.block_update_properties(): self.current_face_color = unique_face_color selected_edge_colors = self._data_view._edge_color[ selected_data_indices ] if ( unique_edge_color := _unique_element(selected_edge_colors) ) is not None: with self.block_update_properties(): self.current_edge_color = unique_edge_color unique_edge_width = _unique_element( np.array( [ self._data_view.shapes[i].edge_width for i in selected_data ] ) ) if unique_edge_width is not None: with self.block_update_properties(): self.current_edge_width = unique_edge_width unique_properties = {} for k, v in self.properties.items(): unique_properties[k] = _unique_element( v[selected_data_indices] ) if all(p is not None for p in unique_properties.values()): with self.block_update_properties(): self.current_properties = unique_properties def _set_color(self, color, attribute: str): """Set the face_color or edge_color property Parameters ---------- color : (N, 4) array or str The value for setting edge or face_color attribute : str in {'edge', 'face'} The name of the attribute to set the color of. Should be 'edge' for edge_color or 'face' for face_color. """ if self._is_color_mapped(color): if guess_continuous(self.properties[color]): setattr(self, f'_{attribute}_color_mode', ColorMode.COLORMAP) else: setattr(self, f'_{attribute}_color_mode', ColorMode.CYCLE) setattr(self, f'_{attribute}_color_property', color) self.refresh_colors(update_color_mapping=True) else: if len(self.data) > 0: transformed_color = transform_color_with_defaults( num_entries=len(self.data), colors=color, elem_name="face_color", default="white", ) colors = normalize_and_broadcast_colors( len(self.data), transformed_color ) else: colors = np.empty((0, 4)) setattr(self._data_view, f'{attribute}_color', colors) setattr(self, f'_{attribute}_color_mode', ColorMode.DIRECT) color_event = getattr(self.events, f'{attribute}_color') color_event() def refresh_colors(self, update_color_mapping: bool = False): """Calculate and update face and edge colors if using a cycle or color map Parameters ---------- update_color_mapping : bool If set to True, the function will recalculate the color cycle map or colormap (whichever is being used). If set to False, the function will use the current color cycle map or color map. For example, if you are adding/modifying shapes and want them to be colored with the same mapping as the other shapes (i.e., the new shapes shouldn't affect the color cycle map or colormap), set update_color_mapping=False. Default value is False. """ self._refresh_color('face', update_color_mapping) self._refresh_color('edge', update_color_mapping) def _refresh_color( self, attribute: str, update_color_mapping: bool = False ): """Calculate and update face or edge colors if using a cycle or color map Parameters ---------- attribute : str in {'edge', 'face'} The name of the attribute to set the color of. Should be 'edge' for edge_color or 'face' for face_color. update_color_mapping : bool If set to True, the function will recalculate the color cycle map or colormap (whichever is being used). If set to False, the function will use the current color cycle map or color map. For example, if you are adding/modifying shapes and want them to be colored with the same mapping as the other shapes (i.e., the new shapes shouldn't affect the color cycle map or colormap), set update_color_mapping=False. Default value is False. """ if self._update_properties: color_mode = getattr(self, f'_{attribute}_color_mode') if color_mode in [ColorMode.CYCLE, ColorMode.COLORMAP]: colors = self._map_color(attribute, update_color_mapping) setattr(self._data_view, f'{attribute}_color', colors) color_event = getattr(self.events, f'{attribute}_color') color_event() def _initialize_color(self, color, attribute: str, n_shapes: int): """Get the face/edge colors the Shapes layer will be initialized with Parameters ---------- color : (N, 4) array or str The value for setting edge or face_color attribute : str in {'edge', 'face'} The name of the attribute to set the color of. Should be 'edge' for edge_color or 'face' for face_color. Returns ------- init_colors : (N, 4) array or str The calculated values for setting edge or face_color """ if self._is_color_mapped(color): if guess_continuous(self.properties[color]): setattr(self, f'_{attribute}_color_mode', ColorMode.COLORMAP) else: setattr(self, f'_{attribute}_color_mode', ColorMode.CYCLE) setattr(self, f'_{attribute}_color_property', color) init_colors = self._map_color( attribute, update_color_mapping=False ) else: if n_shapes > 0: transformed_color = transform_color_with_defaults( num_entries=n_shapes, colors=color, elem_name="face_color", default="white", ) init_colors = normalize_and_broadcast_colors( n_shapes, transformed_color ) else: init_colors = np.empty((0, 4)) setattr(self, f'_{attribute}_color_mode', ColorMode.DIRECT) return init_colors def _map_color(self, attribute: str, update_color_mapping: bool = False): """Calculate the mapping for face or edge colors if using a cycle or color map Parameters ---------- attribute : str in {'edge', 'face'} The name of the attribute to set the color of. Should be 'edge' for edge_color or 'face' for face_color. update_color_mapping : bool If set to True, the function will recalculate the color cycle map or colormap (whichever is being used). If set to False, the function will use the current color cycle map or color map. For example, if you are adding/modifying shapes and want them to be colored with the same mapping as the other shapes (i.e., the new shapes shouldn't affect the color cycle map or colormap), set update_color_mapping=False. Default value is False. Returns ------- colors : (N, 4) array or str The calculated values for setting edge or face_color """ color_mode = getattr(self, f'_{attribute}_color_mode') if color_mode == ColorMode.CYCLE: color_property = getattr(self, f'_{attribute}_color_property') color_properties = self.properties[color_property] if update_color_mapping: color_cycle = getattr(self, f'_{attribute}_color_cycle') color_cycle_map = { k: np.squeeze(transform_color(c)) for k, c in zip(np.unique(color_properties), color_cycle) } setattr(self, f'{attribute}_color_cycle_map', color_cycle_map) else: # add properties if they are not in the colormap # and update_color_mapping==False color_cycle_map = getattr(self, f'{attribute}_color_cycle_map') color_cycle_keys = [*color_cycle_map] props_in_map = np.in1d(color_properties, color_cycle_keys) if not np.all(props_in_map): props_to_add = np.unique( color_properties[np.logical_not(props_in_map)] ) color_cycle = getattr(self, f'_{attribute}_color_cycle') for prop in props_to_add: color_cycle_map[prop] = np.squeeze( transform_color(next(color_cycle)) ) setattr( self, f'{attribute}_color_cycle_map', color_cycle_map, ) colors = np.array([color_cycle_map[x] for x in color_properties]) if len(colors) == 0: colors = np.empty((0, 4)) elif color_mode == ColorMode.COLORMAP: color_property = getattr(self, f'_{attribute}_color_property') color_properties = self.properties[color_property] if len(color_properties) > 0: contrast_limits = getattr(self, f'{attribute}_contrast_limits') colormap = getattr(self, f'{attribute}_colormap') if update_color_mapping or contrast_limits is None: colors, contrast_limits = map_property( prop=color_properties, colormap=colormap ) setattr( self, f'{attribute}_contrast_limits', contrast_limits, ) else: colors, _ = map_property( prop=color_properties, colormap=colormap, contrast_limits=contrast_limits, ) else: colors = np.empty((0, 4)) return colors def _get_new_shape_color(self, adding: int, attribute: str): """Get the color for the shape(s) to be added. Parameters ---------- adding : int the number of shapes that were added (and thus the number of color entries to add) attribute : str in {'edge', 'face'} The name of the attribute to set the color of. Should be 'edge' for edge_color_mode or 'face' for face_color_mode. Returns ------- new_colors : (N, 4) array (Nx4) RGBA array of colors for the N new shapes """ color_mode = getattr(self, f'_{attribute}_color_mode') if color_mode == ColorMode.DIRECT: current_face_color = getattr(self, f'_current_{attribute}_color') new_colors = np.tile(current_face_color, (adding, 1)) elif color_mode == ColorMode.CYCLE: property_name = getattr(self, f'_{attribute}_color_property') color_property_value = self.current_properties[property_name][0] # check if the new color property is in the cycle map # and add it if it is not color_cycle_map = getattr(self, f'{attribute}_color_cycle_map') color_cycle_keys = [*color_cycle_map] if color_property_value not in color_cycle_keys: color_cycle = getattr(self, f'_{attribute}_color_cycle') color_cycle_map[color_property_value] = np.squeeze( transform_color(next(color_cycle)) ) setattr(self, f'{attribute}_color_cycle_map', color_cycle_map) new_colors = np.tile( color_cycle_map[color_property_value], (adding, 1) ) elif color_mode == ColorMode.COLORMAP: property_name = getattr(self, f'_{attribute}_color_property') color_property_value = self.current_properties[property_name][0] colormap = getattr(self, f'{attribute}_colormap') contrast_limits = getattr(self, f'_{attribute}_contrast_limits') fc, _ = map_property( prop=color_property_value, colormap=colormap, contrast_limits=contrast_limits, ) new_colors = np.tile(fc, (adding, 1)) return new_colors def _is_color_mapped(self, color): """determines if the new color argument is for directly setting or cycle/colormap""" if isinstance(color, str): if color in self.properties: return True else: return False elif isinstance(color, (list, np.ndarray)): return False else: raise ValueError( trans._( 'face_color should be the name of a color, an array of colors, or the name of an property', deferred=True, ) ) def _get_state(self): """Get dictionary of layer state. Returns ------- state : dict Dictionary of layer state. """ state = self._get_base_state() state.update( { 'ndim': self.ndim, 'properties': self.properties, 'property_choices': self.property_choices, 'text': self.text.dict(), 'shape_type': self.shape_type, 'opacity': self.opacity, 'z_index': self.z_index, 'edge_width': self.edge_width, 'face_color': self.face_color, 'face_color_cycle': self.face_color_cycle, 'face_colormap': self.face_colormap.name, 'face_contrast_limits': self.face_contrast_limits, 'edge_color': self.edge_color, 'edge_color_cycle': self.edge_color_cycle, 'edge_colormap': self.edge_colormap.name, 'edge_contrast_limits': self.edge_contrast_limits, 'data': self.data, 'features': self.features, } ) return state @property def _indices_view(self): return np.where(self._data_view._displayed)[0] @property def _view_text(self) -> np.ndarray: """Get the values of the text elements in view Returns ------- text : (N x 1) np.ndarray Array of text strings for the N text elements in view """ # This may be triggered when the string encoding instance changed, # in which case it has no cached values, so generate them here. self.text.string._apply(self.features) return self.text.view_text(self._indices_view) @property def _view_text_coords(self) -> Tuple[np.ndarray, str, str]: """Get the coordinates of the text elements in view Returns ------- text_coords : (N x D) np.ndarray Array of coordinates for the N text elements in view anchor_x : str The vispy text anchor for the x axis anchor_y : str The vispy text anchor for the y axis """ ndisplay = self._slice_input.ndisplay order = self._slice_input.order # get the coordinates of the vertices for the shapes in view in_view_shapes_coords = [ self._data_view.data[i] for i in self._indices_view ] # get the coordinates for the dimensions being displayed sliced_in_view_coords = [ position[:, self._slice_input.displayed] for position in in_view_shapes_coords ] return self.text.compute_text_coords( sliced_in_view_coords, ndisplay, order ) @property def _view_text_color(self) -> np.ndarray: """Get the colors of the text elements at the given indices.""" self.text.color._apply(self.features) return self.text._view_color(self._indices_view) @Layer.mode.getter def mode(self): """MODE: Interactive mode. The normal, default mode is PAN_ZOOM, which allows for normal interactivity with the canvas. The SELECT mode allows for entire shapes to be selected, moved and resized. The DIRECT mode allows for shapes to be selected and their individual vertices to be moved. The VERTEX_INSERT and VERTEX_REMOVE modes allow for individual vertices either to be added to or removed from shapes that are already selected. Note that shapes cannot be selected in this mode. The ADD_RECTANGLE, ADD_ELLIPSE, ADD_LINE, ADD_PATH, and ADD_POLYGON modes all allow for their corresponding shape type to be added. """ return str(self._mode) @mode.setter def mode(self, mode: Union[str, Mode]): mode = self._mode_setter_helper(mode) if mode == self._mode: return self._mode = mode self.events.mode(mode=mode) draw_modes = { Mode.SELECT, Mode.DIRECT, Mode.VERTEX_INSERT, Mode.VERTEX_REMOVE, } # don't update thumbnail on mode changes with self.block_thumbnail_update(): if not (mode in draw_modes and self._mode in draw_modes): # Shapes._finish_drawing() calls Shapes.refresh() self._finish_drawing() else: self.refresh() def _reset_editable(self) -> None: self.editable = self._slice_input.ndisplay == 2 def _on_editable_changed(self) -> None: if not self.editable: self.mode = Mode.PAN_ZOOM def add_rectangles( self, data, *, edge_width=None, edge_color=None, face_color=None, z_index=None, ): """Add rectangles to the current layer. Parameters ---------- data : Array | List[Array] List of rectangle data where each element is a (4, D) array of 4 vertices in D dimensions, or a (2, D) array of 2 vertices in D dimensions, where the vertices are top-left and bottom-right corners. Can be a 3-dimensional array for multiple shapes, or list of 2 or 4 vertices for a single shape. edge_width : float | list thickness of lines and edges. If a list is supplied it must be the same length as the length of `data` and each element will be applied to each shape otherwise the same value will be used for all shapes. edge_color : str | tuple | list If string can be any color name recognized by vispy or hex value if starting with `#`. If array-like must be 1-dimensional array with 3 or 4 elements. If a list is supplied it must be the same length as the length of `data` and each element will be applied to each shape otherwise the same value will be used for all shapes. face_color : str | tuple | list If string can be any color name recognized by vispy or hex value if starting with `#`. If array-like must be 1-dimensional array with 3 or 4 elements. If a list is supplied it must be the same length as the length of `data` and each element will be applied to each shape otherwise the same value will be used for all shapes. z_index : int | list Specifier of z order priority. Shapes with higher z order are displayed ontop of others. If a list is supplied it must be the same length as the length of `data` and each element will be applied to each shape otherwise the same value will be used for all shapes. """ # rectangles can have either 4 vertices or (top left, bottom right) valid_vertices_per_shape = (2, 4) validate_num_vertices( data, 'rectangle', valid_vertices=valid_vertices_per_shape ) self.add( data, shape_type='rectangle', edge_width=edge_width, edge_color=edge_color, face_color=face_color, z_index=z_index, ) def add_ellipses( self, data, *, edge_width=None, edge_color=None, face_color=None, z_index=None, ): """Add ellipses to the current layer. Parameters ---------- data : Array | List[Array] List of ellipse data where each element is a (4, D) array of 4 vertices in D dimensions representing a bounding box, or a (2, D) array of center position and radii magnitudes in D dimensions. Can be a 3-dimensional array for multiple shapes, or list of 2 or 4 vertices for a single shape. edge_width : float | list thickness of lines and edges. If a list is supplied it must be the same length as the length of `data` and each element will be applied to each shape otherwise the same value will be used for all shapes. edge_color : str | tuple | list If string can be any color name recognized by vispy or hex value if starting with `#`. If array-like must be 1-dimensional array with 3 or 4 elements. If a list is supplied it must be the same length as the length of `data` and each element will be applied to each shape otherwise the same value will be used for all shapes. face_color : str | tuple | list If string can be any color name recognized by vispy or hex value if starting with `#`. If array-like must be 1-dimensional array with 3 or 4 elements. If a list is supplied it must be the same length as the length of `data` and each element will be applied to each shape otherwise the same value will be used for all shapes. z_index : int | list Specifier of z order priority. Shapes with higher z order are displayed ontop of others. If a list is supplied it must be the same length as the length of `data` and each element will be applied to each shape otherwise the same value will be used for all shapes. """ valid_elem_per_shape = (2, 4) validate_num_vertices( data, 'ellipse', valid_vertices=valid_elem_per_shape ) self.add( data, shape_type='ellipse', edge_width=edge_width, edge_color=edge_color, face_color=face_color, z_index=z_index, ) def add_polygons( self, data, *, edge_width=None, edge_color=None, face_color=None, z_index=None, ): """Add polygons to the current layer. Parameters ---------- data : Array | List[Array] List of polygon data where each element is a (V, D) array of V vertices in D dimensions representing a polygon. Can be a 3-dimensional array if polygons have same number of vertices, or a list of V vertices for a single polygon. edge_width : float | list thickness of lines and edges. If a list is supplied it must be the same length as the length of `data` and each element will be applied to each shape otherwise the same value will be used for all shapes. edge_color : str | tuple | list If string can be any color name recognized by vispy or hex value if starting with `#`. If array-like must be 1-dimensional array with 3 or 4 elements. If a list is supplied it must be the same length as the length of `data` and each element will be applied to each shape otherwise the same value will be used for all shapes. face_color : str | tuple | list If string can be any color name recognized by vispy or hex value if starting with `#`. If array-like must be 1-dimensional array with 3 or 4 elements. If a list is supplied it must be the same length as the length of `data` and each element will be applied to each shape otherwise the same value will be used for all shapes. z_index : int | list Specifier of z order priority. Shapes with higher z order are displayed ontop of others. If a list is supplied it must be the same length as the length of `data` and each element will be applied to each shape otherwise the same value will be used for all shapes. """ min_vertices = 3 validate_num_vertices(data, 'polygon', min_vertices=min_vertices) self.add( data, shape_type='polygon', edge_width=edge_width, edge_color=edge_color, face_color=face_color, z_index=z_index, ) def add_lines( self, data, *, edge_width=None, edge_color=None, face_color=None, z_index=None, ): """Add lines to the current layer. Parameters ---------- data : Array | List[Array] List of line data where each element is a (2, D) array of 2 vertices in D dimensions representing a line. Can be a 3-dimensional array for multiple shapes, or list of 2 vertices for a single shape. edge_width : float | list thickness of lines and edges. If a list is supplied it must be the same length as the length of `data` and each element will be applied to each shape otherwise the same value will be used for all shapes. edge_color : str | tuple | list If string can be any color name recognized by vispy or hex value if starting with `#`. If array-like must be 1-dimensional array with 3 or 4 elements. If a list is supplied it must be the same length as the length of `data` and each element will be applied to each shape otherwise the same value will be used for all shapes. face_color : str | tuple | list If string can be any color name recognized by vispy or hex value if starting with `#`. If array-like must be 1-dimensional array with 3 or 4 elements. If a list is supplied it must be the same length as the length of `data` and each element will be applied to each shape otherwise the same value will be used for all shapes. z_index : int | list Specifier of z order priority. Shapes with higher z order are displayed ontop of others. If a list is supplied it must be the same length as the length of `data` and each element will be applied to each shape otherwise the same value will be used for all shapes. """ valid_vertices_per_line = (2,) validate_num_vertices( data, 'line', valid_vertices=valid_vertices_per_line ) self.add( data, shape_type='line', edge_width=edge_width, edge_color=edge_color, face_color=face_color, z_index=z_index, ) def add_paths( self, data, *, edge_width=None, edge_color=None, face_color=None, z_index=None, ): """Add paths to the current layer. Parameters ---------- data : Array | List[Array] List of path data where each element is a (V, D) array of V vertices in D dimensions representing a path. Can be a 3-dimensional array if all paths have same number of vertices, or a list of V vertices for a single path. edge_width : float | list thickness of lines and edges. If a list is supplied it must be the same length as the length of `data` and each element will be applied to each shape otherwise the same value will be used for all shapes. edge_color : str | tuple | list If string can be any color name recognized by vispy or hex value if starting with `#`. If array-like must be 1-dimensional array with 3 or 4 elements. If a list is supplied it must be the same length as the length of `data` and each element will be applied to each shape otherwise the same value will be used for all shapes. face_color : str | tuple | list If string can be any color name recognized by vispy or hex value if starting with `#`. If array-like must be 1-dimensional array with 3 or 4 elements. If a list is supplied it must be the same length as the length of `data` and each element will be applied to each shape otherwise the same value will be used for all shapes. z_index : int | list Specifier of z order priority. Shapes with higher z order are displayed ontop of others. If a list is supplied it must be the same length as the length of `data` and each element will be applied to each shape otherwise the same value will be used for all shapes. """ min_vertices_per_path = 2 validate_num_vertices(data, 'path', min_vertices=min_vertices_per_path) self.add( data, shape_type='path', edge_width=edge_width, edge_color=edge_color, face_color=face_color, z_index=z_index, ) def add( self, data, *, shape_type='rectangle', edge_width=None, edge_color=None, face_color=None, z_index=None, ): """Add shapes to the current layer. Parameters ---------- data : Array | Tuple(Array,str) | List[Array | Tuple(Array, str)] | Tuple(List[Array], str) List of shape data, where each element is either an (N, D) array of the N vertices of a shape in D dimensions or a tuple containing an array of the N vertices and the shape_type string. When a shape_type is present, it overrides keyword arg shape_type. Can be an 3-dimensional array if each shape has the same number of vertices. shape_type : string | list String of shape shape_type, must be one of "{'line', 'rectangle', 'ellipse', 'path', 'polygon'}". If a list is supplied it must be the same length as the length of `data` and each element will be applied to each shape otherwise the same value will be used for all shapes. Overridden by data shape_type, if present. edge_width : float | list thickness of lines and edges. If a list is supplied it must be the same length as the length of `data` and each element will be applied to each shape otherwise the same value will be used for all shapes. edge_color : str | tuple | list If string can be any color name recognized by vispy or hex value if starting with `#`. If array-like must be 1-dimensional array with 3 or 4 elements. If a list is supplied it must be the same length as the length of `data` and each element will be applied to each shape otherwise the same value will be used for all shapes. face_color : str | tuple | list If string can be any color name recognized by vispy or hex value if starting with `#`. If array-like must be 1-dimensional array with 3 or 4 elements. If a list is supplied it must be the same length as the length of `data` and each element will be applied to each shape otherwise the same value will be used for all shapes. z_index : int | list Specifier of z order priority. Shapes with higher z order are displayed ontop of others. If a list is supplied it must be the same length as the length of `data` and each element will be applied to each shape otherwise the same value will be used for all shapes. """ data, shape_type = extract_shape_type(data, shape_type) if edge_width is None: edge_width = self.current_edge_width n_new_shapes = number_of_shapes(data) if edge_color is None: edge_color = self._get_new_shape_color( n_new_shapes, attribute='edge' ) if face_color is None: face_color = self._get_new_shape_color( n_new_shapes, attribute='face' ) if self._data_view is not None: z_index = z_index or max(self._data_view._z_index, default=-1) + 1 else: z_index = z_index or 0 if n_new_shapes > 0: total_shapes = n_new_shapes + self.nshapes self._feature_table.resize(total_shapes) self.text.apply(self.features) self._add_shapes( data, shape_type=shape_type, edge_width=edge_width, edge_color=edge_color, face_color=face_color, z_index=z_index, ) self.events.data(value=self.data) def _init_shapes( self, data, *, shape_type='rectangle', edge_width=None, edge_color=None, edge_color_cycle, edge_colormap, edge_contrast_limits, face_color=None, face_color_cycle, face_colormap, face_contrast_limits, z_index=None, ): """Add shapes to the data view. Parameters ---------- data : Array | Tuple(Array,str) | List[Array | Tuple(Array, str)] | Tuple(List[Array], str) List of shape data, where each element is either an (N, D) array of the N vertices of a shape in D dimensions or a tuple containing an array of the N vertices and the shape_type string. When a shape_type is present, it overrides keyword arg shape_type. Can be an 3-dimensional array if each shape has the same number of vertices. shape_type : string | list String of shape shape_type, must be one of "{'line', 'rectangle', 'ellipse', 'path', 'polygon'}". If a list is supplied it must be the same length as the length of `data` and each element will be applied to each shape otherwise the same value will be used for all shapes. Overriden by data shape_type, if present. edge_width : float | list thickness of lines and edges. If a list is supplied it must be the same length as the length of `data` and each element will be applied to each shape otherwise the same value will be used for all shapes. edge_color : str | tuple | list If string can be any color name recognized by vispy or hex value if starting with `#`. If array-like must be 1-dimensional array with 3 or 4 elements. If a list is supplied it must be the same length as the length of `data` and each element will be applied to each shape otherwise the same value will be used for all shapes. face_color : str | tuple | list If string can be any color name recognized by vispy or hex value if starting with `#`. If array-like must be 1-dimensional array with 3 or 4 elements. If a list is supplied it must be the same length as the length of `data` and each element will be applied to each shape otherwise the same value will be used for all shapes. z_index : int | list Specifier of z order priority. Shapes with higher z order are displayed ontop of others. If a list is supplied it must be the same length as the length of `data` and each element will be applied to each shape otherwise the same value will be used for all shapes. """ n_shapes = number_of_shapes(data) with self.block_update_properties(): self._edge_color_property = '' self.edge_color_cycle_map = {} self.edge_colormap = edge_colormap self._edge_contrast_limits = edge_contrast_limits if edge_color_cycle is None: edge_color_cycle = deepcopy(DEFAULT_COLOR_CYCLE) self.edge_color_cycle = edge_color_cycle edge_color = self._initialize_color( edge_color, attribute='edge', n_shapes=n_shapes ) self._face_color_property = '' self.face_color_cycle_map = {} self.face_colormap = face_colormap self._face_contrast_limits = face_contrast_limits if face_color_cycle is None: face_color_cycle = deepcopy(DEFAULT_COLOR_CYCLE) self.face_color_cycle = face_color_cycle face_color = self._initialize_color( face_color, attribute='face', n_shapes=n_shapes ) with self.block_thumbnail_update(): self._add_shapes( data, shape_type=shape_type, edge_width=edge_width, edge_color=edge_color, face_color=face_color, z_index=z_index, z_refresh=False, ) self._data_view._update_z_order() self.refresh_colors() def _add_shapes( self, data, *, shape_type='rectangle', edge_width=None, edge_color=None, face_color=None, z_index=None, z_refresh=True, ): """Add shapes to the data view. Parameters ---------- data : Array | Tuple(Array,str) | List[Array | Tuple(Array, str)] | Tuple(List[Array], str) List of shape data, where each element is either an (N, D) array of the N vertices of a shape in D dimensions or a tuple containing an array of the N vertices and the shape_type string. When a shape_type is present, it overrides keyword arg shape_type. Can be an 3-dimensional array if each shape has the same number of vertices. shape_type : string | list String of shape shape_type, must be one of "{'line', 'rectangle', 'ellipse', 'path', 'polygon'}". If a list is supplied it must be the same length as the length of `data` and each element will be applied to each shape otherwise the same value will be used for all shapes. Overridden by data shape_type, if present. edge_width : float | list thickness of lines and edges. If a list is supplied it must be the same length as the length of `data` and each element will be applied to each shape otherwise the same value will be used for all shapes. edge_color : str | tuple | list If string can be any color name recognized by vispy or hex value if starting with `#`. If array-like must be 1-dimensional array with 3 or 4 elements. If a list is supplied it must be the same length as the length of `data` and each element will be applied to each shape otherwise the same value will be used for all shapes. face_color : str | tuple | list If string can be any color name recognized by vispy or hex value if starting with `#`. If array-like must be 1-dimensional array with 3 or 4 elements. If a list is supplied it must be the same length as the length of `data` and each element will be applied to each shape otherwise the same value will be used for all shapes. z_index : int | list Specifier of z order priority. Shapes with higher z order are displayed on top of others. If a list is supplied it must be the same length as the length of `data` and each element will be applied to each shape otherwise the same value will be used for all shapes. z_refresh : bool If set to true, the mesh elements are reindexed with the new z order. When shape_index is provided, z_refresh will be overwritten to false, as the z indices will not change. When adding a batch of shapes, set to false and then call ShapesList._update_z_order() once at the end. """ if edge_width is None: edge_width = self.current_edge_width if edge_color is None: edge_color = self._current_edge_color if face_color is None: face_color = self._current_face_color if self._data_view is not None: z_index = z_index or max(self._data_view._z_index, default=-1) + 1 else: z_index = z_index or 0 if len(data) > 0: if np.array(data[0]).ndim == 1: # If a single array for a shape has been passed turn into list data = [data] # transform the colors transformed_ec = transform_color_with_defaults( num_entries=len(data), colors=edge_color, elem_name="edge_color", default="white", ) transformed_edge_color = normalize_and_broadcast_colors( len(data), transformed_ec ) transformed_fc = transform_color_with_defaults( num_entries=len(data), colors=face_color, elem_name="face_color", default="white", ) transformed_face_color = normalize_and_broadcast_colors( len(data), transformed_fc ) # Turn input arguments into iterables shape_inputs = zip( data, ensure_iterable(shape_type), ensure_iterable(edge_width), transformed_edge_color, transformed_face_color, ensure_iterable(z_index), ) self._add_shapes_to_view(shape_inputs, self._data_view) self._display_order_stored = copy(self._slice_input.order) self._ndisplay_stored = copy(self._slice_input.ndisplay) self._update_dims() def _add_shapes_to_view(self, shape_inputs, data_view): """Build new shapes and add them to the _data_view""" shape_inputs = tuple(shape_inputs) # build all shapes sh_inp = tuple( ( shape_classes[ShapeType(st)]( d, edge_width=ew, z_index=z, dims_order=self._slice_input.order, ndisplay=self._slice_input.ndisplay, ), ec, fc, ) for d, st, ew, ec, fc, z in shape_inputs ) shapes, edge_colors, face_colors = tuple(zip(*sh_inp)) # Add all shapes at once (faster than adding them one by one) data_view.add( shape=shapes, edge_color=edge_colors, face_color=face_colors, z_refresh=False, ) data_view._update_z_order() @property def text(self) -> TextManager: """TextManager: The TextManager object containing the text properties""" return self._text @text.setter def text(self, text): self._text._update_from_layer( text=text, features=self.features, ) def refresh_text(self): """Refresh the text values. This is generally used if the properties were updated without changing the data """ self.text.refresh(self.features) def _set_view_slice(self): """Set the view given the slicing indices.""" ndisplay = self._slice_input.ndisplay if not ndisplay == self._ndisplay_stored: self.selected_data = set() self._data_view.ndisplay = min(self.ndim, ndisplay) self._ndisplay_stored = ndisplay self._clipboard = {} if not self._slice_input.order == self._display_order_stored: self.selected_data = set() self._data_view.update_dims_order(self._slice_input.order) self._display_order_stored = copy(self._slice_input.order) # Clear clipboard if dimensions swap self._clipboard = {} slice_key = np.array(self._slice_indices)[ self._slice_input.not_displayed ] if not np.all(slice_key == self._data_view.slice_key): self.selected_data = set() self._data_view.slice_key = slice_key def interaction_box(self, index): """Create the interaction box around a shape or list of shapes. If a single index is passed then the boudning box will be inherited from that shapes interaction box. If list of indices is passed it will be computed directly. Parameters ---------- index : int | list Index of a single shape, or a list of shapes around which to construct the interaction box Returns ------- box : np.ndarray 10x2 array of vertices of the interaction box. The first 8 points are the corners and midpoints of the box in clockwise order starting in the upper-left corner. The 9th point is the center of the box, and the last point is the location of the rotation handle that can be used to rotate the box """ if isinstance(index, (list, np.ndarray, set)): if len(index) == 0: box = None elif len(index) == 1: box = copy(self._data_view.shapes[list(index)[0]]._box) else: indices = np.isin(self._data_view.displayed_index, list(index)) box = create_box(self._data_view.displayed_vertices[indices]) else: box = copy(self._data_view.shapes[index]._box) if box is not None: rot = box[Box.TOP_CENTER] length_box = np.linalg.norm( box[Box.BOTTOM_LEFT] - box[Box.TOP_LEFT] ) if length_box > 0: r = self._rotation_handle_length * self.scale_factor rot = ( rot - r * (box[Box.BOTTOM_LEFT] - box[Box.TOP_LEFT]) / length_box ) box = np.append(box, [rot], axis=0) return box def _outline_shapes(self): """Find outlines of any selected or hovered shapes. Returns ------- vertices : None | np.ndarray Nx2 array of any vertices of outline or None triangles : None | np.ndarray Mx3 array of any indices of vertices for triangles of outline or None """ if self._value is not None and ( self._value[0] is not None or len(self.selected_data) > 0 ): if len(self.selected_data) > 0: index = list(self.selected_data) if self._value[0] is not None: if self._value[0] in index: pass else: index.append(self._value[0]) index.sort() else: index = self._value[0] centers, offsets, triangles = self._data_view.outline(index) vertices = centers + ( self.scale_factor * self._highlight_width * offsets ) vertices = vertices[:, ::-1] else: vertices = None triangles = None return vertices, triangles def _compute_vertices_and_box(self): """Compute location of highlight vertices and box for rendering. Returns ------- vertices : np.ndarray Nx2 array of any vertices to be rendered as Markers face_color : str String of the face color of the Markers edge_color : str String of the edge color of the Markers and Line for the box pos : np.ndarray Nx2 array of vertices of the box that will be rendered using a Vispy Line width : float Width of the box edge """ if len(self.selected_data) > 0: if self._mode == Mode.SELECT: # If in select mode just show the interaction boudning box # including its vertices and the rotation handle box = self._selected_box[Box.WITH_HANDLE] if self._value[0] is None: face_color = 'white' elif self._value[1] is None: face_color = 'white' else: face_color = self._highlight_color edge_color = self._highlight_color vertices = box[:, ::-1] # Use a subset of the vertices of the interaction_box to plot # the line around the edge pos = box[Box.LINE_HANDLE][:, ::-1] width = 1.5 elif self._mode in ( [ Mode.DIRECT, Mode.ADD_PATH, Mode.ADD_POLYGON, Mode.ADD_RECTANGLE, Mode.ADD_ELLIPSE, Mode.ADD_LINE, Mode.VERTEX_INSERT, Mode.VERTEX_REMOVE, ] ): # If in one of these mode show the vertices of the shape itself inds = np.isin( self._data_view.displayed_index, list(self.selected_data) ) vertices = self._data_view.displayed_vertices[inds][:, ::-1] # If currently adding path don't show box over last vertex if self._mode == Mode.ADD_PATH: vertices = vertices[:-1] if self._value[0] is None: face_color = 'white' elif self._value[1] is None: face_color = 'white' else: face_color = self._highlight_color edge_color = self._highlight_color pos = None width = 0 else: # Otherwise show nothing vertices = np.empty((0, 2)) face_color = 'white' edge_color = 'white' pos = None width = 0 elif self._is_selecting: # If currently dragging a selection box just show an outline of # that box vertices = np.empty((0, 2)) edge_color = self._highlight_color face_color = 'white' box = create_box(self._drag_box) width = 1.5 # Use a subset of the vertices of the interaction_box to plot # the line around the edge pos = box[Box.LINE][:, ::-1] else: # Otherwise show nothing vertices = np.empty((0, 2)) face_color = 'white' edge_color = 'white' pos = None width = 0 return vertices, face_color, edge_color, pos, width def _set_highlight(self, force=False): """Render highlights of shapes. Includes boundaries, vertices, interaction boxes, and the drag selection box when appropriate. Parameters ---------- force : bool Bool that forces a redraw to occur when `True` """ # Check if any shape or vertex ids have changed since last call if ( self.selected_data == self._selected_data_stored and np.all(self._value == self._value_stored) and np.all(self._drag_box == self._drag_box_stored) ) and not force: return self._selected_data_stored = copy(self.selected_data) self._value_stored = copy(self._value) self._drag_box_stored = copy(self._drag_box) self.events.highlight() def _finish_drawing(self, event=None): """Reset properties used in shape drawing.""" index = copy(self._moving_value[0]) self._is_moving = False self.selected_data = set() self._drag_start = None self._drag_box = None self._is_selecting = False self._fixed_vertex = None self._value = (None, None) self._moving_value = (None, None) if self._is_creating is True and self._mode == Mode.ADD_PATH: vertices = self._data_view.shapes[index].data if len(vertices) <= 2: self._data_view.remove(index) else: self._data_view.edit(index, vertices[:-1]) if self._is_creating is True and self._mode == Mode.ADD_POLYGON: vertices = self._data_view.shapes[index].data if len(vertices) <= 3: self._data_view.remove(index) else: self._data_view.edit(index, vertices[:-1]) self._is_creating = False self._update_dims() @contextmanager def block_thumbnail_update(self): """Use this context manager to block thumbnail updates""" previous = self._allow_thumbnail_update self._allow_thumbnail_update = False try: yield finally: self._allow_thumbnail_update = previous def _update_thumbnail(self, event=None): """Update thumbnail with current shapes and colors.""" # Set the thumbnail to black, opacity 1 colormapped = np.zeros(self._thumbnail_shape) colormapped[..., 3] = 1 # if the shapes layer is empty, don't update, just leave it black if len(self.data) == 0: self.thumbnail = colormapped # don't update the thumbnail if dragging a shape elif self._is_moving is False and self._allow_thumbnail_update is True: # calculate min vals for the vertices and pad with 0.5 # the offset is needed to ensure that the top left corner of the shapes # corresponds to the top left corner of the thumbnail de = self._extent_data offset = ( np.array([de[0, d] for d in self._slice_input.displayed]) + 0.5 ) # calculate range of values for the vertices and pad with 1 # padding ensures the entire shape can be represented in the thumbnail # without getting clipped shape = np.ceil( [de[1, d] - de[0, d] + 1 for d in self._slice_input.displayed] ).astype(int) zoom_factor = np.divide( self._thumbnail_shape[:2], shape[-2:] ).min() colormapped = self._data_view.to_colors( colors_shape=self._thumbnail_shape[:2], zoom_factor=zoom_factor, offset=offset[-2:], max_shapes=self._max_shapes_thumbnail, ) self.thumbnail = colormapped def remove_selected(self): """Remove any selected shapes.""" index = list(self.selected_data) to_remove = sorted(index, reverse=True) for ind in to_remove: self._data_view.remove(ind) if len(index) > 0: self._feature_table.remove(index) self.text.remove(index) self._data_view._edge_color = np.delete( self._data_view._edge_color, index, axis=0 ) self._data_view._face_color = np.delete( self._data_view._face_color, index, axis=0 ) self.selected_data = set() self._finish_drawing() self.events.data(value=self.data) def _rotate_box(self, angle, center=(0, 0)): """Perform a rotation on the selected box. Parameters ---------- angle : float angle specifying rotation of shapes in degrees. center : list coordinates of center of rotation. """ theta = np.radians(angle) transform = np.array( [[np.cos(theta), np.sin(theta)], [-np.sin(theta), np.cos(theta)]] ) box = self._selected_box - center self._selected_box = box @ transform.T + center def _scale_box(self, scale, center=(0, 0)): """Perform a scaling on the selected box. Parameters ---------- scale : float, list scalar or list specifying rescaling of shape. center : list coordinates of center of rotation. """ if not isinstance(scale, (list, np.ndarray)): scale = [scale, scale] box = self._selected_box - center box = np.array(box * scale) if not np.all(box[Box.TOP_CENTER] == box[Box.HANDLE]): r = self._rotation_handle_length * self.scale_factor handle_vec = box[Box.HANDLE] - box[Box.TOP_CENTER] cur_len = np.linalg.norm(handle_vec) box[Box.HANDLE] = box[Box.TOP_CENTER] + r * handle_vec / cur_len self._selected_box = box + center def _transform_box(self, transform, center=(0, 0)): """Perform a linear transformation on the selected box. Parameters ---------- transform : np.ndarray 2x2 array specifying linear transform. center : list coordinates of center of rotation. """ box = self._selected_box - center box = box @ transform.T if not np.all(box[Box.TOP_CENTER] == box[Box.HANDLE]): r = self._rotation_handle_length * self.scale_factor handle_vec = box[Box.HANDLE] - box[Box.TOP_CENTER] cur_len = np.linalg.norm(handle_vec) box[Box.HANDLE] = box[Box.TOP_CENTER] + r * handle_vec / cur_len self._selected_box = box + center def _get_value(self, position): """Value of the data at a position in data coordinates. Parameters ---------- position : tuple Position in data coordinates. Returns ------- shape : int | None Index of shape if any that is at the coordinates. Returns `None` if no shape is found. vertex : int | None Index of vertex if any that is at the coordinates. Returns `None` if no vertex is found. """ if self._slice_input.ndisplay == 3: return (None, None) if self._is_moving: return self._moving_value coord = [position[i] for i in self._slice_input.displayed] # Check selected shapes value = None selected_index = list(self.selected_data) if len(selected_index) > 0: if self._mode == Mode.SELECT: # Check if inside vertex of interaction box or rotation handle box = self._selected_box[Box.WITH_HANDLE] distances = abs(box - coord) # Get the vertex sizes sizes = self._vertex_size * self.scale_factor / 2 # Check if any matching vertices matches = np.all(distances <= sizes, axis=1).nonzero() if len(matches[0]) > 0: value = (selected_index[0], matches[0][-1]) elif self._mode in ( [Mode.DIRECT, Mode.VERTEX_INSERT, Mode.VERTEX_REMOVE] ): # Check if inside vertex of shape inds = np.isin(self._data_view.displayed_index, selected_index) vertices = self._data_view.displayed_vertices[inds] distances = abs(vertices - coord) # Get the vertex sizes sizes = self._vertex_size * self.scale_factor / 2 # Check if any matching vertices matches = np.all(distances <= sizes, axis=1).nonzero()[0] if len(matches) > 0: index = inds.nonzero()[0][matches[-1]] shape = self._data_view.displayed_index[index] vals, idx = np.unique( self._data_view.displayed_index, return_index=True ) shape_in_list = list(vals).index(shape) value = (shape, index - idx[shape_in_list]) if value is None: # Check if mouse inside shape shape = self._data_view.inside(coord) value = (shape, None) return value def _get_value_3d( self, start_point: np.ndarray, end_point: np.ndarray, dims_displayed: List[int], ) -> Tuple[Union[float, int], None]: """Get the layer data value along a ray Parameters ---------- start_point : np.ndarray The start position of the ray used to interrogate the data. end_point : np.ndarray The end position of the ray used to interrogate the data. dims_displayed : List[int] The indices of the dimensions currently displayed in the Viewer. Returns ------- value The data value along the supplied ray. vertex : None Index of vertex if any that is at the coordinates. Always returns `None`. """ value, _ = self._get_index_and_intersection( start_point=start_point, end_point=end_point, dims_displayed=dims_displayed, ) return (value, None) def _get_index_and_intersection( self, start_point: np.ndarray, end_point: np.ndarray, dims_displayed: List[int], ) -> Tuple[Union[None, float, int], Union[None, np.ndarray]]: """Get the shape index and intersection point of the first shape (i.e., closest to start_point) along the specified 3D line segment. Note: this method is meant to be used for 3D intersection and returns (None, None) when used in 2D (i.e., len(dims_displayed) is 2). Parameters ---------- start_point : np.ndarray The start position of the ray used to interrogate the data in layer coordinates. end_point : np.ndarray The end position of the ray used to interrogate the data in layer coordinates. dims_displayed : List[int] The indices of the dimensions currently displayed in the Viewer. Returns ------- value Union[None, float, int] The data value along the supplied ray. intersection_point : Union[None, np.ndarray] (n,) array containing the point where the ray intersects the first shape (i.e., the shape most in the foreground). The coordinate is in layer coordinates. """ if len(dims_displayed) != 3: # return None if in 2D mode return None, None if (start_point is None) or (end_point is None): # return None if the ray doesn't intersect the data bounding box return None, None # Get the normal vector of the click plane start_position, ray_direction = nd_line_segment_to_displayed_data_ray( start_point=start_point, end_point=end_point, dims_displayed=dims_displayed, ) value, intersection = self._data_view._inside_3d( start_position, ray_direction ) # add the full nD coords to intersection intersection_point = start_point.copy() intersection_point[dims_displayed] = intersection return value, intersection_point def get_index_and_intersection( self, position: np.ndarray, view_direction: np.ndarray, dims_displayed: List[int], ) -> Tuple[Union[float, int], None]: """Get the shape index and intersection point of the first shape (i.e., closest to start_point) "under" a mouse click. See examples/add_points_on_nD_shapes.py for example usage. Parameters ---------- position : tuple Position in either data or world coordinates. view_direction : Optional[np.ndarray] A unit vector giving the direction of the ray in nD world coordinates. The default value is None. dims_displayed : Optional[List[int]] A list of the dimensions currently being displayed in the viewer. The default value is None. Returns ------- value The data value along the supplied ray. intersection_point : np.ndarray (n,) array containing the point where the ray intersects the first shape (i.e., the shape most in the foreground). The coordinate is in layer coordinates. """ start_point, end_point = self.get_ray_intersections( position, view_direction, dims_displayed ) if (start_point is not None) and (end_point is not None): shape_index, intersection_point = self._get_index_and_intersection( start_point=start_point, end_point=end_point, dims_displayed=dims_displayed, ) else: shape_index = (None,) intersection_point = None return shape_index, intersection_point def move_to_front(self): """Moves selected objects to be displayed in front of all others.""" if len(self.selected_data) == 0: return new_z_index = max(self._data_view._z_index) + 1 for index in self.selected_data: self._data_view.update_z_index(index, new_z_index) self.refresh() def move_to_back(self): """Moves selected objects to be displayed behind all others.""" if len(self.selected_data) == 0: return new_z_index = min(self._data_view._z_index) - 1 for index in self.selected_data: self._data_view.update_z_index(index, new_z_index) self.refresh() def _copy_data(self): """Copy selected shapes to clipboard.""" if len(self.selected_data) > 0: index = list(self.selected_data) self._clipboard = { 'data': [ deepcopy(self._data_view.shapes[i]) for i in self._selected_data ], 'edge_color': deepcopy(self._data_view._edge_color[index]), 'face_color': deepcopy(self._data_view._face_color[index]), 'features': deepcopy(self.features.iloc[index]), 'indices': self._slice_indices, 'text': self.text._copy(index), } else: self._clipboard = {} def _paste_data(self): """Paste any shapes from clipboard and then selects them.""" cur_shapes = self.nshapes if len(self._clipboard.keys()) > 0: # Calculate offset based on dimension shifts offset = [ self._slice_indices[i] - self._clipboard['indices'][i] for i in self._slice_input.not_displayed ] self._feature_table.append(self._clipboard['features']) self.text._paste(**self._clipboard['text']) # Add new shape data for i, s in enumerate(self._clipboard['data']): shape = deepcopy(s) data = copy(shape.data) not_disp = self._slice_input.not_displayed data[:, not_disp] = data[:, not_disp] + np.array(offset) shape.data = data face_color = self._clipboard['face_color'][i] edge_color = self._clipboard['edge_color'][i] self._data_view.add( shape, face_color=face_color, edge_color=edge_color ) self.selected_data = set( range(cur_shapes, cur_shapes + len(self._clipboard['data'])) ) self.move_to_front() def to_masks(self, mask_shape=None): """Return an array of binary masks, one for each shape. Parameters ---------- mask_shape : np.ndarray | tuple | None tuple defining shape of mask to be generated. If non specified, takes the max of all the vertices Returns ------- masks : np.ndarray Array where there is one binary mask for each shape """ if mask_shape is None: # See https://github.com/napari/napari/issues/2778 # Point coordinates land on pixel centers. We want to find the # smallest shape that will hold the largest point in the data, # using rounding. mask_shape = np.round(self._extent_data[1]) + 1 mask_shape = np.ceil(mask_shape).astype('int') masks = self._data_view.to_masks(mask_shape=mask_shape) return masks def to_labels(self, labels_shape=None): """Return an integer labels image. Parameters ---------- labels_shape : np.ndarray | tuple | None Tuple defining shape of labels image to be generated. If non specified, takes the max of all the vertiecs Returns ------- labels : np.ndarray Integer array where each value is either 0 for background or an integer up to N for points inside the shape at the index value - 1. For overlapping shapes z-ordering will be respected. """ if labels_shape is None: # See https://github.com/napari/napari/issues/2778 # Point coordinates land on pixel centers. We want to find the # smallest shape that will hold the largest point in the data, # using rounding. labels_shape = np.round(self._extent_data[1]) + 1 labels_shape = np.ceil(labels_shape).astype('int') labels = self._data_view.to_labels(labels_shape=labels_shape) return labels napari-0.5.0a1/napari/layers/surface/000077500000000000000000000000001437041365600174225ustar00rootroot00000000000000napari-0.5.0a1/napari/layers/surface/__init__.py000066400000000000000000000001111437041365600215240ustar00rootroot00000000000000from napari.layers.surface.surface import Surface __all__ = ['Surface'] napari-0.5.0a1/napari/layers/surface/_surface_constants.py000066400000000000000000000020701437041365600236560ustar00rootroot00000000000000from enum import auto from napari.utils.misc import StringEnum from napari.utils.translations import trans class Shading(StringEnum): """Shading: Shading mode for the surface. Selects a preset shading mode in vispy that determines how color is computed in the scene. See also: https://www.khronos.org/registry/OpenGL-Refpages/gl2.1/xhtml/glShadeModel.xml Shading.NONE Computed color is interpreted as input color, unaffected by lighting. Corresponds to shading='none'. Shading.FLAT Computed colours are the color at a specific vertex for each primitive in the mesh. Corresponds to shading='flat'. Shading.SMOOTH Computed colors are interpolated between vertices for each primitive in the mesh. Corresponds to shading='smooth' """ NONE = auto() FLAT = auto() SMOOTH = auto() SHADING_TRANSLATION = { trans._("none"): Shading.NONE, trans._("flat"): Shading.FLAT, trans._("smooth"): Shading.SMOOTH, } napari-0.5.0a1/napari/layers/surface/_surface_key_bindings.py000066400000000000000000000016111437041365600243070ustar00rootroot00000000000000from napari.layers.base._base_constants import Mode from napari.layers.surface.surface import Surface from napari.layers.utils.layer_utils import ( register_layer_action, register_layer_attr_action, ) from napari.utils.translations import trans def register_surface_action(description: str, repeatable: bool = False): return register_layer_action(Surface, description, repeatable) def register_surface_mode_action(description): return register_layer_attr_action(Surface, description, 'mode') @register_surface_mode_action(trans._('Transform')) def activate_surface_transform_mode(layer): layer.mode = Mode.TRANSFORM @register_surface_mode_action(trans._('Pan/zoom')) def activate_surface_pan_zoom_mode(layer): layer.mode = Mode.PAN_ZOOM surface_fun_to_mode = [ (activate_surface_pan_zoom_mode, Mode.PAN_ZOOM), (activate_surface_transform_mode, Mode.TRANSFORM), ] napari-0.5.0a1/napari/layers/surface/_surface_utils.py000066400000000000000000000024261437041365600230070ustar00rootroot00000000000000import numpy as np def calculate_barycentric_coordinates( point: np.ndarray, triangle_vertices: np.ndarray ) -> np.ndarray: """Calculate the barycentric coordinates for a point in a triangle. http://gamedev.stackexchange.com/questions/23743/whats-the-most-efficient-way-to-find-barycentric-coordinates Parameters ---------- point : np.ndarray The coordinates of the point for which to calculate the barycentric coordinate. triangle_vertices : np.ndarray (3, D) array containing the triangle vertices. Returns ------- barycentric_coorinates : np.ndarray The barycentric coordinate [u, v, w], where u, v, and w are the barycentric coordinates for the first, second, third triangle vertex, respectively. """ vertex_a = triangle_vertices[0, :] vertex_b = triangle_vertices[1, :] vertex_c = triangle_vertices[2, :] v0 = vertex_b - vertex_a v1 = vertex_c - vertex_a v2 = point - vertex_a d00 = np.dot(v0, v0) d01 = np.dot(v0, v1) d11 = np.dot(v1, v1) d20 = np.dot(v2, v0) d21 = np.dot(v2, v1) denominator = d00 * d11 - d01 * d01 v = (d11 * d20 - d01 * d21) / denominator w = (d00 * d21 - d01 * d20) / denominator u = 1 - v - w return np.array([u, v, w]) napari-0.5.0a1/napari/layers/surface/_tests/000077500000000000000000000000001437041365600207235ustar00rootroot00000000000000napari-0.5.0a1/napari/layers/surface/_tests/test_surface.py000066400000000000000000000214001437041365600237610ustar00rootroot00000000000000import numpy as np import pytest from napari._tests.utils import check_layer_world_data_extent from napari.layers import Surface def test_random_surface(): """Test instantiating Surface layer with random 2D data.""" np.random.seed(0) vertices = np.random.random((10, 2)) faces = np.random.randint(10, size=(6, 3)) values = np.random.random(10) data = (vertices, faces, values) layer = Surface(data) assert layer.ndim == 2 assert np.all([np.all(ld == d) for ld, d in zip(layer.data, data)]) assert np.all(layer.vertices == vertices) assert np.all(layer.faces == faces) assert np.all(layer.vertex_values == values) assert layer._data_view.shape[1] == 2 assert layer._view_vertex_values.ndim == 1 def test_random_surface_no_values(): """Test instantiating Surface layer with random 2D data but no vertex values.""" np.random.seed(0) vertices = np.random.random((10, 2)) faces = np.random.randint(10, size=(6, 3)) data = (vertices, faces) layer = Surface(data) assert layer.ndim == 2 assert np.all([np.all(ld == d) for ld, d in zip(layer.data, data)]) assert np.all(layer.vertices == vertices) assert np.all(layer.faces == faces) assert np.all(layer.vertex_values == np.ones(len(vertices))) assert layer._data_view.shape[1] == 2 assert layer._view_vertex_values.ndim == 1 def test_random_3D_surface(): """Test instantiating Surface layer with random 3D data.""" np.random.seed(0) vertices = np.random.random((10, 3)) faces = np.random.randint(10, size=(6, 3)) values = np.random.random(10) data = (vertices, faces, values) layer = Surface(data) assert layer.ndim == 3 assert np.all([np.all(ld == d) for ld, d in zip(layer.data, data)]) assert layer._data_view.shape[1] == 2 assert layer._view_vertex_values.ndim == 1 layer._slice_dims(ndisplay=3) assert layer._data_view.shape[1] == 3 assert layer._view_vertex_values.ndim == 1 def test_random_4D_surface(): """Test instantiating Surface layer with random 4D data.""" np.random.seed(0) vertices = np.random.random((10, 4)) faces = np.random.randint(10, size=(6, 3)) values = np.random.random(10) data = (vertices, faces, values) layer = Surface(data) assert layer.ndim == 4 assert np.all([np.all(ld == d) for ld, d in zip(layer.data, data)]) assert layer._data_view.shape[1] == 2 assert layer._view_vertex_values.ndim == 1 layer._slice_dims(ndisplay=3) assert layer._data_view.shape[1] == 3 assert layer._view_vertex_values.ndim == 1 def test_random_3D_timeseries_surface(): """Test instantiating Surface layer with random 3D timeseries data.""" np.random.seed(0) vertices = np.random.random((10, 3)) faces = np.random.randint(10, size=(6, 3)) values = np.random.random((22, 10)) data = (vertices, faces, values) layer = Surface(data) assert layer.ndim == 4 assert np.all([np.all(ld == d) for ld, d in zip(layer.data, data)]) assert layer._data_view.shape[1] == 2 assert layer._view_vertex_values.ndim == 1 assert layer.extent.data[1][0] == 22 layer._slice_dims(ndisplay=3) assert layer._data_view.shape[1] == 3 assert layer._view_vertex_values.ndim == 1 # If a values axis is made to be a displayed axis then no data should be # shown with pytest.warns(UserWarning): layer._slice_dims(ndisplay=3, order=[3, 0, 1, 2]) assert len(layer._data_view) == 0 def test_random_3D_multitimeseries_surface(): """Test instantiating Surface layer with random 3D multitimeseries data.""" np.random.seed(0) vertices = np.random.random((10, 3)) faces = np.random.randint(10, size=(6, 3)) values = np.random.random((16, 22, 10)) data = (vertices, faces, values) layer = Surface(data) assert layer.ndim == 5 assert np.all([np.all(ld == d) for ld, d in zip(layer.data, data)]) assert layer._data_view.shape[1] == 2 assert layer._view_vertex_values.ndim == 1 assert layer.extent.data[1][0] == 16 assert layer.extent.data[1][1] == 22 layer._slice_dims(ndisplay=3) assert layer._data_view.shape[1] == 3 assert layer._view_vertex_values.ndim == 1 def test_changing_surface(): """Test changing surface layer data""" np.random.seed(0) vertices = np.random.random((10, 2)) faces = np.random.randint(10, size=(6, 3)) values = np.random.random(10) data = (vertices, faces, values) layer = Surface(data) vertices = np.random.random((10, 3)) faces = np.random.randint(10, size=(6, 3)) values = np.random.random(10) data = (vertices, faces, values) layer.data = data assert layer.ndim == 3 assert np.all([np.all(ld == d) for ld, d in zip(layer.data, data)]) assert layer._data_view.shape[1] == 2 assert layer._view_vertex_values.ndim == 1 layer._slice_dims(ndisplay=3) assert layer._data_view.shape[1] == 3 assert layer._view_vertex_values.ndim == 1 def test_visiblity(): """Test setting layer visibility.""" np.random.seed(0) vertices = np.random.random((10, 3)) faces = np.random.randint(10, size=(6, 3)) values = np.random.random(10) data = (vertices, faces, values) layer = Surface(data) assert layer.visible is True layer.visible = False assert layer.visible is False layer = Surface(data, visible=False) assert layer.visible is False layer.visible = True assert layer.visible is True def test_surface_gamma(): """Test setting gamma.""" np.random.seed(0) vertices = np.random.random((10, 3)) faces = np.random.randint(10, size=(6, 3)) values = np.random.random(10) data = (vertices, faces, values) layer = Surface(data) assert layer.gamma == 1 # Change gamma property gamma = 0.7 layer.gamma = gamma assert layer.gamma == gamma # Set gamma as keyword argument layer = Surface(data, gamma=gamma) assert layer.gamma == gamma def test_world_data_extent(): """Test extent after applying transforms.""" data = [(-5, 0), (0, 15), (30, 12)] min_val = (-5, 0) max_val = (30, 15) layer = Surface((np.array(data), np.array((0, 1, 2)), np.array((0, 0, 0)))) extent = np.array((min_val, max_val)) check_layer_world_data_extent(layer, extent, (3, 1), (20, 5), False) def test_shading(): """Test setting shading""" np.random.seed(0) vertices = np.random.random((10, 3)) faces = np.random.randint(10, size=(6, 3)) values = np.random.random(10) data = (vertices, faces, values) layer = Surface(data) # change shading property shading = 'flat' layer.shading = shading assert layer.shading == shading # set shading as keyword argument layer = Surface(data, shading=shading) assert layer.shading == shading @pytest.mark.parametrize( "ray_start,ray_direction,expected_value,expected_index", [ ([0, 1, 1], [1, 0, 0], 2, 0), ([10, 1, 1], [-1, 0, 0], 2, 1), ], ) def test_get_value_3d( ray_start, ray_direction, expected_value, expected_index ): vertices = np.array( [ [3, 0, 0], [3, 0, 3], [3, 3, 0], [5, 0, 0], [5, 0, 3], [5, 3, 0], [2, 50, 50], [2, 50, 100], [2, 100, 50], ] ) faces = np.array([[0, 1, 2], [3, 4, 5], [6, 7, 8]]) values = np.array([1, 2, 3, 1, 2, 3, 1, 2, 3]) surface_layer = Surface((vertices, faces, values)) surface_layer._slice_dims([0, 0, 0], ndisplay=3) value, index = surface_layer.get_value( position=ray_start, view_direction=ray_direction, dims_displayed=[0, 1, 2], world=False, ) assert index == expected_index np.testing.assert_allclose(value, expected_value) @pytest.mark.parametrize( "ray_start,ray_direction,expected_value,expected_index", [ ([0, 0, 1, 1], [0, 1, 0, 0], 2, 0), ([0, 10, 1, 1], [0, -1, 0, 0], 2, 1), ], ) def test_get_value_3d_nd( ray_start, ray_direction, expected_value, expected_index ): vertices = np.array( [ [0, 3, 0, 0], [0, 3, 0, 3], [0, 3, 3, 0], [0, 5, 0, 0], [0, 5, 0, 3], [0, 5, 3, 0], [0, 2, 50, 50], [0, 2, 50, 100], [0, 2, 100, 50], ] ) faces = np.array([[0, 1, 2], [3, 4, 5], [6, 7, 8]]) values = np.array([1, 2, 3, 1, 2, 3, 1, 2, 3]) surface_layer = Surface((vertices, faces, values)) surface_layer._slice_dims([0, 0, 0, 0], ndisplay=3) value, index = surface_layer.get_value( position=ray_start, view_direction=ray_direction, dims_displayed=[1, 2, 3], world=False, ) assert index == expected_index np.testing.assert_allclose(value, expected_value) napari-0.5.0a1/napari/layers/surface/_tests/test_surface_utils.py000066400000000000000000000015221437041365600252040ustar00rootroot00000000000000import numpy as np import pytest from napari.layers.surface._surface_utils import ( calculate_barycentric_coordinates, ) @pytest.mark.parametrize( "point,expected_barycentric_coordinates", [ ([5, 1, 1], [1 / 3, 1 / 3, 1 / 3]), ([5, 0, 0], [1, 0, 0]), ([5, 0, 3], [0, 1, 0]), ([5, 3, 0], [0, 0, 1]), ], ) def test_calculate_barycentric_coordinates( point, expected_barycentric_coordinates ): triangle_vertices = np.array( [ [5, 0, 0], [5, 0, 3], [5, 3, 0], ] ) barycentric_coordinates = calculate_barycentric_coordinates( point, triangle_vertices ) np.testing.assert_allclose( barycentric_coordinates, expected_barycentric_coordinates ) np.testing.assert_allclose(np.sum(barycentric_coordinates), 1) napari-0.5.0a1/napari/layers/surface/normals.py000066400000000000000000000023301437041365600214450ustar00rootroot00000000000000from enum import Enum, auto from pydantic import Field from napari.utils.color import ColorValue from napari.utils.events import EventedModel class NormalMode(Enum): FACE = auto() VERTEX = auto() class Normals(EventedModel): """ Represents face or vertex normals of a surface mesh. Attributes ---------- mode: str Which normals to display (face or vertex). Immutable Field. visible : bool Whether the normals are displayed. color : str, array-like The color of the normal lines. See ``ColorValue.validate`` for supported values. width : float The width of the normal lines. length : float The length of the face normal lines. """ mode: NormalMode = Field(NormalMode.FACE, allow_mutation=False) visible: bool = False color: ColorValue = 'black' width: float = 1 length: float = 5 class SurfaceNormals(EventedModel): """ Represents both face and vertex normals for a surface mesh. """ face: Normals = Field( Normals(mode=NormalMode.FACE, color='orange'), allow_mutation=False ) vertex: Normals = Field( Normals(mode=NormalMode.FACE, color='blue'), allow_mutation=False ) napari-0.5.0a1/napari/layers/surface/surface.py000066400000000000000000000447041437041365600214350ustar00rootroot00000000000000import warnings from typing import List, Tuple, Union import numpy as np from napari.layers.base import Layer from napari.layers.intensity_mixin import IntensityVisualizationMixin from napari.layers.surface._surface_constants import Shading from napari.layers.surface._surface_utils import ( calculate_barycentric_coordinates, ) from napari.layers.surface.normals import SurfaceNormals from napari.layers.surface.wireframe import SurfaceWireframe from napari.layers.utils.interactivity_utils import ( nd_line_segment_to_displayed_data_ray, ) from napari.layers.utils.layer_utils import calc_data_range from napari.utils.colormaps import AVAILABLE_COLORMAPS from napari.utils.events import Event from napari.utils.geometry import find_nearest_triangle_intersection from napari.utils.translations import trans # Mixin must come before Layer class Surface(IntensityVisualizationMixin, Layer): """ Surface layer renders meshes onto the canvas. Parameters ---------- data : 2-tuple or 3-tuple of array The first element of the tuple is an (N, D) array of vertices of mesh triangles. The second is an (M, 3) array of int of indices of the mesh triangles. The optional third element is the (K0, ..., KL, N) array of values used to color vertices where the additional L dimensions are used to color the same mesh with different values. If not provided, it defaults to ones. colormap : str, napari.utils.Colormap, tuple, dict Colormap to use for luminance images. If a string must be the name of a supported colormap from vispy or matplotlib. If a tuple the first value must be a string to assign as a name to a colormap and the second item must be a Colormap. If a dict the key must be a string to assign as a name to a colormap and the value must be a Colormap. contrast_limits : list (2,) Color limits to be used for determining the colormap bounds for luminance images. If not passed is calculated as the min and max of the image. gamma : float Gamma correction for determining colormap linearity. Defaults to 1. name : str Name of the layer. metadata : dict Layer metadata. scale : tuple of float Scale factors for the layer. translate : tuple of float Translation values for the layer. rotate : float, 3-tuple of float, or n-D array. If a float convert into a 2D rotation matrix using that value as an angle. If 3-tuple convert into a 3D rotation matrix, using a yaw, pitch, roll convention. Otherwise assume an nD rotation. Angles are assumed to be in degrees. They can be converted from radians with np.degrees if needed. shear : 1-D array or n-D array Either a vector of upper triangular values, or an nD shear matrix with ones along the main diagonal. affine : n-D array or napari.utils.transforms.Affine (N+1, N+1) affine transformation matrix in homogeneous coordinates. The first (N, N) entries correspond to a linear transform and the final column is a length N translation vector and a 1 or a napari `Affine` transform object. Applied as an extra transform on top of the provided scale, rotate, and shear values. opacity : float Opacity of the layer visual, between 0.0 and 1.0. blending : str One of a list of preset blending modes that determines how RGB and alpha values of the layer visual get mixed. Allowed values are {'opaque', 'translucent', and 'additive'}. shading : str, Shading One of a list of preset shading modes that determine the lighting model using when rendering the surface in 3D. * ``Shading.NONE`` Corresponds to ``shading='none'``. * ``Shading.FLAT`` Corresponds to ``shading='flat'``. * ``Shading.SMOOTH`` Corresponds to ``shading='smooth'``. visible : bool Whether the layer visual is currently being displayed. cache : bool Whether slices of out-of-core datasets should be cached upon retrieval. Currently, this only applies to dask arrays. wireframe : dict or SurfaceWireframe Whether and how to display the edges of the surface mesh with a wireframe. normals : dict or SurfaceNormals Whether and how to display the face and vertex normals of the surface mesh. Attributes ---------- data : 3-tuple of array The first element of the tuple is an (N, D) array of vertices of mesh triangles. The second is an (M, 3) array of int of indices of the mesh triangles. The third element is the (K0, ..., KL, N) array of values used to color vertices where the additional L dimensions are used to color the same mesh with different values. vertices : (N, D) array Vertices of mesh triangles. faces : (M, 3) array of int Indices of mesh triangles. vertex_values : (K0, ..., KL, N) array Values used to color vertices. colormap : str, napari.utils.Colormap, tuple, dict Colormap to use for luminance images. If a string must be the name of a supported colormap from vispy or matplotlib. If a tuple the first value must be a string to assign as a name to a colormap and the second item must be a Colormap. If a dict the key must be a string to assign as a name to a colormap and the value must be a Colormap. contrast_limits : list (2,) Color limits to be used for determining the colormap bounds for luminance images. If not passed is calculated as the min and max of the image. shading: str One of a list of preset shading modes that determine the lighting model using when rendering the surface. * ``'none'`` * ``'flat'`` * ``'smooth'`` gamma : float Gamma correction for determining colormap linearity. wireframe : SurfaceWireframe Whether and how to display the edges of the surface mesh with a wireframe. normals : SurfaceNormals Whether and how to display the face and vertex normals of the surface mesh. Notes ----- _data_view : (M, 2) or (M, 3) array The coordinates of the vertices given the viewed dimensions. _view_faces : (P, 3) array The integer indices of the vertices that form the triangles in the currently viewed slice. _colorbar : array Colorbar for current colormap. """ _colormaps = AVAILABLE_COLORMAPS def __init__( self, data, *, colormap='gray', contrast_limits=None, gamma=1, name=None, metadata=None, scale=None, translate=None, rotate=None, shear=None, affine=None, opacity=1, blending='translucent', shading='flat', visible=True, cache=True, experimental_clipping_planes=None, wireframe=None, normals=None, ) -> None: ndim = data[0].shape[1] super().__init__( data, ndim, name=name, metadata=metadata, scale=scale, translate=translate, rotate=rotate, shear=shear, affine=affine, opacity=opacity, blending=blending, visible=visible, cache=cache, experimental_clipping_planes=experimental_clipping_planes, ) self.events.add( interpolation=Event, rendering=Event, shading=Event, ) # assign mesh data and establish default behavior if len(data) not in (2, 3): raise ValueError( trans._( 'Surface data tuple must be 2 or 3, specifying vertices, faces, and optionally vertex values, instead got length {length}.', deferred=True, length=len(data), ) ) self._vertices = data[0] self._faces = data[1] if len(data) == 3: self._vertex_values = data[2] else: self._vertex_values = np.ones(len(self._vertices)) # Set contrast_limits and colormaps self._gamma = gamma if contrast_limits is None: self._contrast_limits_range = calc_data_range(self._vertex_values) else: self._contrast_limits_range = contrast_limits self._contrast_limits = tuple(self._contrast_limits_range) self.colormap = colormap self.contrast_limits = self._contrast_limits # Data containing vectors in the currently viewed slice self._data_view = np.zeros((0, self._slice_input.ndisplay)) self._view_faces = np.zeros((0, 3)) self._view_vertex_values = [] # Trigger generation of view slice and thumbnail. # Use _update_dims instead of refresh here because _get_ndim is # dependent on vertex_values as well as vertices. self._update_dims() # Shading mode self._shading = shading self.wireframe = wireframe or SurfaceWireframe() self.normals = normals or SurfaceNormals() def _calc_data_range(self, mode='data'): return calc_data_range(self.vertex_values) @property def dtype(self): return self.vertex_values.dtype @property def data(self): return (self.vertices, self.faces, self.vertex_values) @data.setter def data(self, data): if len(data) not in (2, 3): raise ValueError( trans._( 'Surface data tuple must be 2 or 3, specifying vertices, faces, and optionally vertex values, instead got length {data_length}.', deferred=True, data_length=len(data), ) ) self._vertices = data[0] self._faces = data[1] if len(data) == 3: self._vertex_values = data[2] else: self._vertex_values = np.ones(len(self._vertices)) self._update_dims() self.events.data(value=self.data) self._reset_editable() if self._keep_auto_contrast: self.reset_contrast_limits() @property def vertices(self): return self._vertices @vertices.setter def vertices(self, vertices): """Array of vertices of mesh triangles.""" self._vertices = vertices self._update_dims() self.events.data(value=self.data) self._reset_editable() @property def vertex_values(self) -> np.ndarray: return self._vertex_values @vertex_values.setter def vertex_values(self, vertex_values: np.ndarray): """Array of values used to color vertices..""" self._vertex_values = vertex_values self._update_dims() self.events.data(value=self.data) self._reset_editable() @property def faces(self) -> np.ndarray: return self._faces @faces.setter def faces(self, faces: np.ndarray): """Array of indices of mesh triangles..""" self.faces = faces self.refresh() self.events.data(value=self.data) self._reset_editable() def _get_ndim(self): """Determine number of dimensions of the layer.""" return self.vertices.shape[1] + (self.vertex_values.ndim - 1) @property def _extent_data(self) -> np.ndarray: """Extent of layer in data coordinates. Returns ------- extent_data : array, shape (2, D) """ if len(self.vertices) == 0: extrema = np.full((2, self.ndim), np.nan) else: maxs = np.max(self.vertices, axis=0) mins = np.min(self.vertices, axis=0) # The full dimensionality and shape of the layer is determined by # the number of additional vertex value dimensions and the # dimensionality of the vertices themselves if self.vertex_values.ndim > 1: mins = [0] * (self.vertex_values.ndim - 1) + list(mins) maxs = list(self.vertex_values.shape[:-1]) + list(maxs) extrema = np.vstack([mins, maxs]) return extrema @property def shading(self): return str(self._shading) @shading.setter def shading(self, shading): if isinstance(shading, Shading): self._shading = shading else: self._shading = Shading(shading) self.events.shading(value=self._shading) def _get_state(self): """Get dictionary of layer state. Returns ------- state : dict Dictionary of layer state. """ state = self._get_base_state() state.update( { 'colormap': self.colormap.name, 'contrast_limits': self.contrast_limits, 'gamma': self.gamma, 'shading': self.shading, 'data': self.data, 'wireframe': self.wireframe.dict(), 'normals': self.normals.dict(), } ) return state def _set_view_slice(self): """Sets the view given the indices to slice with.""" N, vertex_ndim = self.vertices.shape values_ndim = self.vertex_values.ndim - 1 # Take vertex_values dimensionality into account if more than one value # is provided per vertex. if values_ndim > 0: # Get indices for axes corresponding to values dimensions values_indices = self._slice_indices[:-vertex_ndim] values = self.vertex_values[values_indices] if values.ndim > 1: warnings.warn( trans._( "Assigning multiple values per vertex after slicing is not allowed. All dimensions corresponding to vertex_values must be non-displayed dimensions. Data will not be visible.", deferred=True, ) ) self._data_view = np.zeros((0, self._slice_input.ndisplay)) self._view_faces = np.zeros((0, 3)) self._view_vertex_values = [] return self._view_vertex_values = values # Determine which axes of the vertices data are being displayed # and not displayed, ignoring the additional dimensions # corresponding to the vertex_values. indices = np.array(self._slice_indices[-vertex_ndim:]) disp = [ d for d in np.subtract(self._slice_input.displayed, values_ndim) if d >= 0 ] not_disp = [ d for d in np.subtract( self._slice_input.not_displayed, values_ndim ) if d >= 0 ] else: self._view_vertex_values = self.vertex_values indices = np.array(self._slice_indices) not_disp = list(self._slice_input.not_displayed) disp = list(self._slice_input.displayed) self._data_view = self.vertices[:, disp] if len(self.vertices) == 0: self._view_faces = np.zeros((0, 3)) elif vertex_ndim > self._slice_input.ndisplay: vertices = self.vertices[:, not_disp].astype('int') triangles = vertices[self.faces] matches = np.all(triangles == indices[not_disp], axis=(1, 2)) matches = np.where(matches)[0] if len(matches) == 0: self._view_faces = np.zeros((0, 3)) else: self._view_faces = self.faces[matches] else: self._view_faces = self.faces if self._keep_auto_contrast: self.reset_contrast_limits() def _update_thumbnail(self): """Update thumbnail with current surface.""" pass def _get_value(self, position): """Value of the data at a position in data coordinates. Parameters ---------- position : tuple Position in data coordinates. Returns ------- value : None Value of the data at the coord. """ return None def _get_value_3d( self, start_point: np.ndarray, end_point: np.ndarray, dims_displayed: List[int], ) -> Tuple[Union[None, float, int], None]: """Get the layer data value along a ray Parameters ---------- start_point : np.ndarray The start position of the ray used to interrogate the data. end_point : np.ndarray The end position of the ray used to interrogate the data. dims_displayed : List[int] The indices of the dimensions currently displayed in the Viewer. Returns ------- value The data value along the supplied ray. vertex : None Index of vertex if any that is at the coordinates. Always returns `None`. """ if len(dims_displayed) != 3: # only applies to 3D return None, None if (start_point is None) or (end_point is None): # return None if the ray doesn't intersect the data bounding box return None, None start_position, ray_direction = nd_line_segment_to_displayed_data_ray( start_point=start_point, end_point=end_point, dims_displayed=dims_displayed, ) # get the mesh triangles mesh_triangles = self._data_view[self._view_faces] # get the triangles intersection intersection_index, intersection = find_nearest_triangle_intersection( ray_position=start_position, ray_direction=ray_direction, triangles=mesh_triangles, ) if intersection_index is None: return None, None # add the full nD coords to intersection intersection_point = start_point.copy() intersection_point[dims_displayed] = intersection # calculate the value from the intersection triangle_vertex_indices = self._view_faces[intersection_index] triangle_vertices = self._data_view[triangle_vertex_indices] barycentric_coordinates = calculate_barycentric_coordinates( intersection, triangle_vertices ) vertex_values = self._view_vertex_values[triangle_vertex_indices] intersection_value = (barycentric_coordinates * vertex_values).sum() return intersection_value, intersection_index napari-0.5.0a1/napari/layers/surface/wireframe.py000066400000000000000000000010611437041365600217530ustar00rootroot00000000000000from napari.utils.color import ColorValue from napari.utils.events import EventedModel class SurfaceWireframe(EventedModel): """ Wireframe representation of the edges of a surface mesh. Attributes ---------- visible : bool Whether the wireframe is displayed. color : ColorValue The color of the wireframe lines. See ``ColorValue.validate`` for supported values. width : float The width of the wireframe lines. """ visible: bool = False color: ColorValue = 'black' width: float = 1 napari-0.5.0a1/napari/layers/tracks/000077500000000000000000000000001437041365600172615ustar00rootroot00000000000000napari-0.5.0a1/napari/layers/tracks/__init__.py000066400000000000000000000001051437041365600213660ustar00rootroot00000000000000from napari.layers.tracks.tracks import Tracks __all__ = ['Tracks'] napari-0.5.0a1/napari/layers/tracks/_tests/000077500000000000000000000000001437041365600205625ustar00rootroot00000000000000napari-0.5.0a1/napari/layers/tracks/_tests/test_tracks.py000066400000000000000000000166201437041365600234670ustar00rootroot00000000000000import numpy as np import pandas as pd import pytest from napari.layers import Tracks from napari.layers.tracks._track_utils import TrackManager # def test_empty_tracks(): # """Test instantiating Tracks layer without data.""" # pts = Tracks() # assert pts.data.shape == (0, 4) data_array_2dt = np.zeros((1, 4)) data_list_2dt = list(data_array_2dt) dataframe_2dt = pd.DataFrame( data=data_array_2dt, columns=['track_id', 't', 'y', 'x'] ) @pytest.mark.parametrize( "data", [data_array_2dt, data_list_2dt, dataframe_2dt] ) def test_tracks_layer_2dt_ndim(data): """Test instantiating Tracks layer, check 2D+t dimensionality.""" layer = Tracks(data) assert layer.ndim == 3 data_array_3dt = np.zeros((1, 5)) data_list_3dt = list(data_array_3dt) dataframe_3dt = pd.DataFrame( data=data_array_3dt, columns=['track_id', 't', 'z', 'y', 'x'] ) @pytest.mark.parametrize( "data", [data_array_3dt, data_list_3dt, dataframe_3dt] ) def test_tracks_layer_3dt_ndim(data): """Test instantiating Tracks layer, check 3D+t dimensionality.""" layer = Tracks(data) assert layer.ndim == 4 def test_track_layer_name(): """Test track name.""" data = np.zeros((1, 4)) layer = Tracks(data, name='test_tracks') assert layer.name == 'test_tracks' def test_track_layer_data(): """Test data.""" data = np.zeros((100, 4)) data[:, 1] = np.arange(100) layer = Tracks(data) assert np.all(layer.data == data) @pytest.mark.parametrize( "timestamps", [np.arange(100, 200), np.arange(100, 300, 2)] ) def test_track_layer_data_nonzero_starting_time(timestamps): """Test data with sparse timestamps or not starting at zero.""" data = np.zeros((100, 4)) data[:, 1] = timestamps layer = Tracks(data) assert np.all(layer.data == data) def test_track_layer_data_flipped(): """Test data flipped.""" data = np.zeros((100, 4)) data[:, 1] = np.arange(100) data[:, 0] = np.arange(100) data = np.flip(data, axis=0) layer = Tracks(data) assert np.all(layer.data == np.flip(data, axis=0)) properties_dict = {'time': np.arange(100)} properties_df = pd.DataFrame(properties_dict) @pytest.mark.parametrize("properties", [{}, properties_dict, properties_df]) def test_track_layer_properties(properties): """Test properties.""" data = np.zeros((100, 4)) data[:, 1] = np.arange(100) layer = Tracks(data, properties=properties) for k, v in properties.items(): np.testing.assert_equal(layer.properties[k], v) @pytest.mark.parametrize("properties", [{}, properties_dict, properties_df]) def test_track_layer_properties_flipped(properties): """Test properties.""" data = np.zeros((100, 4)) data[:, 1] = np.arange(100) data[:, 0] = np.arange(100) data = np.flip(data, axis=0) layer = Tracks(data, properties=properties) for k, v in properties.items(): np.testing.assert_equal(layer.properties[k], np.flip(v)) @pytest.mark.filterwarnings("ignore:.*track_id.*:UserWarning") def test_track_layer_colorby_nonexistent(): """Test error handling for non-existent properties with color_by""" data = np.zeros((100, 4)) data[:, 1] = np.arange(100) non_existant_property = 'not_a_valid_key' assert non_existant_property not in properties_dict.keys() with pytest.raises(ValueError): Tracks( data, properties=properties_dict, color_by=non_existant_property ) @pytest.mark.filterwarnings("ignore:.*track_id.*:UserWarning") def test_track_layer_properties_changed_colorby(): """Test behaviour when changes to properties invalidate current color_by""" properties_dict_1 = {'time': np.arange(100), 'prop1': np.arange(100)} properties_dict_2 = {'time': np.arange(100), 'prop2': np.arange(100)} data = np.zeros((100, 4)) data[:, 1] = np.arange(100) layer = Tracks(data, properties=properties_dict_1, color_by='prop1') # test warning is raised with pytest.warns(UserWarning): layer.properties = properties_dict_2 # test default fallback assert layer.color_by == 'track_id' def test_track_layer_graph(): """Test track layer graph.""" data = np.zeros((100, 4)) data[:, 1] = np.arange(100) data[50:, 0] = 1 graph = {1: [0]} layer = Tracks(data, graph=graph) assert layer.graph == graph def test_track_layer_reset_data(): """Test changing data once layer is instantiated.""" data = np.zeros((100, 4)) data[:, 1] = np.arange(100) data[50:, 0] = 1 properties = {'time': data[:, 1]} graph = {1: [0]} layer = Tracks(data, graph=graph, properties=properties) cropped_data = data[:10, :] layer.data = cropped_data assert np.all(layer.data == cropped_data) assert layer.graph == {} def test_malformed_id(): """Test for malformed track ID.""" data = np.random.random((100, 4)) data[:, 1] = np.arange(100) with pytest.raises(ValueError): Tracks(data) def test_malformed_graph(): """Test for malformed graph.""" data = np.zeros((100, 4)) data[:, 1] = np.arange(100) data[50:, 0] = 1 graph = {1: [0], 2: [33]} with pytest.raises(ValueError): Tracks(data, graph=graph) def test_tracks_float_time_index(): """Test Tracks layer instantiation with floating point time values""" coords = np.random.normal(loc=50, size=(100, 2)) time = np.random.normal(loc=50, size=(100, 1)) track_id = np.zeros((100, 1)) track_id[50:] = 1 data = np.concatenate((track_id, time, coords), axis=1) Tracks(data) def test_tracks_length_change(): """Test changing length properties of tracks""" track_length = 1000 data = np.zeros((track_length, 4)) layer = Tracks(data) layer.tail_length = track_length assert layer.tail_length == track_length assert layer._max_length == track_length layer = Tracks(data) layer.head_length = track_length assert layer.head_length == track_length assert layer._max_length == track_length def test_fast_points_lookup() -> None: # creates sorted points time_points = np.asarray([0, 1, 3, 5, 10]) repeats = np.asarray([3, 4, 6, 3, 5]) sorted_time = np.repeat(time_points, repeats) end = np.cumsum(repeats) start = np.insert(end[:-1], 0, 0) # compute lookup points_lookup = TrackManager._fast_points_lookup(sorted_time) assert len(time_points) == len(points_lookup) total_length = 0 for s, e, t, r in zip(start, end, time_points, repeats): assert points_lookup[t].start == s assert points_lookup[t].stop == e assert points_lookup[t].stop - points_lookup[t].start == r unique_time = sorted_time[points_lookup[t]] assert np.all(unique_time[0] == unique_time) total_length += len(unique_time) assert total_length == len(sorted_time) def test_single_time_tracks() -> None: """Edge case where all tracks belong to a single time""" # track_id, t, y, x tracks = [[0, 5, 2, 3], [1, 5, 3, 4], [2, 5, 4, 5]] layer = Tracks(tracks) assert np.all(layer.data == tracks) def test_track_ids_ordering() -> None: """Check if tracks ids are correctly set to features when given not-sorted tracks.""" # track_id, t, y, x unsorted_data = np.asarray( [[1, 1, 0, 0], [0, 1, 0, 0], [2, 0, 0, 0], [0, 0, 0, 0], [1, 0, 0, 0]] ) sorted_track_ids = [0, 0, 1, 1, 2] # track_ids after sorting layer = Tracks(unsorted_data) assert np.all(sorted_track_ids == layer.features["track_id"]) napari-0.5.0a1/napari/layers/tracks/_track_utils.py000066400000000000000000000343731437041365600223300ustar00rootroot00000000000000from typing import Dict, List, Union import numpy as np import pandas as pd from scipy.sparse import coo_matrix from scipy.spatial import cKDTree from napari.layers.utils.layer_utils import _FeatureTable from napari.utils.events.custom_types import Array from napari.utils.translations import trans def connex(vertices: np.ndarray) -> list: """Connection array to build vertex edges for vispy LineVisual. Notes ----- See http://api.vispy.org/en/latest/visuals.html#vispy.visuals.LineVisual """ return [True] * (vertices.shape[0] - 1) + [False] class TrackManager: """Manage track data and simplify interactions with the Tracks layer. Attributes ---------- data : array (N, D+1) Coordinates for N points in D+1 dimensions. ID,T,(Z),Y,X. The first axis is the integer ID of the track. D is either 3 or 4 for planar or volumetric timeseries respectively. features : Dataframe-like Features table where each row corresponds to a point and each column is a feature. properties : dict {str: array (N,)}, DataFrame Properties for each point. Each property should be an array of length N, where N is the number of points. graph : dict {int: list} Graph representing associations between tracks. Dictionary defines the mapping between a track ID and the parents of the track. This can be one (the track has one parent, and the parent has >=1 child) in the case of track splitting, or more than one (the track has multiple parents, but only one child) in the case of track merging. See examples/tracks_3d_with_graph.py ndim : int Number of spatiotemporal dimensions of the data. max_time: float, int Maximum value of timestamps in data. track_vertices : array (N, D) Vertices for N points in D dimensions. T,(Z),Y,X track_connex : array (N,) Connection array specifying consecutive vertices that are linked to form the tracks. Boolean track_times : array (N,) Timestamp for each vertex in track_vertices. graph_vertices : array (N, D) Vertices for N points in D dimensions. T,(Z),Y,X graph_connex : array (N,) Connection array specifying consecutive vertices that are linked to form the graph. graph_times : array (N,) Timestamp for each vertex in graph_vertices. track_ids : array (N,) Track ID for each vertex in track_vertices. """ def __init__(self) -> None: # store the raw data here self._data = None self._feature_table = _FeatureTable() self._order = None # use a kdtree to help with fast lookup of the nearest track self._kdtree = None # NOTE(arl): _tracks and _connex store raw data for vispy self._points = None self._points_id = None self._points_lookup = None self._ordered_points_idx = None self._track_vertices = None self._track_connex = None self._graph = None self._graph_vertices = None self._graph_connex = None # lookup table for vertex indices from track id self._id2idxs = None @staticmethod def _fast_points_lookup(sorted_time: np.ndarray) -> Dict[int, slice]: """Computes a fast lookup table from time to their respective points slicing.""" # finds where t transitions to t + 1 transitions = np.nonzero(sorted_time[:-1] - sorted_time[1:])[0] + 1 start = np.insert(transitions, 0, 0) # compute end of slice end = np.roll(start, -1) end[-1] = len(sorted_time) # access first position of each t slice time = sorted_time[start] return {t: slice(s, e) for s, e, t in zip(start, end, time)} @property def data(self) -> np.ndarray: """array (N, D+1): Coordinates for N points in D+1 dimensions.""" return self._data @data.setter def data(self, data: Union[list, np.ndarray]): """set the vertex data and build the vispy arrays for display""" # convert data to a numpy array if it is not already one data = np.asarray(data) # check check the formatting of the incoming track data data = self._validate_track_data(data) # Sort data by ID then time self._order = np.lexsort((data[:, 1], data[:, 0])) self._data = data[self._order] # build the indices for sorting points by time self._ordered_points_idx = np.argsort(self.data[:, 1]) self._points = self.data[self._ordered_points_idx, 1:] # build a tree of the track data to allow fast lookup of nearest track self._kdtree = cKDTree(self._points) # make the lookup table # NOTE(arl): it's important to convert the time index to an integer # here to make sure that we align with the napari dims index which # will be an integer - however, the time index does not necessarily # need to be an int, and the shader will render correctly. time = np.round(self._points[:, 0]).astype(np.uint) self._points_lookup = self._fast_points_lookup(time) # make a second lookup table using a sparse matrix to convert track id # to the vertex indices self._id2idxs = coo_matrix( ( np.broadcast_to(1, self.track_ids.size), # just dummy ones (self.track_ids, np.arange(self.track_ids.size)), ) ).tocsr() @property def features(self): """Dataframe-like features table. It is an implementation detail that this is a `pandas.DataFrame`. In the future, we will target the currently-in-development Data API dataframe protocol [1]. This will enable us to use alternate libraries such as xarray or cuDF for additional features without breaking existing usage of this. If you need to specifically rely on the pandas API, please coerce this to a `pandas.DataFrame` using `features_to_pandas_dataframe`. References ---------- .. [1]: https://data-apis.org/dataframe-protocol/latest/API.html """ return self._feature_table.values @features.setter def features( self, features: Union[Dict[str, np.ndarray], pd.DataFrame], ) -> None: self._feature_table.set_values(features, num_data=len(self.data)) self._feature_table.reorder(self._order) if 'track_id' not in self._feature_table.values: self._feature_table.values['track_id'] = self.track_ids @property def properties(self) -> Dict[str, np.ndarray]: """dict {str: np.ndarray (N,)}: Properties for each track.""" return self._feature_table.properties() @properties.setter def properties(self, properties: Dict[str, Array]): """set track properties""" self.features = properties @property def graph(self) -> Dict[int, Union[int, List[int]]]: """dict {int: list}: Graph representing associations between tracks.""" return self._graph @graph.setter def graph(self, graph: Dict[int, Union[int, List[int]]]): """set the track graph""" self._graph = self._validate_track_graph(graph) @property def track_ids(self): """return the track identifiers""" return self.data[:, 0].astype(np.uint32) @property def unique_track_ids(self): """return the unique track identifiers""" return np.unique(self.track_ids) def __len__(self): """return the number of tracks""" return len(self.unique_track_ids) if self.data is not None else 0 def _vertex_indices_from_id(self, track_id: int): """return the vertices corresponding to a track id""" return self._id2idxs[track_id].nonzero()[1] def _validate_track_data(self, data: np.ndarray) -> np.ndarray: """validate the coordinate data""" if data.ndim != 2: raise ValueError( trans._('track vertices should be a NxD array', deferred=True) ) if data.shape[1] < 4 or data.shape[1] > 5: raise ValueError( trans._( 'track vertices should be 4 or 5-dimensional', deferred=True, ) ) # check that all IDs are integers ids = data[:, 0] if not np.all(np.floor(ids) == ids): raise ValueError( trans._('track id must be an integer', deferred=True) ) if not all([t >= 0 for t in data[:, 1]]): raise ValueError( trans._( 'track timestamps must be greater than zero', deferred=True ) ) return data def _validate_track_graph( self, graph: Dict[int, Union[int, List[int]]] ) -> Dict[int, List[int]]: """validate the track graph""" # check that graph nodes are of correct format for node_idx, parents_idx in graph.items(): # make sure parents are always a list if type(parents_idx) != list: graph[node_idx] = [parents_idx] unique_track_ids = set(self.unique_track_ids) # check that graph nodes exist in the track id lookup for node_idx, parents_idx in graph.items(): nodes = [node_idx] + parents_idx for node in nodes: if node not in unique_track_ids: raise ValueError( trans._( 'graph node {node_idx} not found', deferred=True, node_idx=node_idx, ) ) return graph def build_tracks(self): """build the tracks""" points_id = [] track_vertices = [] track_connex = [] # NOTE(arl): this takes some time when the number of tracks is large for idx in self.unique_track_ids: indices = self._vertex_indices_from_id(idx) # grab the correct vertices and sort by time vertices = self.data[indices, 1:] # coordinates of the text identifiers, vertices and connections points_id += [idx] * vertices.shape[0] track_vertices.append(vertices) track_connex.append(connex(vertices)) self._points_id = np.array(points_id)[self._ordered_points_idx] self._track_vertices = np.concatenate(track_vertices, axis=0) self._track_connex = np.concatenate(track_connex, axis=0) def build_graph(self): """build the track graph""" graph_vertices = [] graph_connex = [] for node_idx, parents_idx in self.graph.items(): # we join from the first observation of the node, to the last # observation of the parent node_start = self._vertex_indices_from_id(node_idx)[0] node = self.data[node_start, 1:] for parent_idx in parents_idx: parent_stop = self._vertex_indices_from_id(parent_idx)[-1] parent = self.data[parent_stop, 1:] graph_vertices.append([node, parent]) graph_connex.append([True, False]) # if there is a graph, store the vertices and connection arrays, # otherwise, clear the vertex arrays if graph_vertices: self._graph_vertices = np.concatenate(graph_vertices, axis=0) self._graph_connex = np.concatenate(graph_connex, axis=0) else: self._graph_vertices = None self._graph_connex = None def vertex_properties(self, color_by: str) -> np.ndarray: """return the properties of tracks by vertex""" if color_by not in self.properties: raise ValueError( trans._( 'Property {color_by} not found', deferred=True, color_by=color_by, ) ) return self.properties[color_by] def get_value(self, coords): """use a kd-tree to lookup the ID of the nearest tree""" if self._kdtree is None: return # query can return indices to points that do not exist, trim that here # then prune to only those in the current frame/time # NOTE(arl): I don't like this!!! d, idx = self._kdtree.query(coords, k=10) idx = [i for i in idx if i >= 0 and i < self._points.shape[0]] pruned = [i for i in idx if self._points[i, 0] == coords[0]] # if we have found a point, return it if pruned and self._points_id is not None: return self._points_id[pruned[0]] # return the track ID @property def ndim(self) -> int: """Determine number of spatiotemporal dimensions of the layer.""" return self.data.shape[1] - 1 @property def max_time(self) -> int: """Determine the maximum timestamp of the dataset""" return int(np.max(self.track_times)) @property def track_vertices(self) -> np.ndarray: """return the track vertices""" return self._track_vertices @property def track_connex(self) -> np.ndarray: """vertex connections for drawing track lines""" return self._track_connex @property def graph_vertices(self) -> np.ndarray: """return the graph vertices""" return self._graph_vertices @property def graph_connex(self): """vertex connections for drawing the graph""" return self._graph_connex @property def track_times(self) -> np.ndarray: """time points associated with each track vertex""" return self.track_vertices[:, 0] @property def graph_times(self) -> np.ndarray: """time points associated with each graph vertex""" if self.graph_vertices is not None: return self.graph_vertices[:, 0] return None def track_labels(self, current_time: int) -> tuple: """return track labels at the current time""" # this is the slice into the time ordered points array if current_time not in self._points_lookup: return [], [] lookup = self._points_lookup[current_time] pos = self._points[lookup, ...] lbl = [f'ID:{i}' for i in self._points_id[lookup]] return lbl, pos napari-0.5.0a1/napari/layers/tracks/_tracks_key_bindings.py000066400000000000000000000015731437041365600240140ustar00rootroot00000000000000from napari.layers.base._base_constants import Mode from napari.layers.tracks.tracks import Tracks from napari.layers.utils.layer_utils import ( register_layer_action, register_layer_attr_action, ) from napari.utils.translations import trans def register_tracks_action(description: str, repeatable: bool = False): return register_layer_action(Tracks, description, repeatable) def register_tracks_mode_action(description): return register_layer_attr_action(Tracks, description, 'mode') @register_tracks_mode_action(trans._('Transform')) def activate_tracks_transform_mode(layer): layer.mode = Mode.TRANSFORM @register_tracks_mode_action(trans._('Pan/zoom')) def activate_tracks_pan_zoom_mode(layer): layer.mode = Mode.PAN_ZOOM tracks_fun_to_mode = [ (activate_tracks_pan_zoom_mode, Mode.PAN_ZOOM), (activate_tracks_transform_mode, Mode.TRANSFORM), ] napari-0.5.0a1/napari/layers/tracks/tracks.py000066400000000000000000000520321437041365600211240ustar00rootroot00000000000000# from napari.layers.base.base import Layer # from napari.utils.events import Event # from napari.utils.colormaps import AVAILABLE_COLORMAPS from typing import Dict, List, Union from warnings import warn import numpy as np import pandas as pd from napari.layers.base import Layer from napari.layers.tracks._track_utils import TrackManager from napari.utils.colormaps import AVAILABLE_COLORMAPS, Colormap from napari.utils.events import Event from napari.utils.translations import trans class Tracks(Layer): """Tracks layer. Parameters ---------- data : array (N, D+1) Coordinates for N points in D+1 dimensions. ID,T,(Z),Y,X. The first axis is the integer ID of the track. D is either 3 or 4 for planar or volumetric timeseries respectively. features : Dataframe-like Features table where each row corresponds to a point and each column is a feature. properties : dict {str: array (N,)}, DataFrame Properties for each point. Each property should be an array of length N, where N is the number of points. graph : dict {int: list} Graph representing associations between tracks. Dictionary defines the mapping between a track ID and the parents of the track. This can be one (the track has one parent, and the parent has >=1 child) in the case of track splitting, or more than one (the track has multiple parents, but only one child) in the case of track merging. See examples/tracks_3d_with_graph.py color_by : str Track property (from property keys) by which to color vertices. tail_width : float Width of the track tails in pixels. tail_length : float Length of the positive (backward in time) tails in units of time. head_length : float Length of the positive (forward in time) tails in units of time. colormap : str Default colormap to use to set vertex colors. Specialized colormaps, relating to specified properties can be passed to the layer via colormaps_dict. colormaps_dict : dict {str: napari.utils.Colormap} Optional dictionary mapping each property to a colormap for that property. This allows each property to be assigned a specific colormap, rather than having a global colormap for everything. name : str Name of the layer. metadata : dict Layer metadata. scale : tuple of float Scale factors for the layer. translate : tuple of float Translation values for the layer. rotate : float, 3-tuple of float, or n-D array. If a float convert into a 2D rotation matrix using that value as an angle. If 3-tuple convert into a 3D rotation matrix, using a yaw, pitch, roll convention. Otherwise assume an nD rotation. Angles are assumed to be in degrees. They can be converted from radians with np.degrees if needed. shear : 1-D array or n-D array Either a vector of upper triangular values, or an nD shear matrix with ones along the main diagonal. affine : n-D array or napari.utils.transforms.Affine (N+1, N+1) affine transformation matrix in homogeneous coordinates. The first (N, N) entries correspond to a linear transform and the final column is a length N translation vector and a 1 or a napari `Affine` transform object. Applied as an extra transform on top of the provided scale, rotate, and shear values. opacity : float Opacity of the layer visual, between 0.0 and 1.0. blending : str One of a list of preset blending modes that determines how RGB and alpha values of the layer visual get mixed. Allowed values are {'opaque', 'translucent', and 'additive'}. visible : bool Whether the layer visual is currently being displayed. cache : bool Whether slices of out-of-core datasets should be cached upon retrieval. Currently, this only applies to dask arrays. """ # The max number of tracks that will ever be used to render the thumbnail # If more tracks are present then they are randomly subsampled _max_tracks_thumbnail = 1024 def __init__( self, data, *, features=None, properties=None, graph=None, tail_width=2, tail_length=30, head_length=0, name=None, metadata=None, scale=None, translate=None, rotate=None, shear=None, affine=None, opacity=1, blending='additive', visible=True, colormap='turbo', color_by='track_id', colormaps_dict=None, cache=True, experimental_clipping_planes=None, ) -> None: # if not provided with any data, set up an empty layer in 2D+t if data is None: data = np.empty((0, 4)) else: # convert data to a numpy array if it is not already one data = np.asarray(data) # set the track data dimensions (remove ID from data) ndim = data.shape[1] - 1 super().__init__( data, ndim, name=name, metadata=metadata, scale=scale, translate=translate, rotate=rotate, shear=shear, affine=affine, opacity=opacity, blending=blending, visible=visible, cache=cache, experimental_clipping_planes=experimental_clipping_planes, ) self.events.add( tail_width=Event, tail_length=Event, head_length=Event, display_id=Event, display_tail=Event, display_graph=Event, color_by=Event, colormap=Event, properties=Event, rebuild_tracks=Event, rebuild_graph=Event, ) # track manager deals with data slicing, graph building and properties self._manager = TrackManager() self._track_colors = None self._colormaps_dict = colormaps_dict or {} # additional colormaps self._color_by = color_by # default color by ID self._colormap = colormap # use this to update shaders when the displayed dims change self._current_displayed_dims = None # track display default limits self._max_length = 300 self._max_width = 20 # track display properties self.tail_width = tail_width self.tail_length = tail_length self.head_length = head_length self.display_id = False self.display_tail = True self.display_graph = True # set the data, features, and graph self.data = data if properties is not None: self.properties = properties else: self.features = features self.graph = graph or {} self.color_by = color_by self.colormap = colormap self.refresh() # reset the display before returning self._current_displayed_dims = None @property def _extent_data(self) -> np.ndarray: """Extent of layer in data coordinates. Returns ------- extent_data : array, shape (2, D) """ if len(self.data) == 0: extrema = np.full((2, self.ndim), np.nan) else: maxs = np.max(self.data, axis=0) mins = np.min(self.data, axis=0) extrema = np.vstack([mins, maxs]) return extrema[:, 1:] def _get_ndim(self) -> int: """Determine number of dimensions of the layer.""" return self._manager.ndim def _get_state(self): """Get dictionary of layer state. Returns ------- state : dict Dictionary of layer state. """ state = self._get_base_state() state.update( { 'data': self.data, 'properties': self.properties, 'graph': self.graph, 'color_by': self.color_by, 'colormap': self.colormap, 'colormaps_dict': self.colormaps_dict, 'tail_width': self.tail_width, 'tail_length': self.tail_length, 'head_length': self.head_length, 'features': self.features, } ) return state def _set_view_slice(self): """Sets the view given the indices to slice with.""" # if the displayed dims have changed, update the shader data dims_displayed = self._slice_input.displayed if dims_displayed != self._current_displayed_dims: # store the new dims self._current_displayed_dims = dims_displayed # fire the events to update the shaders self.events.rebuild_tracks() self.events.rebuild_graph() return def _get_value(self, position) -> int: """Value of the data at a position in data coordinates. Use a kd-tree to lookup the ID of the nearest tree. Parameters ---------- position : tuple Position in data coordinates. Returns ------- value : int or None Index of track that is at the current coordinate if any. """ return self._manager.get_value(np.array(position)) def _update_thumbnail(self): """Update thumbnail with current points and colors.""" colormapped = np.zeros(self._thumbnail_shape) colormapped[..., 3] = 1 if self._view_data is not None and self.track_colors is not None: de = self._extent_data min_vals = [de[0, i] for i in self._slice_input.displayed] shape = np.ceil( [de[1, i] - de[0, i] + 1 for i in self._slice_input.displayed] ).astype(int) zoom_factor = np.divide( self._thumbnail_shape[:2], shape[-2:] ).min() if len(self._view_data) > self._max_tracks_thumbnail: thumbnail_indices = np.random.randint( 0, len(self._view_data), self._max_tracks_thumbnail ) points = self._view_data[thumbnail_indices] else: points = self._view_data thumbnail_indices = range(len(self._view_data)) # get the track coords here coords = np.floor( (points[:, :2] - min_vals[1:] + 0.5) * zoom_factor ).astype(int) coords = np.clip( coords, 0, np.subtract(self._thumbnail_shape[:2], 1) ) # modulate track colors as per colormap/current_time colors = self.track_colors[thumbnail_indices] times = self.track_times[thumbnail_indices] alpha = (self.head_length + self.current_time - times) / ( self.tail_length + self.head_length ) alpha[times > self.current_time] = 1.0 colors[:, -1] = np.clip(1.0 - alpha, 0.0, 1.0) colormapped[coords[:, 1], coords[:, 0]] = colors colormapped[..., 3] *= self.opacity self.thumbnail = colormapped @property def _view_data(self): """return a view of the data""" return self._pad_display_data(self._manager.track_vertices) @property def _view_graph(self): """return a view of the graph""" return self._pad_display_data(self._manager.graph_vertices) def _pad_display_data(self, vertices): """pad display data when moving between 2d and 3d""" if vertices is None: return data = vertices[:, self._slice_input.displayed] # if we're only displaying two dimensions, then pad the display dim # with zeros if self._slice_input.ndisplay == 2: data = np.pad(data, ((0, 0), (0, 1)), 'constant') return data[:, (1, 0, 2)] # y, x, z -> x, y, z else: return data[:, (2, 1, 0)] # z, y, x -> x, y, z @property def current_time(self): """current time according to the first dimension""" # TODO(arl): get the correct index here time_step = self._slice_indices[0] if isinstance(time_step, slice): # if we are visualizing all time, then just set to the maximum # timestamp of the dataset return self._manager.max_time return time_step @property def use_fade(self) -> bool: """toggle whether we fade the tail of the track, depending on whether the time dimension is displayed""" return 0 in self._slice_input.not_displayed @property def data(self) -> np.ndarray: """array (N, D+1): Coordinates for N points in D+1 dimensions.""" return self._manager.data @data.setter def data(self, data: np.ndarray): """set the data and build the vispy arrays for display""" # set the data and build the tracks self._manager.data = data self._manager.build_tracks() # reset the properties and recolor the tracks self.features = {} self._recolor_tracks() # reset the graph self._manager.graph = {} self._manager.build_graph() # fire events to update shaders self._update_dims() self.events.rebuild_tracks() self.events.rebuild_graph() self.events.data(value=self.data) self._reset_editable() @property def features(self): """Dataframe-like features table. It is an implementation detail that this is a `pandas.DataFrame`. In the future, we will target the currently-in-development Data API dataframe protocol [1]. This will enable us to use alternate libraries such as xarray or cuDF for additional features without breaking existing usage of this. If you need to specifically rely on the pandas API, please coerce this to a `pandas.DataFrame` using `features_to_pandas_dataframe`. References ---------- .. [1]: https://data-apis.org/dataframe-protocol/latest/API.html """ return self._manager.features @features.setter def features( self, features: Union[Dict[str, np.ndarray], pd.DataFrame], ) -> None: self._manager.features = features self.events.properties() self._check_color_by_in_features() @property def properties(self) -> Dict[str, np.ndarray]: """dict {str: np.ndarray (N,)}: Properties for each track.""" return self._manager.properties @property def properties_to_color_by(self) -> List[str]: """track properties that can be used for coloring etc...""" return list(self.properties.keys()) @properties.setter def properties(self, properties: Dict[str, np.ndarray]): """set track properties""" self.features = properties @property def graph(self) -> Dict[int, Union[int, List[int]]]: """dict {int: list}: Graph representing associations between tracks.""" return self._manager.graph @graph.setter def graph(self, graph: Dict[int, Union[int, List[int]]]): """Set the track graph.""" self._manager.graph = graph self._manager.build_graph() self.events.rebuild_graph() @property def tail_width(self) -> Union[int, float]: """float: Width for all vectors in pixels.""" return self._tail_width @tail_width.setter def tail_width(self, tail_width: Union[int, float]): self._tail_width = np.clip(tail_width, 0.5, self._max_width) self.events.tail_width() @property def tail_length(self) -> Union[int, float]: """float: Width for all vectors in pixels.""" return self._tail_length @tail_length.setter def tail_length(self, tail_length: Union[int, float]): if tail_length > self._max_length: self._max_length = tail_length self._tail_length = tail_length self.events.tail_length() @property def head_length(self) -> Union[int, float]: return self._head_length @head_length.setter def head_length(self, head_length: Union[int, float]): if head_length > self._max_length: self._max_length = head_length self._head_length = head_length self.events.head_length() @property def display_id(self) -> bool: """display the track id""" return self._display_id @display_id.setter def display_id(self, value: bool): self._display_id = value self.events.display_id() self.refresh() @property def display_tail(self) -> bool: """display the track tail""" return self._display_tail @display_tail.setter def display_tail(self, value: bool): self._display_tail = value self.events.display_tail() @property def display_graph(self) -> bool: """display the graph edges""" return self._display_graph @display_graph.setter def display_graph(self, value: bool): self._display_graph = value self.events.display_graph() @property def color_by(self) -> str: return self._color_by @color_by.setter def color_by(self, color_by: str): """set the property to color vertices by""" if color_by not in self.properties_to_color_by: raise ValueError( trans._( '{color_by} is not a valid property key', deferred=True, color_by=color_by, ) ) self._color_by = color_by self._recolor_tracks() self.events.color_by() @property def colormap(self) -> str: return self._colormap @colormap.setter def colormap(self, colormap: str): """set the default colormap""" if colormap not in AVAILABLE_COLORMAPS: raise ValueError( trans._( 'Colormap {colormap} not available', deferred=True, colormap=colormap, ) ) self._colormap = colormap self._recolor_tracks() self.events.colormap() @property def colormaps_dict(self) -> Dict[str, Colormap]: return self._colormaps_dict @colormaps_dict.setter def colomaps_dict(self, colormaps_dict: Dict[str, Colormap]): # validate the dictionary entries? self._colormaps_dict = colormaps_dict def _recolor_tracks(self): """recolor the tracks""" # this catch prevents a problem coloring the tracks if the data is # updated before the properties are. properties should always contain # a track_id key if self.color_by not in self.properties_to_color_by: self._color_by = 'track_id' self.events.color_by() # if we change the coloring, rebuild the vertex colors array vertex_properties = self._manager.vertex_properties(self.color_by) def _norm(p): return (p - np.min(p)) / np.max([1e-10, np.ptp(p)]) if self.color_by in self.colormaps_dict: colormap = self.colormaps_dict[self.color_by] else: # if we don't have a colormap, get one and scale the properties colormap = AVAILABLE_COLORMAPS[self.colormap] vertex_properties = _norm(vertex_properties) # actually set the vertex colors self._track_colors = colormap.map(vertex_properties) @property def track_connex(self) -> np.ndarray: """vertex connections for drawing track lines""" return self._manager.track_connex @property def track_colors(self) -> np.ndarray: """return the vertex colors according to the currently selected property""" return self._track_colors @property def graph_connex(self) -> np.ndarray: """vertex connections for drawing the graph""" return self._manager.graph_connex @property def track_times(self) -> np.ndarray: """time points associated with each track vertex""" return self._manager.track_times @property def graph_times(self) -> np.ndarray: """time points associated with each graph vertex""" return self._manager.graph_times @property def track_labels(self) -> tuple: """return track labels at the current time""" labels, positions = self._manager.track_labels(self.current_time) # if there are no labels, return empty for vispy if not labels: return None, (None, None) padded_positions = self._pad_display_data(positions) return labels, padded_positions def _check_color_by_in_features(self): if self._color_by not in self.features.columns: warn( ( trans._( "Previous color_by key {key!r} not present in features. Falling back to track_id", deferred=True, key=self._color_by, ) ), UserWarning, ) self._color_by = 'track_id' self.events.color_by() napari-0.5.0a1/napari/layers/utils/000077500000000000000000000000001437041365600171325ustar00rootroot00000000000000napari-0.5.0a1/napari/layers/utils/__init__.py000066400000000000000000000000001437041365600212310ustar00rootroot00000000000000napari-0.5.0a1/napari/layers/utils/_color_manager_constants.py000066400000000000000000000005751437041365600245560ustar00rootroot00000000000000from enum import Enum class ColorMode(str, Enum): """ ColorMode: Color setting mode. DIRECT (default mode) allows each point to be set arbitrarily CYCLE allows the color to be set via a color cycle over an attribute COLORMAP allows color to be set via a color map over an attribute """ DIRECT = 'direct' CYCLE = 'cycle' COLORMAP = 'colormap' napari-0.5.0a1/napari/layers/utils/_link_layers.py000066400000000000000000000223011437041365600221550ustar00rootroot00000000000000from __future__ import annotations from contextlib import contextmanager from functools import partial from itertools import combinations, permutations, product from typing import TYPE_CHECKING, Callable, DefaultDict, Iterable, Set, Tuple from weakref import ReferenceType, ref if TYPE_CHECKING: from napari.layers import Layer from napari.utils.events.event import WarningEmitter from napari.utils.translations import trans #: Record of already linked layers... to avoid duplicating callbacks # in the form of {(id(layer1), id(layer2), attribute_name) -> callback} LinkKey = Tuple['ReferenceType[Layer]', 'ReferenceType[Layer]', str] Unlinker = Callable[[], None] _UNLINKERS: dict[LinkKey, Unlinker] = dict() _LINKED_LAYERS: DefaultDict[ ReferenceType[Layer], Set[ReferenceType[Layer]] ] = DefaultDict(set) def layer_is_linked(layer: Layer) -> bool: """Return True if `layer` is linked to any other layers.""" return ref(layer) in _LINKED_LAYERS def get_linked_layers(*layers: Layer) -> Set[Layer]: """Return layers that are linked to any layer in `*layers`. Note, if multiple layers are provided, the returned set will represent any layer that is linked to any one of the input layers. They may not all be directly linked to each other. This is useful for context menu generation. """ if not layers: return {} refs = set.union(*(_LINKED_LAYERS.get(ref(x), set()) for x in layers)) return {x() for x in refs if x() is not None} def link_layers( layers: Iterable[Layer], attributes: Iterable[str] = () ) -> list[LinkKey]: """Link ``attributes`` between all layers in ``layers``. This essentially performs the following operation: .. code-block:: python for lay1, lay2 in permutations(layers, 2): for attr in attributes: lay1.events..connect(_set_lay2_) Recursion is prevented by checking for value equality prior to setting. Parameters ---------- layers : Iterable[napari.layers.Layer] The set of layers to link attributes : Iterable[str], optional The set of attributes to link. If not provided (the default), *all*, event-providing attributes that are common to all ``layers`` will be linked. Returns ------- links: list of (int, int, str) keys The links created during execution of the function. The first two elements of each tuple are the ids of the two layers, and the last element is the linked attribute. Raises ------ ValueError If any of the attributes provided are not valid "event-emitting" attributes, or are not shared by all of the layers provided. Examples -------- >>> data = np.random.rand(3, 64, 64) >>> viewer = napari.view_image(data, channel_axis=0) >>> link_layers(viewer.layers) # doctest: +SKIP """ from napari.utils.misc import pick_equality_operator valid_attrs = _get_common_evented_attributes(layers) # now, ensure that the attributes requested are valid attr_set = set(attributes) if attributes: extra = attr_set - valid_attrs if extra: raise ValueError( trans._( "Cannot link attributes that are not shared by all layers: {extra}. Allowable attrs include:\n{valid_attrs}", deferred=True, extra=extra, valid_attrs=valid_attrs, ) ) else: # if no attributes are specified, ALL valid attributes are linked. attr_set = valid_attrs # now, connect requested attributes between all requested layers. links = [] for (lay1, lay2), attribute in product(permutations(layers, 2), attr_set): key = _link_key(lay1, lay2, attribute) # if the layers and attribute are already linked then ignore if key in _UNLINKERS: continue def _make_l2_setter(l1=lay1, l2=lay2, attr=attribute): # get a suitable equality operator for this attribute type eq_op = pick_equality_operator(getattr(l1, attr)) def setter(event=None): new_val = getattr(l1, attr) # this line is the important part for avoiding recursion if not eq_op(getattr(l2, attr), new_val): setattr(l2, attr, new_val) setter.__doc__ = f"Set {attr!r} on {l1} to that of {l2}" setter.__qualname__ = f"set_{attr}_on_layer_{id(l2)}" return setter # acually make the connection callback = _make_l2_setter() emitter_group = getattr(lay1.events, attribute) emitter_group.connect(callback) # store the connection so that we don't make it again. # and save an "unlink" function for the key. _UNLINKERS[key] = partial(emitter_group.disconnect, callback) _LINKED_LAYERS[ref(lay1)].add(ref(lay2)) links.append(key) return links def unlink_layers(layers: Iterable[Layer], attributes: Iterable[str] = ()): """Unlink previously linked ``attributes`` between all layers in ``layers``. Parameters ---------- layers : Iterable[napari.layers.Layer] The list of layers to unlink. All combinations of layers provided will be unlinked. If a single layer is provided, it will be unlinked from all other layers. attributes : Iterable[str], optional The set of attributes to unlink. If not provided, all connections between the provided layers will be unlinked. """ if not layers: raise ValueError( trans._("Must provide at least one layer to unlink", deferred=True) ) layer_refs = [ref(layer) for layer in layers] if len(layer_refs) == 1: # If a single layer was provided, find all keys that include that layer # in either the first or second position keys = (k for k in list(_UNLINKERS) if layer_refs[0] in k[:2]) else: # otherwise, first find all combinations of layers provided layer_combos = {frozenset(i) for i in combinations(layer_refs, 2)} # then find all keys that include that combination keys = (k for k in list(_UNLINKERS) if set(k[:2]) in layer_combos) if attributes: # if attributes were provided, further restrict the keys to those # that include that attribute keys = (k for k in keys if k[2] in attributes) _unlink_keys(keys) @contextmanager def layers_linked(layers: Iterable[Layer], attributes: Iterable[str] = ()): """Context manager that temporarily links ``attributes`` on ``layers``.""" links = link_layers(layers, attributes) try: yield finally: _unlink_keys(links) def _get_common_evented_attributes( layers: Iterable[Layer], exclude: set[str] = frozenset( ('thumbnail', 'status', 'name', 'data', 'extent') ), with_private=False, ) -> set[str]: """Get the set of common, non-private evented attributes in ``layers``. Not all layer events are attributes, and not all attributes have corresponding events. Here we get the set of valid, non-private attributes that are both events and attributes for the provided layer set. Parameters ---------- layers : iterable A set of layers to evaluate for attribute linking. exclude : set, optional Layer attributes that make no sense to link, or may error on changing. {'thumbnail', 'status', 'name', 'data'} with_private : bool, optional include private attributes Returns ------- names : set of str A set of attribute names that may be linked between ``layers``. """ from inspect import ismethod try: first_layer = next(iter(layers)) except StopIteration: raise ValueError( trans._( "``layers`` iterable must have at least one layer", deferred=True, ) ) from None layer_events = [ { e for e in lay.events if not isinstance(lay.events[e], WarningEmitter) } for lay in layers ] common_events = set.intersection(*layer_events) common_attrs = set.intersection(*(set(dir(lay)) for lay in layers)) if not with_private: common_attrs = {x for x in common_attrs if not x.startswith("_")} common = common_events & common_attrs - exclude # lastly, discard any method-only events (we just want attrs) for attr in set(common_attrs): # properties do not count as methods and will not be excluded if ismethod(getattr(first_layer.__class__, attr, None)): common.discard(attr) return common def _link_key(lay1: Layer, lay2: Layer, attr: str) -> LinkKey: """Generate a "link key" for these layers and attribute.""" return (ref(lay1), ref(lay2), attr) def _unlink_keys(keys: Iterable[LinkKey]): """Disconnect layer linkages by keys.""" for key in keys: disconnecter = _UNLINKERS.pop(key, None) if disconnecter: disconnecter() global _LINKED_LAYERS _LINKED_LAYERS = _rebuild_link_index() def _rebuild_link_index(): links = DefaultDict(set) for l1, l2, _attr in _UNLINKERS: links[l1].add(l2) return links napari-0.5.0a1/napari/layers/utils/_slice_input.py000066400000000000000000000103411437041365600221600ustar00rootroot00000000000000from __future__ import annotations import warnings from dataclasses import dataclass from typing import List, Tuple, Union import numpy as np from napari.utils.misc import reorder_after_dim_reduction from napari.utils.transforms import Affine from napari.utils.translations import trans @dataclass(frozen=True) class _SliceInput: """Encapsulates the input needed for slicing a layer. An instance of this should be associated with a layer and some of the values in ``Viewer.dims`` when slicing a layer. """ # The number of dimensions to be displayed in the slice. ndisplay: int # The point in layer world coordinates that defines the slicing plane. # Only the elements in the non-displayed dimensions have meaningful values. point: Tuple[float, ...] # The layer dimension indices in the order they are displayed. # A permutation of the ``range(self.ndim)``. # The last ``self.ndisplay`` dimensions are displayed in the canvas. order: Tuple[int, ...] @property def ndim(self) -> int: """The dimensionality of the associated layer.""" return len(self.order) @property def displayed(self) -> List[int]: """The layer dimension indices displayed in this slice.""" return list(self.order[-self.ndisplay :]) @property def not_displayed(self) -> List[int]: """The layer dimension indices not displayed in this slice.""" return list(self.order[: -self.ndisplay]) def with_ndim(self, ndim: int) -> _SliceInput: """Returns a new instance with the given number of layer dimensions.""" old_ndim = self.ndim if old_ndim > ndim: point = self.point[-ndim:] order = reorder_after_dim_reduction(self.order[-ndim:]) elif old_ndim < ndim: point = (0,) * (ndim - old_ndim) + self.point order = tuple(range(ndim - old_ndim)) + tuple( o + ndim - old_ndim for o in self.order ) else: point = self.point order = self.order return _SliceInput(ndisplay=self.ndisplay, point=point, order=order) def data_indices( self, world_to_data: Affine, round_index: bool = True ) -> Tuple[Union[int, float, slice], ...]: """Transforms this into indices that can be used to slice layer data. The elements in non-displayed dimensions will be real numbers. The elements in displayed dimensions will be ``slice(None)``. """ if not self.is_orthogonal(world_to_data): warnings.warn( trans._( 'Non-orthogonal slicing is being requested, but is not fully supported. Data is displayed without applying an out-of-slice rotation or shear component.', deferred=True, ), category=UserWarning, ) slice_world_to_data = world_to_data.set_slice(self.not_displayed) world_pts = [self.point[ax] for ax in self.not_displayed] data_pts = slice_world_to_data(world_pts) if round_index: # A round is taken to convert these values to slicing integers data_pts = np.round(data_pts).astype(int) indices = [slice(None)] * self.ndim for i, ax in enumerate(self.not_displayed): indices[ax] = data_pts[i] return tuple(indices) def is_orthogonal(self, world_to_data: Affine) -> bool: """Returns True if this slice represents an orthogonal slice through a layer's data, False otherwise.""" # Subspace spanned by non displayed dimensions non_displayed_subspace = np.zeros(self.ndim) for d in self.not_displayed: non_displayed_subspace[d] = 1 # Map subspace through inverse transform, ignoring translation world_to_data = Affine( ndim=self.ndim, linear_matrix=world_to_data.linear_matrix, translate=None, ) mapped_nd_subspace = world_to_data(non_displayed_subspace) # Look at displayed subspace displayed_mapped_subspace = ( mapped_nd_subspace[d] for d in self.displayed ) # Check that displayed subspace is null return all(abs(v) < 1e-8 for v in displayed_mapped_subspace) napari-0.5.0a1/napari/layers/utils/_tests/000077500000000000000000000000001437041365600204335ustar00rootroot00000000000000napari-0.5.0a1/napari/layers/utils/_tests/__init__.py000066400000000000000000000000001437041365600225320ustar00rootroot00000000000000napari-0.5.0a1/napari/layers/utils/_tests/test_color_encoding.py000066400000000000000000000147771437041365600250500ustar00rootroot00000000000000import pandas as pd import pytest from napari._tests.utils import assert_colors_equal from napari.layers.utils.color_encoding import ( ColorEncoding, ConstantColorEncoding, DirectColorEncoding, ManualColorEncoding, NominalColorEncoding, QuantitativeColorEncoding, ) def make_features_with_no_columns(*, num_rows) -> pd.DataFrame: return pd.DataFrame({}, index=range(num_rows)) @pytest.fixture def features() -> pd.DataFrame: return pd.DataFrame( { 'class': ['a', 'b', 'c'], 'confidence': [0.5, 1, 0], 'custom_colors': ['red', 'green', 'cyan'], } ) def test_constant_call_with_no_rows(): features = make_features_with_no_columns(num_rows=0) encoding = ConstantColorEncoding(constant='red') values = encoding(features) assert_colors_equal(values, 'red') def test_constant_call_with_some_rows(): features = make_features_with_no_columns(num_rows=3) encoding = ConstantColorEncoding(constant='red') values = encoding(features) assert_colors_equal(values, 'red') def test_manual_call_with_no_rows(): features = make_features_with_no_columns(num_rows=0) array = ['red', 'green', 'cyan'] default = 'yellow' encoding = ManualColorEncoding(array=array, default=default) values = encoding(features) assert_colors_equal(values, []) def test_manual_call_with_fewer_rows(): features = make_features_with_no_columns(num_rows=2) array = ['red', 'green', 'cyan'] default = 'yellow' encoding = ManualColorEncoding(array=array, default=default) values = encoding(features) assert_colors_equal(values, ['red', 'green']) def test_manual_call_with_same_rows(): features = make_features_with_no_columns(num_rows=3) array = ['red', 'green', 'cyan'] default = 'yellow' encoding = ManualColorEncoding(array=array, default=default) values = encoding(features) assert_colors_equal(values, ['red', 'green', 'cyan']) def test_manual_with_more_rows(): features = make_features_with_no_columns(num_rows=4) array = ['red', 'green', 'cyan'] default = 'yellow' encoding = ManualColorEncoding(array=array, default=default) values = encoding(features) assert_colors_equal(values, ['red', 'green', 'cyan', 'yellow']) def test_direct(features): encoding = DirectColorEncoding(feature='custom_colors') values = encoding(features) assert_colors_equal(values, list(features['custom_colors'])) def test_direct_with_missing_feature(features): encoding = DirectColorEncoding(feature='not_class') with pytest.raises(KeyError): encoding(features) def test_nominal_with_dict_colormap(features): colormap = {'a': 'red', 'b': 'yellow', 'c': 'green'} encoding = NominalColorEncoding( feature='class', colormap=colormap, ) values = encoding(features) assert_colors_equal(values, ['red', 'yellow', 'green']) def test_nominal_with_dict_cycle(features): colormap = ['red', 'yellow', 'green'] encoding = NominalColorEncoding( feature='class', colormap=colormap, ) values = encoding(features) assert_colors_equal(values, ['red', 'yellow', 'green']) def test_nominal_with_missing_feature(features): colormap = {'a': 'red', 'b': 'yellow', 'c': 'green'} encoding = NominalColorEncoding(feature='not_class', colormap=colormap) with pytest.raises(KeyError): encoding(features) def test_quantitative_with_colormap_name(features): colormap = 'gray' encoding = QuantitativeColorEncoding( feature='confidence', colormap=colormap ) values = encoding(features) assert_colors_equal(values, [[c] * 3 for c in features['confidence']]) def test_quantitative_with_colormap_values(features): colormap = ['black', 'red'] encoding = QuantitativeColorEncoding( feature='confidence', colormap=colormap ) values = encoding(features) assert_colors_equal(values, [[c, 0, 0] for c in features['confidence']]) def test_quantitative_with_contrast_limits(features): colormap = 'gray' encoding = QuantitativeColorEncoding( feature='confidence', colormap=colormap, contrast_limits=(0, 2), ) values = encoding(features) assert encoding.contrast_limits == (0, 2) assert_colors_equal(values, [[c / 2] * 3 for c in features['confidence']]) def test_quantitative_with_missing_feature(features): colormap = 'gray' encoding = QuantitativeColorEncoding( feature='not_confidence', colormap=colormap ) with pytest.raises(KeyError): encoding(features) def test_validate_from_named_color(): argument = 'red' expected = ConstantColorEncoding(constant=argument) actual = ColorEncoding.validate(argument) assert actual == expected def test_validate_from_sequence(): argument = ['red', 'green', 'cyan'] expected = ManualColorEncoding(array=argument) actual = ColorEncoding.validate(argument) assert actual == expected def test_validate_from_constant_dict(): constant = 'yellow' argument = {'constant': constant} expected = ConstantColorEncoding(constant=constant) actual = ColorEncoding.validate(argument) assert actual == expected def test_validate_from_manual_dict(): array = ['red', 'green', 'cyan'] default = 'yellow' argument = {'array': array, 'default': default} expected = ManualColorEncoding(array=array, default=default) actual = ColorEncoding.validate(argument) assert actual == expected def test_validate_from_direct_dict(): feature = 'class' argument = {'feature': feature} expected = DirectColorEncoding(feature=feature) actual = ColorEncoding.validate(argument) assert actual == expected def test_validate_from_nominal_dict(): feature = 'class' colormap = ['red', 'green', 'cyan'] argument = {'feature': feature, 'colormap': colormap} expected = NominalColorEncoding( feature=feature, colormap=colormap, ) actual = ColorEncoding.validate(argument) assert actual == expected def test_validate_from_quantitative_dict(features): feature = 'confidence' colormap = 'gray' contrast_limits = (0, 2) argument = { 'feature': feature, 'colormap': colormap, 'contrast_limits': contrast_limits, } expected = QuantitativeColorEncoding( feature=feature, colormap=colormap, contrast_limits=contrast_limits, ) actual = ColorEncoding.validate(argument) assert actual == expected napari-0.5.0a1/napari/layers/utils/_tests/test_color_manager.py000066400000000000000000000532771437041365600246720ustar00rootroot00000000000000import json from itertools import cycle, islice import numpy as np import pytest from pydantic import ValidationError from napari.layers.utils.color_manager import ColorManager, ColorProperties from napari.utils.colormaps.categorical_colormap import CategoricalColormap from napari.utils.colormaps.standardize_color import transform_color def _make_cycled_properties(values, length): """Helper function to make property values Parameters ---------- values The values to be cycled. length : int The length of the resulting property array Returns ------- cycled_properties : np.ndarray The property array comprising the cycled values. """ cycled_properties = np.array(list(islice(cycle(values), 0, length))) return cycled_properties def test_color_manager_empty(): cm = ColorManager() np.testing.assert_allclose(cm.colors, np.empty((0, 4))) assert cm.color_mode == 'direct' color_mapping = {0: np.array([1, 1, 1, 1]), 1: np.array([1, 0, 0, 1])} fallback_colors = np.array([[1, 0, 0, 1], [0, 1, 0, 1]]) default_fallback_color = np.array([[1, 1, 1, 1]]) categorical_map = CategoricalColormap( colormap=color_mapping, fallback_color=fallback_colors ) @pytest.mark.parametrize( 'cat_cmap,expected', [ ({'colormap': color_mapping}, (color_mapping, default_fallback_color)), ( {'colormap': color_mapping, 'fallback_color': fallback_colors}, (color_mapping, fallback_colors), ), ({'fallback_color': fallback_colors}, ({}, fallback_colors)), (color_mapping, (color_mapping, default_fallback_color)), (fallback_colors, ({}, fallback_colors)), (categorical_map, (color_mapping, fallback_colors)), ], ) def test_categorical_colormap_from_dict(cat_cmap, expected): colors = np.array([[1, 1, 1, 1], [1, 0, 0, 1], [0, 0, 0, 1]]) cm = ColorManager( colors=colors, categorical_colormap=cat_cmap, color_mode='direct' ) np.testing.assert_equal(cm.categorical_colormap.colormap, expected[0]) np.testing.assert_almost_equal( cm.categorical_colormap.fallback_color.values, expected[1] ) def test_invalid_categorical_colormap(): colors = np.array([[1, 1, 1, 1], [1, 0, 0, 1], [0, 0, 0, 1]]) invalid_cmap = 42 with pytest.raises(ValidationError): _ = ColorManager( colors=colors, categorical_colormap=invalid_cmap, color_mode='direct', ) c_prop_dict = { 'name': 'point_type', 'values': np.array(['A', 'B', 'C']), 'current_value': np.array(['C']), } c_prop_obj = ColorProperties(**c_prop_dict) @pytest.mark.parametrize( 'c_props,expected', [ (None, None), ({}, None), (c_prop_obj, c_prop_obj), (c_prop_dict, c_prop_obj), ], ) def test_color_properties_coercion(c_props, expected): colors = np.array([[1, 1, 1, 1], [1, 0, 0, 1], [0, 0, 0, 1]]) cm = ColorManager( colors=colors, color_properties=c_props, color_mode='direct' ) assert cm.color_properties == expected wrong_type = ('prop_1', np.array([1, 2, 3])) invalid_keys = {'values': np.array(['A', 'B', 'C'])} @pytest.mark.parametrize('c_props', [wrong_type, invalid_keys]) def test_invalid_color_properties(c_props): colors = np.array([[1, 1, 1, 1], [1, 0, 0, 1], [0, 0, 0, 1]]) with pytest.raises(ValidationError): _ = ColorManager( colors=colors, color_properties=c_props, color_mode='direct' ) @pytest.mark.parametrize( 'curr_color,expected', [ (None, np.array([0, 0, 0, 1])), ([], np.array([0, 0, 0, 1])), ('red', np.array([1, 0, 0, 1])), ([1, 0, 0, 1], np.array([1, 0, 0, 1])), ], ) def test_current_color_coercion(curr_color, expected): colors = np.array([[1, 1, 1, 1], [1, 0, 0, 1], [0, 0, 0, 1]]) cm = ColorManager( colors=colors, current_color=curr_color, color_mode='direct' ) np.testing.assert_allclose(cm.current_color, expected) color_str = ['red', 'red', 'red'] color_list = [[1, 0, 0, 1], [1, 0, 0, 1], [1, 0, 0, 1]] color_arr = np.asarray(color_list) @pytest.mark.parametrize('color', [color_str, color_list, color_arr]) def test_color_manager_direct(color): cm = ColorManager(colors=color, color_mode='direct') color_mode = cm.color_mode assert color_mode == 'direct' expected_colors = np.array([[1, 0, 0, 1], [1, 0, 0, 1], [1, 0, 0, 1]]) np.testing.assert_allclose(cm.colors, expected_colors) np.testing.assert_allclose(cm.current_color, expected_colors[-1]) # test adding a color new_color = [1, 1, 1, 1] cm._add(new_color) np.testing.assert_allclose(cm.colors[-1], new_color) # test removing colors cm._remove([0, 3]) np.testing.assert_allclose(cm.colors, expected_colors[1:3]) # test pasting colors paste_colors = np.array([[0, 0, 0, 1], [0, 0, 0, 1]]) cm._paste(colors=paste_colors, properties={}) post_paste_colors = np.vstack((expected_colors[1:3], paste_colors)) np.testing.assert_allclose(cm.colors, post_paste_colors) # refreshing the colors in direct mode should have no effect cm._refresh_colors(properties={}) np.testing.assert_allclose(cm.colors, post_paste_colors) @pytest.mark.parametrize('color', [color_str, color_list, color_arr]) def test_set_color_direct(color): """Test setting the colors via the set_color method in direct mode""" # create an empty color manager cm = ColorManager() np.testing.assert_allclose(cm.colors, np.empty((0, 4))) assert cm.color_mode == 'direct' # set colors expected_colors = np.array([[1, 0, 0, 1], [1, 0, 0, 1], [1, 0, 0, 1]]) cm._set_color( color, n_colors=len(color), properties={}, current_properties={} ) np.testing.assert_almost_equal(cm.colors, expected_colors) def test_continuous_colormap(): # create ColorManager with a continuous colormap n_colors = 10 properties = { 'name': 'point_type', 'values': _make_cycled_properties([0, 1.5], n_colors), } cm = ColorManager( color_properties=properties, continuous_colormap='gray', color_mode='colormap', ) color_mode = cm.color_mode assert color_mode == 'colormap' color_array = transform_color(['black', 'white'] * int(n_colors / 2)) colors = cm.colors.copy() np.testing.assert_allclose(colors, color_array) np.testing.assert_allclose(cm.current_color, [1, 1, 1, 1]) # Add 2 color elements and test their color cm._add(0, n_colors=2) cm_colors = cm.colors assert len(cm_colors) == n_colors + 2 np.testing.assert_allclose( cm_colors, np.vstack( (color_array, transform_color('black'), transform_color('black')) ), ) # Check removing data adjusts colors correctly cm._remove({0, 2, 11}) cm_colors_2 = cm.colors assert len(cm_colors_2) == (n_colors - 1) np.testing.assert_allclose( cm_colors_2, np.vstack((color_array[1], color_array[3:], transform_color('black'))), ) # adjust the clims cm.contrast_limits = (0, 3) updated_colors = cm.colors np.testing.assert_allclose(updated_colors[-2], [0.5, 0.5, 0.5, 1]) # first verify that prop value 0 is colored black current_colors = cm.colors np.testing.assert_allclose(current_colors[-1], [0, 0, 0, 1]) # change the colormap new_colormap = 'gray_r' cm.continuous_colormap = new_colormap assert cm.continuous_colormap.name == new_colormap # the props valued 0 should now be white updated_colors = cm.colors np.testing.assert_allclose(updated_colors[-1], [1, 1, 1, 1]) # test pasting values paste_props = {'point_type': np.array([0, 0])} paste_colors = np.array([[1, 1, 1, 1], [1, 1, 1, 1]]) cm._paste(colors=paste_colors, properties=paste_props) np.testing.assert_allclose(cm.colors[-2:], paste_colors) def test_set_color_colormap(): # make an empty colormanager init_color_properties = { 'name': 'point_type', 'values': np.empty(0), 'current_value': np.array([1.5]), } cm = ColorManager( color_properties=init_color_properties, continuous_colormap='gray', color_mode='colormap', ) # use the set_color method to update the colors n_colors = 10 updated_properties = { 'point_type': _make_cycled_properties([0, 1.5], n_colors) } current_properties = {'point_type': np.array([1.5])} cm._set_color( color='point_type', n_colors=n_colors, properties=updated_properties, current_properties=current_properties, ) color_array = transform_color(['black', 'white'] * int(n_colors / 2)) np.testing.assert_allclose(cm.colors, color_array) color_cycle_str = ['red', 'blue'] color_cycle_rgb = [[1, 0, 0], [0, 0, 1]] color_cycle_rgba = [[1, 0, 0, 1], [0, 0, 1, 1]] @pytest.mark.parametrize( "color_cycle", [color_cycle_str, color_cycle_rgb, color_cycle_rgba], ) def test_color_cycle(color_cycle): """Test setting color with a color cycle list""" # create Points using list color cycle n_colors = 10 properties = { 'name': 'point_type', 'values': _make_cycled_properties(['A', 'B'], n_colors), } cm = ColorManager( color_mode='cycle', color_properties=properties, categorical_colormap=color_cycle, ) color_mode = cm.color_mode assert color_mode == 'cycle' color_array = transform_color( list(islice(cycle(color_cycle), 0, n_colors)) ) np.testing.assert_allclose(cm.colors, color_array) # Add 2 color elements and test their color cm._add('A', n_colors=2) cm_colors = cm.colors assert len(cm_colors) == n_colors + 2 np.testing.assert_allclose( cm_colors, np.vstack( (color_array, transform_color('red'), transform_color('red')) ), ) # Check removing data adjusts colors correctly cm._remove({0, 2, 11}) cm_colors_2 = cm.colors assert len(cm_colors_2) == (n_colors - 1) np.testing.assert_allclose( cm_colors_2, np.vstack((color_array[1], color_array[3:], transform_color('red'))), ) # update the colormap cm.categorical_colormap = ['black', 'white'] # the first color should now be black np.testing.assert_allclose(cm.colors[0], [0, 0, 0, 1]) # test pasting values paste_props = {'point_type': np.array(['B', 'B'])} paste_colors = np.array([[0, 0, 0, 1], [0, 0, 0, 1]]) cm._paste(colors=paste_colors, properties=paste_props) np.testing.assert_allclose(cm.colors[-2:], paste_colors) def test_set_color_cycle(): # make an empty colormanager init_color_properties = { 'name': 'point_type', 'values': np.empty(0), 'current_value': np.array(['A']), } cm = ColorManager( color_properties=init_color_properties, categorical_colormap=['black', 'white'], mode='cycle', ) # use the set_color method to update the colors n_colors = 10 updated_properties = { 'point_type': _make_cycled_properties(['A', 'B'], n_colors) } current_properties = {'point_type': np.array(['B'])} cm._set_color( color='point_type', n_colors=n_colors, properties=updated_properties, current_properties=current_properties, ) color_array = transform_color(['black', 'white'] * int(n_colors / 2)) np.testing.assert_allclose(cm.colors, color_array) @pytest.mark.parametrize('n_colors', [0, 1, 5]) def test_init_color_manager_direct(n_colors): color_manager = ColorManager._from_layer_kwargs( colors='red', properties={}, n_colors=n_colors, continuous_colormap='viridis', contrast_limits=None, categorical_colormap=[[0, 0, 0, 1], [1, 1, 1, 1]], ) assert len(color_manager.colors) == n_colors assert color_manager.color_mode == 'direct' np.testing.assert_array_almost_equal( color_manager.current_color, [1, 0, 0, 1] ) if n_colors > 0: expected_colors = np.tile([1, 0, 0, 1], (n_colors, 1)) np.testing.assert_array_almost_equal( color_manager.colors, expected_colors ) # test that colormanager state can be saved and loaded cm_dict = color_manager.dict() color_manager_2 = ColorManager._from_layer_kwargs( colors=cm_dict, properties={}, n_colors=n_colors ) assert color_manager == color_manager_2 # test json serialization json_str = color_manager.json() cm_json_dict = json.loads(json_str) color_manager_3 = ColorManager._from_layer_kwargs( colors=cm_json_dict, properties={}, n_colors=n_colors ) assert color_manager == color_manager_3 def test_init_color_manager_cycle(): n_colors = 10 color_cycle = [[0, 0, 0, 1], [1, 1, 1, 1]] properties = {'point_type': _make_cycled_properties(['A', 'B'], n_colors)} color_manager = ColorManager._from_layer_kwargs( colors='point_type', properties=properties, n_colors=n_colors, continuous_colormap='viridis', contrast_limits=None, categorical_colormap=color_cycle, ) assert len(color_manager.colors) == n_colors assert color_manager.color_mode == 'cycle' color_array = transform_color( list(islice(cycle(color_cycle), 0, n_colors)) ) np.testing.assert_allclose(color_manager.colors, color_array) assert color_manager.color_properties.current_value == 'B' # test that colormanager state can be saved and loaded cm_dict = color_manager.dict() color_manager_2 = ColorManager._from_layer_kwargs( colors=cm_dict, properties=properties ) assert color_manager == color_manager_2 # test json serialization json_str = color_manager.json() cm_json_dict = json.loads(json_str) color_manager_3 = ColorManager._from_layer_kwargs( colors=cm_json_dict, properties={}, n_colors=n_colors ) assert color_manager == color_manager_3 def test_init_color_manager_cycle_with_colors_dict(): """Test initializing color cycle ColorManager from layer kwargs where the colors are given as a dictionary of ColorManager fields/values """ n_colors = 10 color_cycle = [[0, 0, 0, 1], [1, 1, 1, 1]] properties = {'point_type': _make_cycled_properties(['A', 'B'], n_colors)} colors_dict = { 'color_properties': 'point_type', 'color_mode': 'cycle', 'categorical_colormap': color_cycle, } color_manager = ColorManager._from_layer_kwargs( colors=colors_dict, properties=properties, n_colors=n_colors, continuous_colormap='viridis', ) assert len(color_manager.colors) == n_colors assert color_manager.color_mode == 'cycle' color_array = transform_color( list(islice(cycle(color_cycle), 0, n_colors)) ) np.testing.assert_allclose(color_manager.colors, color_array) assert color_manager.color_properties.current_value == 'B' assert color_manager.continuous_colormap.name == 'viridis' def test_init_empty_color_manager_cycle(): n_colors = 0 color_cycle = [[0, 0, 0, 1], [1, 1, 1, 1]] properties = {'point_type': ['A', 'B']} color_manager = ColorManager._from_layer_kwargs( colors='point_type', properties=properties, n_colors=n_colors, continuous_colormap='viridis', contrast_limits=None, categorical_colormap=color_cycle, ) assert len(color_manager.colors) == n_colors assert color_manager.color_mode == 'cycle' np.testing.assert_allclose(color_manager.current_color, [0, 0, 0, 1]) assert color_manager.color_properties.current_value == 'A' color_manager._add() np.testing.assert_allclose(color_manager.colors, [[0, 0, 0, 1]]) color_manager.color_properties.current_value = 'B' color_manager._add() np.testing.assert_allclose( color_manager.colors, [[0, 0, 0, 1], [1, 1, 1, 1]] ) # test that colormanager state can be saved and loaded cm_dict = color_manager.dict() color_manager_2 = ColorManager._from_layer_kwargs( colors=cm_dict, properties=properties ) assert color_manager == color_manager_2 def test_init_color_manager_colormap(): n_colors = 10 color_cycle = [[0, 0, 0, 1], [1, 1, 1, 1]] properties = {'point_type': _make_cycled_properties([0, 1.5], n_colors)} color_manager = ColorManager._from_layer_kwargs( colors='point_type', properties=properties, n_colors=n_colors, continuous_colormap='gray', contrast_limits=None, categorical_colormap=color_cycle, ) assert len(color_manager.colors) == n_colors assert color_manager.color_mode == 'colormap' color_array = transform_color(['black', 'white'] * int(n_colors / 2)) colors = color_manager.colors.copy() np.testing.assert_allclose(colors, color_array) np.testing.assert_allclose(color_manager.current_color, [1, 1, 1, 1]) assert color_manager.color_properties.current_value == 1.5 # test that colormanager state can be saved and loaded cm_dict = color_manager.dict() color_manager_2 = ColorManager._from_layer_kwargs( colors=cm_dict, properties=properties ) assert color_manager == color_manager_2 # test json serialization json_str = color_manager.json() cm_json_dict = json.loads(json_str) color_manager_3 = ColorManager._from_layer_kwargs( colors=cm_json_dict, properties={}, n_colors=n_colors ) assert color_manager == color_manager_3 def test_init_color_manager_colormap_with_colors_dict(): """Test initializing colormap ColorManager from layer kwargs where the colors are given as a dictionary of ColorManager fields/values """ n_colors = 10 color_cycle = [[0, 0, 0, 1], [1, 1, 1, 1]] properties = {'point_type': _make_cycled_properties([0, 1.5], n_colors)} colors_dict = { 'color_properties': 'point_type', 'color_mode': 'colormap', 'categorical_colormap': color_cycle, 'continuous_colormap': 'gray', } color_manager = ColorManager._from_layer_kwargs( colors=colors_dict, properties=properties, n_colors=n_colors ) assert len(color_manager.colors) == n_colors assert color_manager.color_mode == 'colormap' color_array = transform_color(['black', 'white'] * int(n_colors / 2)) colors = color_manager.colors.copy() np.testing.assert_allclose(colors, color_array) np.testing.assert_allclose(color_manager.current_color, [1, 1, 1, 1]) assert color_manager.color_properties.current_value == 1.5 assert color_manager.continuous_colormap.name == 'gray' def test_init_empty_color_manager_colormap(): n_colors = 0 color_cycle = [[0, 0, 0, 1], [1, 1, 1, 1]] properties = {'point_type': [0]} color_manager = ColorManager._from_layer_kwargs( colors='point_type', properties=properties, n_colors=n_colors, color_mode='colormap', continuous_colormap='gray', contrast_limits=None, categorical_colormap=color_cycle, ) assert len(color_manager.colors) == n_colors assert color_manager.color_mode == 'colormap' np.testing.assert_allclose(color_manager.current_color, [0, 0, 0, 1]) assert color_manager.color_properties.current_value == 0 color_manager._add() np.testing.assert_allclose(color_manager.colors, [[1, 1, 1, 1]]) color_manager.color_properties.current_value = 1.5 color_manager._add(update_clims=True) np.testing.assert_allclose( color_manager.colors, [[0, 0, 0, 1], [1, 1, 1, 1]] ) # test that colormanager state can be saved and loaded cm_dict = color_manager.dict() color_manager_2 = ColorManager._from_layer_kwargs( colors=cm_dict, properties=properties ) assert color_manager == color_manager_2 def test_color_manager_invalid_color_properties(): """Passing an invalid property name for color_properties should raise a KeyError """ n_colors = 10 color_cycle = [[0, 0, 0, 1], [1, 1, 1, 1]] properties = {'point_type': _make_cycled_properties([0, 1.5], n_colors)} colors_dict = { 'color_properties': 'not_point_type', 'color_mode': 'colormap', 'categorical_colormap': color_cycle, 'continuous_colormap': 'gray', } with pytest.raises(KeyError): _ = ColorManager._from_layer_kwargs( colors=colors_dict, properties=properties, n_colors=n_colors ) def test_refresh_colors(): # create ColorManager with a continuous colormap n_colors = 4 properties = { 'name': 'point_type', 'values': _make_cycled_properties([0, 1.5], n_colors), } cm = ColorManager( color_properties=properties, continuous_colormap='gray', color_mode='colormap', ) color_mode = cm.color_mode assert color_mode == 'colormap' color_array = transform_color(['black', 'white'] * int(n_colors / 2)) colors = cm.colors.copy() np.testing.assert_allclose(colors, color_array) np.testing.assert_allclose(cm.current_color, [1, 1, 1, 1]) # after refresh, the color should now be white. since we didn't # update the color mapping, the other values should remain # unchanged even though we added a value that extends the range # of values new_properties = {'point_type': properties['values']} new_properties['point_type'][0] = 3 cm._refresh_colors(new_properties, update_color_mapping=False) new_colors = color_array.copy() new_colors[0] = [1, 1, 1, 1] np.testing.assert_allclose(cm.colors, new_colors) # now, refresh the colors, but update the mapping cm._refresh_colors(new_properties, update_color_mapping=True) refreshed_colors = [ [1, 1, 1, 1], [0.5, 0.5, 0.5, 1], [0, 0, 0, 1], [0.5, 0.5, 0.5, 1], ] np.testing.assert_allclose(cm.colors, refreshed_colors) napari-0.5.0a1/napari/layers/utils/_tests/test_color_manager_utils.py000066400000000000000000000027161437041365600261020ustar00rootroot00000000000000import numpy as np from napari.layers.utils.color_manager_utils import ( guess_continuous, is_color_mapped, ) def test_guess_continuous(): continuous_annotation = np.array([1, 2, 3], dtype=np.float32) assert guess_continuous(continuous_annotation) categorical_annotation_1 = np.array([True, False], dtype=bool) assert not guess_continuous(categorical_annotation_1) categorical_annotation_2 = np.array([1, 2, 3], dtype=int) assert not guess_continuous(categorical_annotation_2) def test_is_colormapped_string(): color = 'hello' properties = { 'hello': np.array([1, 1, 1, 1]), 'hi': np.array([1, 0, 0, 1]), } assert is_color_mapped(color, properties) assert not is_color_mapped('red', properties) def test_is_colormapped_dict(): """Colors passed as dicts are treated as colormapped""" color = {0: np.array([1, 1, 1, 1]), 1: np.array([1, 1, 0, 1])} properties = { 'hello': np.array([1, 1, 1, 1]), 'hi': np.array([1, 0, 0, 1]), } assert is_color_mapped(color, properties) def test_is_colormapped_array(): """Colors passed as list/array are treated as not colormapped""" color_list = [[1, 1, 1, 1], [1, 1, 0, 1]] properties = { 'hello': np.array([1, 1, 1, 1]), 'hi': np.array([1, 0, 0, 1]), } assert not is_color_mapped(color_list, properties) color_array = np.array(color_list) assert not is_color_mapped(color_array, properties) napari-0.5.0a1/napari/layers/utils/_tests/test_color_transforms.py000066400000000000000000000056271437041365600254520ustar00rootroot00000000000000from itertools import cycle import numpy as np import pytest from vispy.color import ColorArray from napari.layers.utils.color_transformations import ( normalize_and_broadcast_colors, transform_color_cycle, transform_color_with_defaults, ) def test_transform_color_basic(): """Test inner method with the same name.""" shape = (10, 2) np.random.seed(0) data = 20 * np.random.random(shape) colorarray = transform_color_with_defaults( num_entries=len(data), colors='r', elem_name='edge_color', default='black', ) np.testing.assert_array_equal(colorarray, ColorArray('r').rgba) def test_transform_color_wrong_colorname(): shape = (10, 2) np.random.seed(0) data = 20 * np.random.random(shape) with pytest.warns(UserWarning): colorarray = transform_color_with_defaults( num_entries=len(data), colors='rr', elem_name='edge_color', default='black', ) np.testing.assert_array_equal(colorarray, ColorArray('black').rgba) def test_transform_color_wrong_colorlen(): shape = (10, 2) np.random.seed(0) data = 20 * np.random.random(shape) with pytest.warns(UserWarning): colorarray = transform_color_with_defaults( num_entries=len(data), colors=['r', 'r'], elem_name='face_color', default='black', ) np.testing.assert_array_equal(colorarray, ColorArray('black').rgba) def test_normalize_colors_basic(): shape = (10, 2) np.random.seed(0) data = 20 * np.random.random(shape) colors = ColorArray(['w'] * shape[0]).rgba colorarray = normalize_and_broadcast_colors(len(data), colors) np.testing.assert_array_equal(colorarray, colors) def test_normalize_colors_wrong_num(): shape = (10, 2) np.random.seed(0) data = 20 * np.random.random(shape) colors = ColorArray(['w'] * shape[0]).rgba with pytest.warns(UserWarning): colorarray = normalize_and_broadcast_colors(len(data), colors[:-1]) np.testing.assert_array_equal(colorarray, colors) def test_normalize_colors_zero_colors(): shape = (10, 2) np.random.seed(0) data = 20 * np.random.random(shape) real = np.ones((shape[0], 4), dtype=np.float32) with pytest.warns(UserWarning): colorarray = normalize_and_broadcast_colors(len(data), []) np.testing.assert_array_equal(colorarray, real) def test_transform_color_cycle(): colors = ['red', 'blue'] transformed_color_cycle, transformed_colors = transform_color_cycle( colors, elem_name='face_color', default='white' ) transformed_result = np.array( [next(transformed_color_cycle) for i in range(10)] ) color_cycle = cycle(np.array([[1, 0, 0, 1], [0, 0, 1, 1]])) color_cycle_result = np.array([next(color_cycle) for i in range(10)]) np.testing.assert_allclose(transformed_result, color_cycle_result) napari-0.5.0a1/napari/layers/utils/_tests/test_interactivity_utils.py000066400000000000000000000021771437041365600261710ustar00rootroot00000000000000import numpy as np import pytest from napari.layers.utils.interactivity_utils import ( drag_data_to_projected_distance, ) @pytest.mark.parametrize( "start_position, end_position, view_direction, vector, expected_value", [ # drag vector parallel to view direction # projected onto perpendicular vector ([0, 0, 0], [0, 0, 1], [0, 0, 1], [1, 0, 0], 0), # same as above, projection onto multiple perpendicular vectors # should produce multiple results ([0, 0, 0], [0, 0, 1], [0, 0, 1], [[1, 0, 0], [0, 1, 0]], [0, 0]), # drag vector perpendicular to view direction # projected onto itself ([0, 0, 0], [0, 1, 0], [0, 0, 1], [0, 1, 0], 1), # drag vector perpendicular to view direction # projected onto itself ([0, 0, 0], [0, 1, 0], [0, 0, 1], [0, 1, 0], 1), ], ) def test_drag_data_to_projected_distance( start_position, end_position, view_direction, vector, expected_value ): result = drag_data_to_projected_distance( start_position, end_position, view_direction, vector ) assert np.allclose(result, expected_value) napari-0.5.0a1/napari/layers/utils/_tests/test_layer_utils.py000066400000000000000000000321361437041365600244050ustar00rootroot00000000000000import time import numpy as np import pandas as pd import pytest from dask import array as da from napari.layers.utils.layer_utils import ( _FeatureTable, calc_data_range, coerce_current_properties, dataframe_to_properties, dims_displayed_world_to_layer, get_current_properties, register_layer_attr_action, segment_normal, ) from napari.utils.key_bindings import KeymapHandler, KeymapProvider data_dask = da.random.random( size=(100_000, 1000, 1000), chunks=(1, 1000, 1000) ) data_dask_8b = da.random.randint( 0, 100, size=(1_000, 10, 10), chunks=(1, 10, 10), dtype=np.uint8 ) data_dask_1d = da.random.random(size=(20_000_000,), chunks=(5000,)) data_dask_1d_rgb = da.random.random(size=(5_000_000, 3), chunks=(50_000, 3)) data_dask_plane = da.random.random( size=(100_000, 100_000), chunks=(1000, 1000) ) def test_calc_data_range(): # all zeros should return [0, 1] by default data = np.zeros((10, 10)) clim = calc_data_range(data) assert np.all(clim == [0, 1]) # all ones should return [0, 1] by default data = np.ones((10, 10)) clim = calc_data_range(data) assert np.all(clim == [0, 1]) # return min and max data = np.random.random((10, 15)) data[0, 0] = 0 data[0, 1] = 2 clim = calc_data_range(data) assert np.all(clim == [0, 2]) # return min and max data = np.random.random((6, 10, 15)) data[0, 0, 0] = 0 data[0, 0, 1] = 2 clim = calc_data_range(data) assert np.all(clim == [0, 2]) # Try large data data = np.zeros((1000, 2000)) data[0, 0] = 0 data[0, 1] = 2 clim = calc_data_range(data) assert np.all(clim == [0, 2]) # Try large data mutlidimensional data = np.zeros((3, 1000, 1000)) data[0, 0, 0] = 0 data[0, 0, 1] = 2 clim = calc_data_range(data) assert np.all(clim == [0, 2]) @pytest.mark.parametrize( 'data', [data_dask_8b, data_dask, data_dask_1d, data_dask_1d_rgb, data_dask_plane], ) def test_calc_data_range_fast(data): now = time.monotonic() val = calc_data_range(data) assert len(val) > 0 elapsed = time.monotonic() - now assert elapsed < 5, "test took too long, computation was likely not lazy" def test_segment_normal_2d(): a = np.array([1, 1]) b = np.array([1, 10]) unit_norm = segment_normal(a, b) assert np.all(unit_norm == np.array([1, 0])) def test_segment_normal_3d(): a = np.array([1, 1, 0]) b = np.array([1, 10, 0]) p = np.array([1, 0, 0]) unit_norm = segment_normal(a, b, p) assert np.all(unit_norm == np.array([0, 0, -1])) def test_dataframe_to_properties(): properties = {'point_type': np.array(['A', 'B'] * 5)} properties_df = pd.DataFrame(properties) converted_properties = dataframe_to_properties(properties_df) np.testing.assert_equal(converted_properties, properties) def test_get_current_properties_with_properties_then_last_values(): properties = { "face_color": np.array(["cyan", "red", "red"]), "angle": np.array([0.5, 1.5, 1.5]), } current_properties = get_current_properties(properties, {}, 3) assert current_properties == { "face_color": "red", "angle": 1.5, } def test_get_current_properties_with_property_choices_then_first_values(): properties = { "face_color": np.empty(0, dtype=str), "angle": np.empty(0, dtype=float), } property_choices = { "face_color": np.array(["cyan", "red"]), "angle": np.array([0.5, 1.5]), } current_properties = get_current_properties( properties, property_choices, ) assert current_properties == { "face_color": "cyan", "angle": 0.5, } def test_coerce_current_properties_valid_values(): current_properties = { 'annotation': ['leg'], 'confidence': 1, 'annotator': 'ash', 'model': np.array(['best']), } expected_current_properties = { 'annotation': np.array(['leg']), 'confidence': np.array([1]), 'annotator': np.array(['ash']), 'model': np.array(['best']), } coerced_current_properties = coerce_current_properties(current_properties) for k in coerced_current_properties: value = coerced_current_properties[k] assert isinstance(value, np.ndarray) np.testing.assert_equal(value, expected_current_properties[k]) def test_coerce_current_properties_invalid_values(): current_properties = { 'annotation': ['leg'], 'confidence': 1, 'annotator': 'ash', 'model': np.array(['best', 'best_v2_final']), } with pytest.raises(ValueError): _ = coerce_current_properties(current_properties) @pytest.mark.parametrize( "dims_displayed,ndim_world,ndim_layer,expected", [ ([1, 2, 3], 4, 4, [1, 2, 3]), ([0, 1, 2], 4, 4, [0, 1, 2]), ([1, 2, 3], 4, 3, [0, 1, 2]), ([0, 1, 2], 4, 3, [2, 0, 1]), ([1, 2, 3], 4, 2, [0, 1]), ([0, 1, 2], 3, 3, [0, 1, 2]), ([0, 1], 2, 2, [0, 1]), ([1, 0], 2, 2, [1, 0]), ], ) def test_dims_displayed_world_to_layer( dims_displayed, ndim_world, ndim_layer, expected ): dims_displayed_layer = dims_displayed_world_to_layer( dims_displayed, ndim_world=ndim_world, ndim_layer=ndim_layer ) np.testing.assert_array_equal(dims_displayed_layer, expected) def test_feature_table_from_layer_with_none_then_empty(): feature_table = _FeatureTable.from_layer(features=None) assert feature_table.values.shape == (0, 0) def test_feature_table_from_layer_with_num_data_only(): feature_table = _FeatureTable.from_layer(num_data=5) assert feature_table.values.shape == (5, 0) assert feature_table.defaults.shape == (1, 0) def test_feature_table_from_layer_with_properties_and_num_data(): properties = { 'class': np.array(['sky', 'person', 'building', 'person']), 'confidence': np.array([0.2, 0.5, 1, 0.8]), } feature_table = _FeatureTable.from_layer(properties=properties, num_data=4) features = feature_table.values assert features.shape == (4, 2) np.testing.assert_array_equal(features['class'], properties['class']) np.testing.assert_array_equal( features['confidence'], properties['confidence'] ) defaults = feature_table.defaults assert defaults.shape == (1, 2) assert defaults['class'][0] == properties['class'][-1] assert defaults['confidence'][0] == properties['confidence'][-1] def test_feature_table_from_layer_with_properties_and_choices(): properties = { 'class': np.array(['sky', 'person', 'building', 'person']), } property_choices = { 'class': np.array(['building', 'person', 'sky']), } feature_table = _FeatureTable.from_layer( properties=properties, property_choices=property_choices, num_data=4 ) features = feature_table.values assert features.shape == (4, 1) class_column = features['class'] np.testing.assert_array_equal(class_column, properties['class']) assert isinstance(class_column.dtype, pd.CategoricalDtype) np.testing.assert_array_equal( class_column.dtype.categories, property_choices['class'] ) defaults = feature_table.defaults assert defaults.shape == (1, 1) assert defaults['class'][0] == properties['class'][-1] def test_feature_table_from_layer_with_choices_only(): property_choices = { 'class': np.array(['building', 'person', 'sky']), } feature_table = _FeatureTable.from_layer( property_choices=property_choices, num_data=0 ) features = feature_table.values assert features.shape == (0, 1) class_column = features['class'] assert isinstance(class_column.dtype, pd.CategoricalDtype) np.testing.assert_array_equal( class_column.dtype.categories, property_choices['class'] ) defaults = feature_table.defaults assert defaults.shape == (1, 1) assert defaults['class'][0] == property_choices['class'][0] def test_feature_table_from_layer_with_empty_properties_and_choices(): properties = { 'class': np.array([]), } property_choices = { 'class': np.array(['building', 'person', 'sky']), } feature_table = _FeatureTable.from_layer( properties=properties, property_choices=property_choices, num_data=0 ) features = feature_table.values assert features.shape == (0, 1) class_column = features['class'] assert isinstance(class_column.dtype, pd.CategoricalDtype) np.testing.assert_array_equal( class_column.dtype.categories, property_choices['class'] ) defaults = feature_table.defaults assert defaults.shape == (1, 1) assert defaults['class'][0] == property_choices['class'][0] TEST_FEATURES = pd.DataFrame( { 'class': pd.Series( ['sky', 'person', 'building', 'person'], dtype=pd.CategoricalDtype( categories=('building', 'person', 'sky') ), ), 'confidence': pd.Series([0.2, 0.5, 1, 0.8]), } ) def test_feature_table_from_layer_with_properties_as_dataframe(): feature_table = _FeatureTable.from_layer(properties=TEST_FEATURES) pd.testing.assert_frame_equal(feature_table.values, TEST_FEATURES) def _make_feature_table(): return _FeatureTable(TEST_FEATURES.copy(deep=True), num_data=4) def test_feature_table_resize_smaller(): feature_table = _make_feature_table() feature_table.resize(2) features = feature_table.values assert features.shape == (2, 2) np.testing.assert_array_equal(features['class'], ['sky', 'person']) np.testing.assert_array_equal(features['confidence'], [0.2, 0.5]) def test_feature_table_resize_larger(): feature_table = _make_feature_table() expected_dtypes = feature_table.values.dtypes feature_table.resize(6) features = feature_table.values assert features.shape == (6, 2) np.testing.assert_array_equal( features['class'], ['sky', 'person', 'building', 'person', 'person', 'person'], ) np.testing.assert_array_equal( features['confidence'], [0.2, 0.5, 1, 0.8, 0.8, 0.8], ) np.testing.assert_array_equal(features.dtypes, expected_dtypes) def test_feature_table_append(): feature_table = _make_feature_table() to_append = pd.DataFrame( { 'class': ['sky', 'building'], 'confidence': [0.6, 0.1], } ) feature_table.append(to_append) features = feature_table.values assert features.shape == (6, 2) np.testing.assert_array_equal( features['class'], ['sky', 'person', 'building', 'person', 'sky', 'building'], ) np.testing.assert_array_equal( features['confidence'], [0.2, 0.5, 1, 0.8, 0.6, 0.1], ) def test_feature_table_remove(): feature_table = _make_feature_table() feature_table.remove([1, 3]) features = feature_table.values assert features.shape == (2, 2) np.testing.assert_array_equal(features['class'], ['sky', 'building']) np.testing.assert_array_equal(features['confidence'], [0.2, 1]) def test_feature_table_from_layer_with_custom_index(): features = pd.DataFrame({'a': [1, 3], 'b': [7.5, -2.1]}, index=[1, 2]) feature_table = _FeatureTable.from_layer(features=features) expected = features.reset_index(drop=True) pd.testing.assert_frame_equal(feature_table.values, expected) def test_feature_table_from_layer_with_custom_index_and_num_data(): features = pd.DataFrame({'a': [1, 3], 'b': [7.5, -2.1]}, index=[1, 2]) feature_table = _FeatureTable.from_layer(features=features, num_data=2) expected = features.reset_index(drop=True) pd.testing.assert_frame_equal(feature_table.values, expected) def test_feature_table_from_layer_with_unordered_pd_series_properties(): properties = { 'a': pd.Series([1, 3], index=[3, 4]), 'b': pd.Series([7.5, -2.1], index=[1, 2]), } feature_table = _FeatureTable.from_layer(properties=properties, num_data=2) expected = pd.DataFrame({'a': [1, 3], 'b': [7.5, -2.1]}, index=[0, 1]) pd.testing.assert_frame_equal(feature_table.values, expected) def test_feature_table_from_layer_with_unordered_pd_series_features(): features = { 'a': pd.Series([1, 3], index=[3, 4]), 'b': pd.Series([7.5, -2.1], index=[1, 2]), } feature_table = _FeatureTable.from_layer(features=features, num_data=2) expected = pd.DataFrame({'a': [1, 3], 'b': [7.5, -2.1]}, index=[0, 1]) pd.testing.assert_frame_equal(feature_table.values, expected) def test_register_label_attr_action(monkeypatch): monkeypatch.setattr(time, "time", lambda: 1) class Foo(KeymapProvider): def __init__(self) -> None: super().__init__() self.value = 0 foo = Foo() handler = KeymapHandler() handler.keymap_providers = [foo] @register_layer_attr_action(Foo, "value desc", "value", "K") def set_value_1(x): x.value = 1 handler.press_key("K") assert foo.value == 1 handler.release_key("K") assert foo.value == 1 foo.value = 0 handler.press_key("K") assert foo.value == 1 monkeypatch.setattr(time, "time", lambda: 2) handler.release_key("K") assert foo.value == 0 napari-0.5.0a1/napari/layers/utils/_tests/test_link_layers.py000066400000000000000000000122251437041365600243620ustar00rootroot00000000000000import numpy as np import pytest from napari import layers from napari.layers.utils._link_layers import ( layers_linked, link_layers, unlink_layers, ) BASE_ATTRS = {} BASE_ATTRS = { 'opacity': 0.75, 'blending': 'additive', 'visible': False, 'editable': False, 'shear': [30], } IM_ATTRS = { 'rendering': 'translucent', 'iso_threshold': 0.34, 'interpolation2d': 'linear', 'contrast_limits': [0.25, 0.75], 'gamma': 0.5, } @pytest.mark.parametrize('key, value', {**BASE_ATTRS, **IM_ATTRS}.items()) def test_link_image_layers_all_attributes(key, value): """Test linking common attributes across layers of similar types.""" l1 = layers.Image(np.random.rand(10, 10), contrast_limits=(0, 0.8)) l2 = layers.Image(np.random.rand(10, 10), contrast_limits=(0.1, 0.9)) link_layers([l1, l2]) # linking does (currently) apply to things that were unequal before linking assert l1.contrast_limits != l2.contrast_limits # once we set either... they will both be changed assert getattr(l1, key) != value setattr(l2, key, value) assert getattr(l1, key) == getattr(l2, key) == value @pytest.mark.parametrize('key, value', BASE_ATTRS.items()) def test_link_different_type_layers_all_attributes(key, value): """Test linking common attributes across layers of different types.""" l1 = layers.Image(np.random.rand(10, 10)) l2 = layers.Points(None) link_layers([l1, l2]) # once we set either... they will both be changed assert getattr(l1, key) != value setattr(l2, key, value) assert getattr(l1, key) == getattr(l2, key) == value def test_link_invalid_param(): """Test that linking non-shared attributes raises.""" l1 = layers.Image(np.random.rand(10, 10)) l2 = layers.Points(None) with pytest.raises(ValueError) as e: link_layers([l1, l2], ('rendering',)) assert "Cannot link attributes that are not shared by all layers" in str(e) def test_double_linking_noop(): """Test that linking already linked layers is a noop.""" l1 = layers.Points(None) l2 = layers.Points(None) l3 = layers.Points(None) # no callbacks to begin with assert len(l1.events.opacity.callbacks) == 0 # should have two after linking layers link_layers([l1, l2, l3]) assert len(l1.events.opacity.callbacks) == 2 # should STILL have two after linking layers again link_layers([l1, l2, l3]) assert len(l1.events.opacity.callbacks) == 2 def test_removed_linked_target(): """Test that linking already linked layers is a noop.""" l1 = layers.Points(None) l2 = layers.Points(None) l3 = layers.Points(None) link_layers([l1, l2, l3]) l1.opacity = 0.5 assert l1.opacity == l2.opacity == l3.opacity == 0.5 # if we delete layer3 we shouldn't get an error when updating otherlayers del l3 l1.opacity = 0.25 assert l1.opacity == l2.opacity def test_context_manager(): """Test that we can temporarily link layers.""" l1 = layers.Points(None) l2 = layers.Points(None) l3 = layers.Points(None) assert len(l1.events.opacity.callbacks) == 0 with layers_linked([l1, l2, l3], ('opacity',)): assert len(l1.events.opacity.callbacks) == 2 assert len(l1.events.blending.callbacks) == 0 # it's just opacity del l2 # if we lose a layer in the meantime it should be ok assert len(l1.events.opacity.callbacks) == 0 def test_unlink_layers(): """Test that we can unlink layers.""" l1 = layers.Points(None) l2 = layers.Points(None) l3 = layers.Points(None) link_layers([l1, l2, l3]) assert len(l1.events.opacity.callbacks) == 2 unlink_layers([l1, l2], ('opacity',)) # just unlink opacity on l1/l2 assert len(l1.events.opacity.callbacks) == 1 assert len(l2.events.opacity.callbacks) == 1 # l3 is still connected to them both assert len(l3.events.opacity.callbacks) == 2 # blending was untouched assert len(l1.events.blending.callbacks) == 2 assert len(l2.events.blending.callbacks) == 2 assert len(l3.events.blending.callbacks) == 2 unlink_layers([l1, l2, l3]) # unlink everything assert len(l1.events.blending.callbacks) == 0 assert len(l2.events.blending.callbacks) == 0 assert len(l3.events.blending.callbacks) == 0 def test_unlink_single_layer(): """Test that we can unlink a single layer from all others.""" l1 = layers.Points(None) l2 = layers.Points(None) l3 = layers.Points(None) link_layers([l1, l2, l3]) assert len(l1.events.opacity.callbacks) == 2 unlink_layers([l1], ('opacity',)) # just unlink L1 opacicity from others assert len(l1.events.opacity.callbacks) == 0 assert len(l2.events.opacity.callbacks) == 1 assert len(l3.events.opacity.callbacks) == 1 # blending was untouched assert len(l1.events.blending.callbacks) == 2 assert len(l2.events.blending.callbacks) == 2 assert len(l3.events.blending.callbacks) == 2 unlink_layers([l1]) # completely unlink L1 from everything assert not l1.events.blending.callbacks def test_mode_recursion(): l1 = layers.Points(None, name='l1') l2 = layers.Points(None, name='l2') link_layers([l1, l2]) l1.mode = 'add' napari-0.5.0a1/napari/layers/utils/_tests/test_plane.py000066400000000000000000000056301437041365600231470ustar00rootroot00000000000000import numpy as np import pytest from pydantic import ValidationError from napari.layers.utils.plane import ClippingPlaneList, Plane, SlicingPlane def test_plane_instantiation(): plane = Plane(position=(32, 32, 32), normal=(1, 0, 0), thickness=2) assert isinstance(plane, Plane) def test_plane_vector_normalisation(): plane = Plane(position=(0, 0, 0), normal=(5, 0, 0)) assert np.allclose(plane.normal, (1, 0, 0)) def test_plane_vector_setter(): plane = Plane(position=(0, 0, 0), normal=(1, 0, 0)) plane.normal = (1, 0, 0) def test_plane_from_points(): points = np.array([[0, 0, 0], [1, 0, 0], [0, 1, 0]]) plane = Plane.from_points(*points) assert isinstance(plane, Plane) assert plane.normal == (0, 0, 1) assert np.allclose(plane.position, np.mean(points, axis=0)) def test_update_slicing_plane_from_dict(): properties = { 'position': (0, 0, 0), 'normal': (1, 0, 0), } plane = SlicingPlane() plane.update(properties) for k, v in properties.items(): assert getattr(plane, k) == v def test_plane_from_array(): pos = (0, 0, 0) norm = (0, 0, 1) array = np.array([pos, norm]) plane = SlicingPlane.from_array(array) assert isinstance(plane, SlicingPlane) assert plane.position == pos assert plane.normal == norm def test_plane_to_array(): pos = (0, 0, 0) norm = (0, 0, 1) array = np.array([pos, norm]) plane = SlicingPlane(position=pos, normal=norm) assert np.allclose(plane.as_array(), array) def test_plane_3_tuple(): """Test for failure to instantiate with non 3-sequences of numbers""" with pytest.raises(ValidationError): plane = SlicingPlane( # noqa: F841 position=(32, 32, 32, 32), normal=(1, 0, 0, 0), ) def test_clipping_plane_list_instantiation(): plane_list = ClippingPlaneList() assert isinstance(plane_list, ClippingPlaneList) def test_clipping_plane_list_from_array(): pos = (0, 0, 0) norm = (0, 0, 1) array = np.array([pos, norm]) stacked = np.stack([array, array]) plane_list = ClippingPlaneList.from_array(stacked) assert isinstance(plane_list, ClippingPlaneList) assert plane_list[0].position == pos assert plane_list[1].position == pos assert plane_list[0].normal == norm assert plane_list[1].normal == norm def test_clipping_plane_list_as_array(): pos = (0, 0, 0) norm = (0, 0, 1) array = np.array([pos, norm]) stacked = np.stack([array, array]) plane_list = ClippingPlaneList.from_array(stacked) assert np.allclose(plane_list.as_array(), array) def test_clipping_plane_list_from_bounding_box(): center = (0, 0, 0) dims = (2, 2, 2) plane_list = ClippingPlaneList.from_bounding_box(center, dims) assert isinstance(plane_list, ClippingPlaneList) assert len(plane_list) == 6 assert plane_list.as_array().sum() == 0 # everything is mirrored around 0 napari-0.5.0a1/napari/layers/utils/_tests/test_stack_utils.py000066400000000000000000000203211437041365600243670ustar00rootroot00000000000000import numpy as np import pytest from napari.layers import Image from napari.layers.utils.stack_utils import ( images_to_stack, split_channels, stack_to_images, ) from napari.utils.transforms import Affine def test_stack_to_images_basic(): """Test that a 2 channel zcyx stack is split into 2 image layers""" data = np.random.randint(0, 100, (10, 2, 128, 128)) stack = Image(data) images = stack_to_images(stack, 1, colormap=None) assert isinstance(images, list) assert images[0].colormap.name == 'magenta' assert len(images) == 2 for i in images: assert type(stack) == type(i) assert i.data.shape == (10, 128, 128) def test_stack_to_images_multiscale(): """Test that a 3 channel multiscale image returns 3 multiscale images.""" data = list() data.append(np.random.randint(0, 200, (3, 128, 128))) data.append(np.random.randint(0, 200, (3, 64, 64))) data.append(np.random.randint(0, 200, (3, 32, 32))) data.append(np.random.randint(0, 200, (3, 16, 16))) stack = Image(data) images = stack_to_images(stack, 0) assert len(images) == 3 assert len(images[0].data) == 4 assert images[0].data[-1].shape[-1] == 16 assert images[1].data[-1].shape[-1] == 16 assert images[2].data[-1].shape[-1] == 16 def test_stack_to_images_rgb(): """Test 3 channel RGB image (channel axis = -1) into single channels.""" data = np.random.randint(0, 100, (10, 128, 128, 3)) stack = Image(data) images = stack_to_images(stack, -1, colormap=None) assert isinstance(images, list) assert len(images) == 3 for i in images: assert type(stack) == type(i) assert i.data.shape == (10, 128, 128) assert i.scale.shape == (3,) assert i.rgb is False def test_stack_to_images_4_channels(): """Test 4x128x128 stack is split into 4 channels w/ colormap keyword""" data = np.random.randint(0, 100, (4, 128, 128)) stack = Image(data) images = stack_to_images(stack, 0, colormap=['red', 'blue']) assert isinstance(images, list) assert len(images) == 4 assert images[-2].colormap.name == 'red' for i in images: assert type(stack) == type(i) assert i.data.shape == (128, 128) def test_stack_to_images_0_rgb(): """Split RGB along the first axis (z or t) so the images remain rgb""" data = np.random.randint(0, 100, (10, 128, 128, 3)) stack = Image(data) images = stack_to_images(stack, 0, colormap=None) assert isinstance(images, list) assert len(images) == 10 for i in images: assert i.rgb assert type(stack) == type(i) assert i.data.shape == (128, 128, 3) def test_stack_to_images_1_channel(): """Split when only one channel""" data = np.random.randint(0, 100, (10, 1, 128, 128)) stack = Image(data) images = stack_to_images(stack, 1, colormap=['magma']) assert isinstance(images, list) assert len(images) == 1 for i in images: assert i.rgb is False assert type(stack) == type(i) assert i.data.shape == (10, 128, 128) def test_images_to_stack_with_scale(): """Test that 3-Image list is combined to stack with scale and translate.""" images = [ Image(np.random.randint(0, 255, (10, 128, 128))) for _ in range(3) ] stack = images_to_stack( images, 1, colormap='green', scale=(3, 1, 1, 1), translate=(1, 0, 2, 3) ) assert isinstance(stack, Image) assert stack.data.shape == (10, 3, 128, 128) assert stack.colormap.name == 'green' assert list(stack.scale) == [3, 1, 1, 1] assert list(stack.translate) == [1, 0, 2, 3] def test_images_to_stack_none_scale(): """Test combining images using scale & translate from 1st image in list""" images = [ Image( np.random.randint(0, 255, (10, 128, 128)), scale=(4, 1, 1), translate=(0, -1, 2), ) for _ in range(3) ] stack = images_to_stack(images, 1, colormap='green') assert isinstance(stack, Image) assert stack.data.shape == (10, 3, 128, 128) assert stack.colormap.name == 'green' assert list(stack.scale) == [4, 1, 1, 1] assert list(stack.translate) == [0, 0, -1, 2] @pytest.fixture( params=[ { 'rgb': None, 'colormap': None, 'contrast_limits': None, 'gamma': 1, 'interpolation': 'nearest', 'rendering': 'mip', 'iso_threshold': 0.5, 'attenuation': 0.5, 'name': None, 'metadata': None, 'scale': None, 'translate': None, 'opacity': 1, 'blending': None, 'visible': True, 'multiscale': None, 'rotate': None, 'affine': None, }, { 'rgb': None, 'colormap': None, 'rendering': 'mip', 'attenuation': 0.5, 'metadata': None, 'scale': None, 'opacity': 1, 'visible': True, 'multiscale': None, }, {}, ], ids=['full-kwargs', 'partial-kwargs', 'empty-kwargs'], ) def kwargs(request): return request.param def test_split_channels(kwargs): """Test split_channels with shape (3,128,128) expecting 3 (128,128)""" data = np.random.randint(0, 200, (3, 128, 128)) result_list = split_channels(data, 0, **kwargs) assert len(result_list) == 3 for d, _meta, _ in result_list: assert d.shape == (128, 128) def test_split_channels_multiscale(kwargs): """Test split_channels with multiscale expecting List[LayerData]""" data = list() data.append(np.random.randint(0, 200, (3, 128, 128))) data.append(np.random.randint(0, 200, (3, 64, 64))) data.append(np.random.randint(0, 200, (3, 32, 32))) data.append(np.random.randint(0, 200, (3, 16, 16))) result_list = split_channels(data, 0, **kwargs) assert len(result_list) == 3 for ds, m, _ in result_list: assert m['multiscale'] is True assert ds[0].shape == (128, 128) assert ds[1].shape == (64, 64) assert ds[2].shape == (32, 32) assert ds[3].shape == (16, 16) def test_split_channels_blending(kwargs): """Test split_channels with shape (3,128,128) expecting 3 (128,128)""" kwargs['blending'] = 'translucent' data = np.random.randint(0, 200, (3, 128, 128)) result_list = split_channels(data, 0, **kwargs) assert len(result_list) == 3 for d, meta, _ in result_list: assert d.shape == (128, 128) assert meta['blending'] == 'translucent' def test_split_channels_missing_keywords(): data = np.random.randint(0, 200, (3, 128, 128)) result_list = split_channels(data, 0) assert len(result_list) == 3 for chan, layer in enumerate(result_list): assert layer[0].shape == (128, 128) assert ( layer[1]['blending'] == 'translucent_no_depth' if chan == 0 else 'additive' ) def test_split_channels_affine_nparray(kwargs): kwargs['affine'] = np.eye(3) data = np.random.randint(0, 200, (3, 128, 128)) result_list = split_channels(data, 0, **kwargs) assert len(result_list) == 3 for d, meta, _ in result_list: assert d.shape == (128, 128) assert np.array_equal(meta['affine'], np.eye(3)) def test_split_channels_affine_napari(kwargs): kwargs['affine'] = Affine(affine_matrix=np.eye(3)) data = np.random.randint(0, 200, (3, 128, 128)) result_list = split_channels(data, 0, **kwargs) assert len(result_list) == 3 for d, meta, _ in result_list: assert d.shape == (128, 128) assert np.array_equal(meta['affine'].affine_matrix, np.eye(3)) def test_split_channels_multi_affine_napari(kwargs): kwargs['affine'] = [ Affine(scale=[1, 1]), Affine(scale=[2, 2]), Affine(scale=[3, 3]), ] data = np.random.randint(0, 200, (3, 128, 128)) result_list = split_channels(data, 0, **kwargs) assert len(result_list) == 3 for idx, result_data in enumerate(result_list): d, meta, _ = result_data assert d.shape == (128, 128) assert np.array_equal( meta['affine'].affine_matrix, Affine(scale=[idx + 1, idx + 1]).affine_matrix, ) napari-0.5.0a1/napari/layers/utils/_tests/test_string_encoding.py000066400000000000000000000120501437041365600252160ustar00rootroot00000000000000import numpy as np import pandas as pd import pytest from napari.layers.utils.string_encoding import ( ConstantStringEncoding, DirectStringEncoding, FormatStringEncoding, ManualStringEncoding, StringEncoding, ) def make_features_with_no_columns(*, num_rows) -> pd.DataFrame: return pd.DataFrame({}, index=range(num_rows)) @pytest.fixture def features() -> pd.DataFrame: return pd.DataFrame( { 'class': ['a', 'b', 'c'], 'confidence': [0.5, 1, 0.25], } ) @pytest.fixture def numeric_features() -> pd.DataFrame: return pd.DataFrame( { 'label': [1, 2, 3], 'confidence': [0.5, 1, 0.25], } ) def test_constant_call_with_no_rows(): features = make_features_with_no_columns(num_rows=0) encoding = ConstantStringEncoding(constant='abc') values = encoding(features) np.testing.assert_equal(values, 'abc') def test_constant_call_with_some_rows(): features = make_features_with_no_columns(num_rows=3) encoding = ConstantStringEncoding(constant='abc') values = encoding(features) np.testing.assert_equal(values, 'abc') def test_manual_call_with_no_rows(): features = make_features_with_no_columns(num_rows=0) array = ['a', 'b', 'c'] default = 'd' encoding = ManualStringEncoding(array=array, default=default) values = encoding(features) np.testing.assert_array_equal(values, np.array([], dtype=str)) def test_manual_call_with_fewer_rows(): features = make_features_with_no_columns(num_rows=2) array = ['a', 'b', 'c'] default = 'd' encoding = ManualStringEncoding(array=array, default=default) values = encoding(features) np.testing.assert_array_equal(values, ['a', 'b']) def test_manual_call_with_same_rows(): features = make_features_with_no_columns(num_rows=3) array = ['a', 'b', 'c'] default = 'd' encoding = ManualStringEncoding(array=array, default=default) values = encoding(features) np.testing.assert_array_equal(values, ['a', 'b', 'c']) def test_manual_with_more_rows(): features = make_features_with_no_columns(num_rows=4) array = ['a', 'b', 'c'] default = 'd' encoding = ManualStringEncoding(array=array, default=default) values = encoding(features) np.testing.assert_array_equal(values, ['a', 'b', 'c', 'd']) def test_direct(features): encoding = DirectStringEncoding(feature='class') values = encoding(features) np.testing.assert_array_equal(values, features['class']) def test_direct_with_a_missing_feature(features): encoding = DirectStringEncoding(feature='not_class') with pytest.raises(KeyError): encoding(features) def test_format(features): encoding = FormatStringEncoding(format='{class}: {confidence:.2f}') values = encoding(features) np.testing.assert_array_equal(values, ['a: 0.50', 'b: 1.00', 'c: 0.25']) def test_format_with_bad_string(features): encoding = FormatStringEncoding(format='{class}: {confidence:.2f') with pytest.raises(ValueError): encoding(features) def test_format_with_missing_field(features): encoding = FormatStringEncoding(format='{class}: {score:.2f}') with pytest.raises(KeyError): encoding(features) def test_format_with_mixed_feature_numeric_types(numeric_features): encoding = FormatStringEncoding(format='{label:d}: {confidence:.2f}') values = encoding(numeric_features) np.testing.assert_array_equal(values, ['1: 0.50', '2: 1.00', '3: 0.25']) def test_validate_from_format_string(): argument = '{class}: {score:.2f}' expected = FormatStringEncoding(format=argument) actual = StringEncoding.validate(argument) assert actual == expected def test_validate_from_non_format_string(): argument = 'abc' expected = DirectStringEncoding(feature=argument) actual = StringEncoding.validate(argument) assert actual == expected def test_validate_from_sequence(): argument = ['a', 'b', 'c'] expected = ManualStringEncoding(array=argument) actual = StringEncoding.validate(argument) assert actual == expected def test_validate_from_constant_dict(): constant = 'test' argument = {'constant': constant} expected = ConstantStringEncoding(constant=constant) actual = StringEncoding.validate(argument) assert actual == expected def test_validate_from_manual_dict(): array = ['a', 'b', 'c'] default = 'd' argument = {'array': array, 'default': default} expected = ManualStringEncoding(array=array, default=default) actual = StringEncoding.validate(argument) assert actual == expected def test_validate_from_direct_dict(): feature = 'class' argument = {'feature': feature} expected = DirectStringEncoding(feature=feature) actual = StringEncoding.validate(argument) assert actual == expected def test_validate_from_format_dict(): format = '{class}: {score:.2f}' argument = {'format': format} expected = FormatStringEncoding(format=format) actual = StringEncoding.validate(argument) assert actual == expected napari-0.5.0a1/napari/layers/utils/_tests/test_style_encoding.py000066400000000000000000000214271437041365600250600ustar00rootroot00000000000000""" These tests cover and help explain the implementations of different types of generic encodings, like constant, manual, and derived encodings, rather than the types of values they encode like strings and colors or the ways those are encoded. In particular, these cover the stateful part of the StyleEncoding, which is important to napari at the time of writing, but may be removed in the future. """ from typing import Any, Union import numpy as np import pandas as pd import pytest from pydantic import Field from napari.layers.utils.style_encoding import ( _ConstantStyleEncoding, _DerivedStyleEncoding, _ManualStyleEncoding, ) from napari.utils.events.custom_types import Array @pytest.fixture def features() -> pd.DataFrame: return pd.DataFrame( { 'scalar': [1, 2, 3], 'vector': [[1, 1], [2, 2], [3, 3]], } ) Scalar = Array[int, ()] ScalarArray = Array[int, (-1,)] class ScalarConstantEncoding(_ConstantStyleEncoding[Scalar, ScalarArray]): constant: Scalar def test_scalar_constant_encoding_apply(features): encoding = ScalarConstantEncoding(constant=0) encoding._apply(features) np.testing.assert_array_equal(encoding._values, 0) def test_scalar_constant_encoding_append(): encoding = ScalarConstantEncoding(constant=0) encoding._append(Vector.validate_type([4, 5])) np.testing.assert_array_equal(encoding._values, 0) def test_scalar_constant_encoding_delete(): encoding = ScalarConstantEncoding(constant=0) encoding._delete([0, 2]) np.testing.assert_array_equal(encoding._values, 0) def test_scalar_constant_encoding_clear(): encoding = ScalarConstantEncoding(constant=0) encoding._clear() np.testing.assert_array_equal(encoding._values, 0) class ScalarManualEncoding(_ManualStyleEncoding[Scalar, ScalarArray]): array: ScalarArray default: Scalar = np.array(-1) def test_scalar_manual_encoding_apply_with_shorter(features): encoding = ScalarManualEncoding(array=[1, 2, 3, 4]) encoding._apply(features) np.testing.assert_array_equal(encoding._values, [1, 2, 3]) def test_scalar_manual_encoding_apply_with_equal_length(features): encoding = ScalarManualEncoding(array=[1, 2, 3]) encoding._apply(features) np.testing.assert_array_equal(encoding._values, [1, 2, 3]) def test_scalar_manual_encoding_apply_with_longer(features): encoding = ScalarManualEncoding(array=[1, 2], default=-1) encoding._apply(features) np.testing.assert_array_equal(encoding._values, [1, 2, -1]) def test_scalar_manual_encoding_append(): encoding = ScalarManualEncoding(array=[1, 2, 3]) encoding._append(Vector.validate_type([4, 5])) np.testing.assert_array_equal(encoding._values, [1, 2, 3, 4, 5]) def test_scalar_manual_encoding_delete(): encoding = ScalarManualEncoding(array=[1, 2, 3]) encoding._delete([0, 2]) np.testing.assert_array_equal(encoding._values, [2]) def test_scalar_manual_encoding_clear(): encoding = ScalarManualEncoding(array=[1, 2, 3]) encoding._clear() np.testing.assert_array_equal(encoding._values, [1, 2, 3]) class ScalarDirectEncoding(_DerivedStyleEncoding[Scalar, ScalarArray]): feature: str fallback: Scalar = np.array(-1) def __call__(self, features: Any) -> ScalarArray: return ScalarArray.validate_type(features[self.feature]) def test_scalar_derived_encoding_apply(features): encoding = ScalarDirectEncoding(feature='scalar') encoding._apply(features) expected_values = features['scalar'] np.testing.assert_array_equal(encoding._values, expected_values) def test_scalar_derived_encoding_apply_with_failure(features): encoding = ScalarDirectEncoding(feature='not_a_column', fallback=-1) with pytest.warns(RuntimeWarning): encoding._apply(features) np.testing.assert_array_equal(encoding._values, [-1] * len(features)) def test_scalar_derived_encoding_append(): encoding = ScalarDirectEncoding(feature='scalar') encoding._cached = ScalarArray.validate_type([1, 2, 3]) encoding._append(ScalarArray.validate_type([4, 5])) np.testing.assert_array_equal(encoding._values, [1, 2, 3, 4, 5]) def test_scalar_derived_encoding_delete(): encoding = ScalarDirectEncoding(feature='scalar') encoding._cached = ScalarArray.validate_type([1, 2, 3]) encoding._delete([0, 2]) np.testing.assert_array_equal(encoding._values, [2]) def test_scalar_derived_encoding_clear(): encoding = ScalarDirectEncoding(feature='scalar') encoding._cached = ScalarArray.validate_type([1, 2, 3]) encoding._clear() np.testing.assert_array_equal(encoding._values, []) Vector = Array[int, (2,)] VectorArray = Array[int, (-1, 2)] class VectorConstantEncoding(_ConstantStyleEncoding[Vector, VectorArray]): constant: Vector def test_vector_constant_encoding_apply(features): encoding = VectorConstantEncoding(constant=[0, 0]) encoding._apply(features) np.testing.assert_array_equal(encoding._values, [0, 0]) def test_vector_constant_encoding_append(): encoding = VectorConstantEncoding(constant=[0, 0]) encoding._append(Vector.validate_type([4, 5])) np.testing.assert_array_equal(encoding._values, [0, 0]) def test_vector_constant_encoding_delete(): encoding = VectorConstantEncoding(constant=[0, 0]) encoding._delete([0, 2]) np.testing.assert_array_equal(encoding._values, [0, 0]) def test_vector_constant_encoding_clear(): encoding = VectorConstantEncoding(constant=[0, 0]) encoding._clear() np.testing.assert_array_equal(encoding._values, [0, 0]) class VectorManualEncoding(_ManualStyleEncoding[Vector, VectorArray]): array: VectorArray default: Vector = Field(default_factory=lambda: np.array([-1, -1])) def test_vector_manual_encoding_apply_with_shorter(features): encoding = VectorManualEncoding(array=[[1, 1], [2, 2], [3, 3], [4, 4]]) encoding._apply(features) np.testing.assert_array_equal(encoding._values, [[1, 1], [2, 2], [3, 3]]) def test_vector_manual_encoding_apply_with_equal_length(features): encoding = VectorManualEncoding(array=[[1, 1], [2, 2], [3, 3]]) encoding._apply(features) np.testing.assert_array_equal(encoding._values, [[1, 1], [2, 2], [3, 3]]) def test_vector_manual_encoding_apply_with_longer(features): encoding = VectorManualEncoding(array=[[1, 1], [2, 2]], default=[-1, -1]) encoding._apply(features) np.testing.assert_array_equal(encoding._values, [[1, 1], [2, 2], [-1, -1]]) def test_vector_manual_encoding_append(): encoding = VectorManualEncoding(array=[[1, 1], [2, 2], [3, 3]]) encoding._append(Vector.validate_type([[4, 4], [5, 5]])) np.testing.assert_array_equal( encoding._values, [[1, 1], [2, 2], [3, 3], [4, 4], [5, 5]] ) def test_vector_manual_encoding_delete(): encoding = VectorManualEncoding(array=[[1, 1], [2, 2], [3, 3]]) encoding._delete([0, 2]) np.testing.assert_array_equal(encoding._values, [[2, 2]]) def test_vector_manual_encoding_clear(): encoding = VectorManualEncoding(array=[[1, 1], [2, 2], [3, 3]]) encoding._clear() np.testing.assert_array_equal(encoding._values, [[1, 1], [2, 2], [3, 3]]) class VectorDirectEncoding(_DerivedStyleEncoding[Vector, VectorArray]): feature: str fallback: Vector = Field(default_factory=lambda: np.array([-1, -1])) def __call__(self, features: Any) -> Union[Vector, VectorArray]: return VectorArray.validate_type(list(features[self.feature])) def test_vector_derived_encoding_apply(features): encoding = VectorDirectEncoding(feature='vector') encoding._apply(features) expected_values = list(features['vector']) np.testing.assert_array_equal(encoding._values, expected_values) def test_vector_derived_encoding_apply_with_failure(features): encoding = VectorDirectEncoding(feature='not_a_column', fallback=[-1, -1]) with pytest.warns(RuntimeWarning): encoding._apply(features) np.testing.assert_array_equal(encoding._values, [[-1, -1]] * len(features)) def test_vector_derived_encoding_append(): encoding = VectorDirectEncoding(feature='vector') encoding._cached = VectorArray.validate_type([[1, 1], [2, 2], [3, 3]]) encoding._append(VectorArray.validate_type([[4, 4], [5, 5]])) np.testing.assert_array_equal( encoding._values, [[1, 1], [2, 2], [3, 3], [4, 4], [5, 5]] ) def test_vector_derived_encoding_delete(): encoding = VectorDirectEncoding(feature='vector') encoding._cached = VectorArray.validate_type([[1, 1], [2, 2], [3, 3]]) encoding._delete([0, 2]) np.testing.assert_array_equal(encoding._values, [[2, 2]]) def test_vector_derived_encoding_clear(): encoding = VectorDirectEncoding(feature='vector') encoding._cached = VectorArray.validate_type([[1, 1], [2, 2], [3, 3]]) encoding._clear() np.testing.assert_array_equal(encoding._values, np.empty((0, 2))) napari-0.5.0a1/napari/layers/utils/_tests/test_text_manager.py000066400000000000000000000572421437041365600245340ustar00rootroot00000000000000from itertools import permutations import numpy as np import pandas as pd import pytest from pydantic import ValidationError from napari._tests.utils import assert_colors_equal from napari.layers.utils._slice_input import _SliceInput from napari.layers.utils.string_encoding import ( ConstantStringEncoding, FormatStringEncoding, ManualStringEncoding, ) from napari.layers.utils.text_manager import TextManager @pytest.mark.filterwarnings('ignore::DeprecationWarning') def test_empty_text_manager_property(): """Test creating an empty text manager in property mode. This is for creating an empty layer with text initialized. """ properties = {'confidence': np.empty(0, dtype=float)} text_manager = TextManager( text='confidence', n_text=0, properties=properties ) assert text_manager.values.size == 0 # add a text element new_properties = {'confidence': np.array([0.5])} text_manager.add(new_properties, 1) np.testing.assert_equal(text_manager.values, ['0.5']) @pytest.mark.filterwarnings('ignore::DeprecationWarning') def test_add_many_text_property(): properties = {'confidence': np.empty(0, dtype=float)} text_manager = TextManager( text='confidence', n_text=0, properties=properties, ) text_manager.add({'confidence': np.array([0.5])}, 2) np.testing.assert_equal(text_manager.values, ['0.5'] * 2) @pytest.mark.filterwarnings('ignore::DeprecationWarning') def test_empty_text_manager_format(): """Test creating an empty text manager in formatted mode. This is for creating an empty layer with text initialized. """ properties = {'confidence': np.empty(0, dtype=float)} text = 'confidence: {confidence:.2f}' text_manager = TextManager(text=text, n_text=0, properties=properties) assert text_manager.values.size == 0 # add a text element new_properties = {'confidence': np.array([0.5])} text_manager.add(new_properties, 1) np.testing.assert_equal(text_manager.values, ['confidence: 0.50']) @pytest.mark.filterwarnings('ignore::DeprecationWarning') def test_add_many_text_formatted(): properties = {'confidence': np.empty(0, dtype=float)} text_manager = TextManager( text='confidence: {confidence:.2f}', n_text=0, properties=properties, ) text_manager.add({'confidence': np.array([0.5])}, 2) np.testing.assert_equal(text_manager.values, ['confidence: 0.50'] * 2) @pytest.mark.filterwarnings('ignore::DeprecationWarning') def test_text_manager_property(): n_text = 3 text = 'class' classes = np.array(['A', 'B', 'C']) properties = {'class': classes, 'confidence': np.array([0.5, 0.3, 1])} text_manager = TextManager(text=text, n_text=n_text, properties=properties) np.testing.assert_equal(text_manager.values, classes) # add new text with properties new_properties = {'class': np.array(['A']), 'confidence': np.array([0.5])} text_manager.add(new_properties, 1) expected_text_2 = np.concatenate([classes, ['A']]) np.testing.assert_equal(text_manager.values, expected_text_2) # remove the first text element text_manager.remove({0}) np.testing.assert_equal(text_manager.values, expected_text_2[1::]) @pytest.mark.filterwarnings('ignore::DeprecationWarning') def test_text_manager_format(): n_text = 3 text = 'confidence: {confidence:.2f}' classes = np.array(['A', 'B', 'C']) properties = {'class': classes, 'confidence': np.array([0.5, 0.3, 1])} expected_text = np.array( ['confidence: 0.50', 'confidence: 0.30', 'confidence: 1.00'] ) text_manager = TextManager(text=text, n_text=n_text, properties=properties) np.testing.assert_equal(text_manager.values, expected_text) # add new text with properties new_properties = {'class': np.array(['A']), 'confidence': np.array([0.5])} text_manager.add(new_properties, 1) expected_text_2 = np.concatenate([expected_text, ['confidence: 0.50']]) np.testing.assert_equal(text_manager.values, expected_text_2) # test getting the text elements when there are none in view text_view = text_manager.view_text([]) np.testing.assert_equal(text_view, np.empty((0,), dtype=str)) # test getting the text elements when the first two elements are in view text_view = text_manager.view_text([0, 1]) np.testing.assert_equal(text_view, expected_text_2[0:2]) text_manager.anchor = 'center' coords = np.array([[0, 0], [10, 10], [20, 20]]) text_coords = text_manager.compute_text_coords(coords, ndisplay=3) np.testing.assert_equal(text_coords, (coords, 'center', 'center')) # remove the first text element text_manager.remove({0}) np.testing.assert_equal(text_manager.values, expected_text_2[1::]) @pytest.mark.filterwarnings('ignore::DeprecationWarning') def test_refresh_text(): n_text = 3 text = 'class' classes = np.array(['A', 'B', 'C']) properties = {'class': classes, 'confidence': np.array([0.5, 0.3, 1])} text_manager = TextManager(text=text, n_text=n_text, properties=properties) new_classes = np.array(['D', 'E', 'F']) new_properties = { 'class': new_classes, 'confidence': np.array([0.5, 0.3, 1]), } text_manager.refresh_text(new_properties) np.testing.assert_equal(new_classes, text_manager.values) @pytest.mark.filterwarnings('ignore::DeprecationWarning') def test_equality(): n_text = 3 text = 'class' classes = np.array(['A', 'B', 'C']) properties = {'class': classes, 'confidence': np.array([0.5, 0.3, 1])} text_manager_1 = TextManager( text=text, n_text=n_text, properties=properties, color='red', ) text_manager_2 = TextManager( text=text, n_text=n_text, properties=properties, color='red', ) assert text_manager_1 == text_manager_2 assert not (text_manager_1 != text_manager_2) text_manager_2.color = 'blue' assert text_manager_1 != text_manager_2 assert not (text_manager_1 == text_manager_2) @pytest.mark.filterwarnings('ignore::DeprecationWarning') def test_blending_modes(): n_text = 3 text = 'class' classes = np.array(['A', 'B', 'C']) properties = {'class': classes, 'confidence': np.array([0.5, 0.3, 1])} text_manager = TextManager( text=text, n_text=n_text, properties=properties, color='red', blending='translucent', ) assert text_manager.blending == 'translucent' # set to another valid blending mode text_manager.blending = 'additive' assert text_manager.blending == 'additive' # set to opaque, which is not allowed with pytest.warns(RuntimeWarning): text_manager.blending = 'opaque' assert text_manager.blending == 'translucent' @pytest.mark.filterwarnings('ignore::DeprecationWarning') def test_text_with_invalid_format_string_then_fallback_with_warning(): n_text = 3 text = 'confidence: {confidence:.2f' properties = {'confidence': np.array([0.5, 0.3, 1])} with pytest.warns(RuntimeWarning): text_manager = TextManager( text=text, n_text=n_text, properties=properties ) np.testing.assert_array_equal(text_manager.values, [''] * n_text) @pytest.mark.filterwarnings('ignore::DeprecationWarning') def test_text_with_format_string_missing_property_then_fallback_with_warning(): n_text = 3 text = 'score: {score:.2f}' properties = {'confidence': np.array([0.5, 0.3, 1])} with pytest.warns(RuntimeWarning): text_manager = TextManager( text=text, n_text=n_text, properties=properties ) np.testing.assert_array_equal(text_manager.values, [''] * n_text) @pytest.mark.filterwarnings('ignore::DeprecationWarning') def test_text_constant_then_repeat_values(): n_text = 3 properties = {'class': np.array(['A', 'B', 'C'])} text_manager = TextManager( text={'constant': 'point'}, n_text=n_text, properties=properties ) np.testing.assert_array_equal(text_manager.values, ['point'] * n_text) @pytest.mark.filterwarnings('ignore::DeprecationWarning') def test_text_constant_with_no_properties(): text_manager = TextManager(text={'constant': 'point'}, n_text=3) np.testing.assert_array_equal(text_manager.values, ['point'] * 3) @pytest.mark.filterwarnings('ignore::DeprecationWarning') def test_add_with_text_constant(): n_text = 3 properties = {'class': np.array(['A', 'B', 'C'])} text_manager = TextManager( text={'constant': 'point'}, n_text=n_text, properties=properties ) np.testing.assert_array_equal(text_manager.values, ['point'] * 3) text_manager.add({'class': np.array(['C'])}, 2) np.testing.assert_array_equal(text_manager.values, ['point'] * 5) @pytest.mark.filterwarnings('ignore::DeprecationWarning') def test_add_with_text_constant_init_empty(): properties = {} text_manager = TextManager( text={'constant': 'point'}, n_text=0, properties=properties ) text_manager.add({'class': np.array(['C'])}, 2) np.testing.assert_array_equal(text_manager.values, ['point'] * 2) @pytest.mark.filterwarnings('ignore::DeprecationWarning') def test_remove_with_text_constant_then_ignored(): n_text = 5 properties = {'class': np.array(['A', 'B', 'C', 'D', 'E'])} text_manager = TextManager( text={'constant': 'point'}, n_text=n_text, properties=properties ) text_manager.remove([1, 3]) np.testing.assert_array_equal(text_manager.values, ['point'] * n_text) def test_from_layer(): text = { 'string': 'class', 'translation': [-0.5, 1], 'visible': False, } features = pd.DataFrame( { 'class': np.array(['A', 'B', 'C']), 'confidence': np.array([1, 0.5, 0]), } ) text_manager = TextManager._from_layer( text=text, features=features, ) np.testing.assert_array_equal(text_manager.values, ['A', 'B', 'C']) np.testing.assert_array_equal(text_manager.translation, [-0.5, 1]) assert not text_manager.visible def test_from_layer_with_no_text(): features = pd.DataFrame({}) text_manager = TextManager._from_layer( text=None, features=features, ) assert text_manager.string == ConstantStringEncoding(constant='') def test_update_from_layer(): text = { 'string': 'class', 'translation': [-0.5, 1], 'visible': False, } features = pd.DataFrame( { 'class': ['A', 'B', 'C'], 'confidence': [1, 0.5, 0], } ) text_manager = TextManager._from_layer( text=text, features=features, ) text = { 'string': 'Conf: {confidence:.2f}', 'translation': [1.5, -2], 'size': 9000, } text_manager._update_from_layer(text=text, features=features) np.testing.assert_array_equal( text_manager.values, ['Conf: 1.00', 'Conf: 0.50', 'Conf: 0.00'] ) np.testing.assert_array_equal(text_manager.translation, [1.5, -2]) assert text_manager.visible assert text_manager.size == 9000 def test_update_from_layer_with_invalid_value_fails_safely(): features = pd.DataFrame( { 'class': ['A', 'B', 'C'], 'confidence': [1, 0.5, 0], } ) text_manager = TextManager._from_layer( text='class', features=features, ) before = text_manager.copy(deep=True) text = { 'string': 'confidence', 'size': -3, } with pytest.raises(ValidationError): text_manager._update_from_layer(text=text, features=features) assert text_manager == before def test_update_from_layer_with_warning_only_one_emitted(): features = pd.DataFrame({'class': ['A', 'B', 'C']}) text_manager = TextManager._from_layer( text='class', features=features, ) text = { 'string': 'class', 'blending': 'opaque', } with pytest.warns(RuntimeWarning) as record: text_manager._update_from_layer( text=text, features=features, ) assert len(record) == 1 def test_init_with_constant_string(): text_manager = TextManager(string={'constant': 'A'}) assert text_manager.string == ConstantStringEncoding(constant='A') np.testing.assert_array_equal(text_manager.values, 'A') def test_init_with_manual_string(): features = pd.DataFrame(index=range(3)) text_manager = TextManager(string=['A', 'B', 'C'], features=features) assert text_manager.string == ManualStringEncoding(array=['A', 'B', 'C']) np.testing.assert_array_equal(text_manager.values, ['A', 'B', 'C']) def test_init_with_format_string(): features = pd.DataFrame({'class': ['A', 'B', 'C']}) text_manager = TextManager(string='class: {class}', features=features) assert text_manager.string == FormatStringEncoding(format='class: {class}') np.testing.assert_array_equal( text_manager.values, ['class: A', 'class: B', 'class: C'] ) def test_apply_with_constant_string(): features = pd.DataFrame(index=range(3)) text_manager = TextManager(string={'constant': 'A'}) features = pd.DataFrame(index=range(5)) text_manager.apply(features) np.testing.assert_array_equal(text_manager.values, 'A') def test_apply_with_manual_string(): string = { 'array': ['A', 'B', 'C'], 'default': 'D', } features = pd.DataFrame(index=range(3)) text_manager = TextManager(string=string, features=features) features = pd.DataFrame(index=range(5)) text_manager.apply(features) np.testing.assert_array_equal( text_manager.values, ['A', 'B', 'C', 'D', 'D'] ) def test_apply_with_derived_string(): features = pd.DataFrame({'class': ['A', 'B', 'C']}) text_manager = TextManager(string='class: {class}', features=features) features = pd.DataFrame({'class': ['A', 'B', 'C', 'D', 'E']}) text_manager.apply(features) np.testing.assert_array_equal( text_manager.values, ['class: A', 'class: B', 'class: C', 'class: D', 'class: E'], ) def test_refresh_with_constant_string(): features = pd.DataFrame(index=range(3)) text_manager = TextManager(string={'constant': 'A'}) text_manager.string = {'constant': 'B'} text_manager.refresh(features) np.testing.assert_array_equal(text_manager.values, 'B') def test_refresh_with_manual_string(): features = pd.DataFrame(index=range(3)) text_manager = TextManager(string=['A', 'B', 'C'], features=features) text_manager.string = ['C', 'B', 'A'] text_manager.refresh(features) np.testing.assert_array_equal(text_manager.values, ['C', 'B', 'A']) def test_refresh_with_derived_string(): features = pd.DataFrame({'class': ['A', 'B', 'C']}) text_manager = TextManager(string='class: {class}', features=features) features = pd.DataFrame({'class': ['E', 'D', 'C', 'B', 'A']}) text_manager.refresh(features) np.testing.assert_array_equal( text_manager.values, ['class: E', 'class: D', 'class: C', 'class: B', 'class: A'], ) def test_copy_paste_with_constant_string(): features = pd.DataFrame(index=range(3)) text_manager = TextManager(string={'constant': 'A'}, features=features) copied = text_manager._copy([0, 2]) text_manager._paste(**copied) np.testing.assert_array_equal(text_manager.values, 'A') def test_copy_paste_with_manual_string(): features = pd.DataFrame(index=range(3)) text_manager = TextManager(string=['A', 'B', 'C'], features=features) copied = text_manager._copy([0, 2]) text_manager._paste(**copied) np.testing.assert_array_equal( text_manager.values, ['A', 'B', 'C', 'A', 'C'] ) def test_copy_paste_with_derived_string(): features = pd.DataFrame({'class': ['A', 'B', 'C']}) text_manager = TextManager(string='class: {class}', features=features) copied = text_manager._copy([0, 2]) text_manager._paste(**copied) np.testing.assert_array_equal( text_manager.values, ['class: A', 'class: B', 'class: C', 'class: A', 'class: C'], ) def test_serialization(): features = pd.DataFrame( {'class': ['A', 'B', 'C'], 'confidence': [0.5, 0.3, 1]} ) original = TextManager(features=features, string='class', color='red') serialized = original.dict() deserialized = TextManager(**serialized) assert original == deserialized def test_view_text_with_constant_text(): features = pd.DataFrame(index=range(3)) text_manager = TextManager(string={'constant': 'A'}, features=features) copied = text_manager._copy([0, 2]) text_manager._paste(**copied) actual = text_manager.view_text([0, 1]) # view_text promises to return an Nx1 array, not just something # broadcastable to an Nx1, so explicitly check the length # because assert_array_equal broadcasts scalars automatically assert len(actual) == 2 np.testing.assert_array_equal(actual, ['A', 'A']) def test_init_with_constant_color(): color = {'constant': 'red'} features = pd.DataFrame(index=range(3)) text_manager = TextManager(color=color, features=features) actual = text_manager.color._values assert_colors_equal(actual, 'red') def test_init_with_manual_color(): color = ['red', 'green', 'blue'] features = pd.DataFrame({'class': ['A', 'B', 'C']}) text_manager = TextManager(color=color, features=features) actual = text_manager.color._values assert_colors_equal(actual, ['red', 'green', 'blue']) def test_init_with_derived_color(): color = {'feature': 'colors'} features = pd.DataFrame({'colors': ['red', 'green', 'blue']}) text_manager = TextManager(color=color, features=features) actual = text_manager.color._values assert_colors_equal(actual, ['red', 'green', 'blue']) def test_init_with_derived_color_missing_feature_then_use_fallback(): color = {'feature': 'not_a_feature', 'fallback': 'cyan'} features = pd.DataFrame({'colors': ['red', 'green', 'blue']}) with pytest.warns(RuntimeWarning): text_manager = TextManager(color=color, features=features) actual = text_manager.color._values assert_colors_equal(actual, ['cyan'] * 3) def test_apply_with_constant_color(): color = {'constant': 'red'} features = pd.DataFrame({'class': ['A', 'B', 'C']}) text_manager = TextManager(color=color, features=features) features = pd.DataFrame({'class': ['A', 'B', 'C', 'D', 'E']}) text_manager.apply(features) actual = text_manager.color._values assert_colors_equal(actual, 'red') def test_apply_with_manual_color_then_use_default(): color = { 'array': ['red', 'green', 'blue'], 'default': 'yellow', } features = pd.DataFrame({'class': ['A', 'B', 'C']}) text_manager = TextManager(color=color, features=features) features = pd.DataFrame({'class': ['A', 'B', 'C', 'D', 'E']}) text_manager.apply(features) actual = text_manager.color._values assert_colors_equal(actual, ['red', 'green', 'blue', 'yellow', 'yellow']) def test_apply_with_derived_color(): color = {'feature': 'colors'} features = pd.DataFrame({'colors': ['red', 'green', 'blue']}) text_manager = TextManager(color=color, features=features) features = pd.DataFrame( {'colors': ['red', 'green', 'blue', 'yellow', 'cyan']} ) text_manager.apply(features) actual = text_manager.color._values assert_colors_equal(actual, ['red', 'green', 'blue', 'yellow', 'cyan']) def test_refresh_with_constant_color(): color = {'constant': 'red'} features = pd.DataFrame(index=range(3)) text_manager = TextManager(color=color, features=features) text_manager.color = {'constant': 'yellow'} text_manager.refresh(features) actual = text_manager.color._values assert_colors_equal(actual, 'yellow') def test_refresh_with_manual_color(): color = ['red', 'green', 'blue'] features = pd.DataFrame(index=range(3)) text_manager = TextManager(color=color, features=features) text_manager.color = ['green', 'cyan', 'yellow'] text_manager.refresh(features) actual = text_manager.color._values assert_colors_equal(actual, ['green', 'cyan', 'yellow']) def test_refresh_with_derived_color(): color = {'feature': 'colors'} features = pd.DataFrame({'colors': ['red', 'green', 'blue']}) text_manager = TextManager(color=color, features=features) features = pd.DataFrame({'colors': ['green', 'yellow', 'magenta']}) text_manager.refresh(features) actual = text_manager.color._values assert_colors_equal(actual, ['green', 'yellow', 'magenta']) def test_copy_paste_with_constant_color(): color = {'constant': 'blue'} features = pd.DataFrame(index=range(3)) text_manager = TextManager(color=color, features=features) copied = text_manager._copy([0, 2]) text_manager._paste(**copied) actual = text_manager.color._values assert_colors_equal(actual, 'blue') def test_copy_paste_with_manual_color(): color = ['magenta', 'red', 'yellow'] features = pd.DataFrame(index=range(3)) text_manager = TextManager(color=color, features=features) copied = text_manager._copy([0, 2]) text_manager._paste(**copied) actual = text_manager.color._values assert_colors_equal( actual, ['magenta', 'red', 'yellow', 'magenta', 'yellow'] ) def test_copy_paste_with_derived_color(): color = {'feature': 'colors'} features = pd.DataFrame({'colors': ['green', 'red', 'magenta']}) text_manager = TextManager(color=color, features=features) copied = text_manager._copy([0, 2]) text_manager._paste(**copied) actual = text_manager.color._values assert_colors_equal( actual, ['green', 'red', 'magenta', 'green', 'magenta'] ) @pytest.mark.parametrize( ('ndim', 'ndisplay', 'translation'), ( (2, 2, 0), # 2D data and display, no translation (2, 3, 0), # 2D data and 3D display, no translation (2, 2, 0), # 3D data and display, no translation (2, 2, 5.2), # 2D data and display, constant translation (2, 3, 5.2), # 2D data and 3D display, constant translation (2, 2, 5.2), # 3D data and display, constant translation (2, 2, [5.2, -3.2]), # 2D data, display, translation (2, 3, [5.2, -3.2]), # 2D data, 3D display, 2D translation (3, 3, [5.2, -3.2, 0.1]), # 3D data, display, translation ), ) def test_compute_text_coords(ndim, ndisplay, translation): """See https://github.com/napari/napari/issues/5111""" num_points = 3 text_manager = TextManager( features=pd.DataFrame(index=range(num_points)), translation=translation, ) np.random.seed(0) # Cannot just use `rand(num_points, ndisplay)` because when # ndim < ndisplay, we need to get ndim data which is what # what layers are doing (e.g. see `Points._view_data`). coords = np.random.rand(num_points, ndim)[-ndisplay:] text_coords, _, _ = text_manager.compute_text_coords( coords, ndisplay=ndisplay ) expected_coords = coords + translation np.testing.assert_equal(text_coords, expected_coords) @pytest.mark.parametrize(('order'), permutations((0, 1, 2))) def test_compute_text_coords_with_3D_data_2D_display(order): """See https://github.com/napari/napari/issues/5111""" num_points = 3 translation = np.array([5.2, -3.2, 0.1]) text_manager = TextManager( features=pd.DataFrame(index=range(num_points)), translation=translation, ) slice_input = _SliceInput(ndisplay=2, point=(0.0,) * 3, order=order) np.random.seed(0) coords = np.random.rand(num_points, slice_input.ndisplay) text_coords, _, _ = text_manager.compute_text_coords( coords, ndisplay=slice_input.ndisplay, order=slice_input.displayed, ) expected_coords = coords + translation[slice_input.displayed] np.testing.assert_equal(text_coords, expected_coords) napari-0.5.0a1/napari/layers/utils/_tests/test_text_utils.py000066400000000000000000000100621437041365600242470ustar00rootroot00000000000000import numpy as np import pytest from napari.layers.utils._text_constants import Anchor from napari.layers.utils._text_utils import ( _calculate_anchor_center, _calculate_anchor_lower_left, _calculate_anchor_lower_right, _calculate_anchor_upper_left, _calculate_anchor_upper_right, _calculate_bbox_centers, _calculate_bbox_extents, get_text_anchors, ) coords = np.array([[0, 0], [10, 0], [0, 10], [10, 10]]) view_data_list = [coords] view_data_ndarray = coords @pytest.mark.parametrize( "view_data,expected_coords", [(view_data_list, [[5, 5]]), (view_data_ndarray, coords)], ) def test_bbox_center(view_data, expected_coords): """Unit test for _calculate_anchor_center. Roundtrip test in test_get_text_anchors""" anchor_data = _calculate_anchor_center(view_data, ndisplay=2) expected_anchor_data = (expected_coords, 'center', 'center') np.testing.assert_equal(anchor_data, expected_anchor_data) @pytest.mark.parametrize( "view_data,expected_coords", [(view_data_list, [[0, 0]]), (view_data_ndarray, coords)], ) def test_bbox_upper_left(view_data, expected_coords): """Unit test for _calculate_anchor_upper_left. Roundtrip test in test_get_text_anchors""" expected_anchor_data = (expected_coords, 'left', 'top') anchor_data = _calculate_anchor_upper_left(view_data, ndisplay=2) np.testing.assert_equal(anchor_data, expected_anchor_data) @pytest.mark.parametrize( "view_data,expected_coords", [(view_data_list, [[0, 10]]), (view_data_ndarray, coords)], ) def test_bbox_upper_right(view_data, expected_coords): """Unit test for _calculate_anchor_upper_right. Roundtrip test in test_get_text_anchors""" expected_anchor_data = (expected_coords, 'right', 'top') anchor_data = _calculate_anchor_upper_right(view_data, ndisplay=2) np.testing.assert_equal(anchor_data, expected_anchor_data) @pytest.mark.parametrize( "view_data,expected_coords", [(view_data_list, [[10, 0]]), (view_data_ndarray, coords)], ) def test_bbox_lower_left(view_data, expected_coords): """Unit test for _calculate_anchor_lower_left. Roundtrip test in test_get_text_anchors""" expected_anchor_data = (expected_coords, 'left', 'bottom') anchor_data = _calculate_anchor_lower_left(view_data, ndisplay=2) np.testing.assert_equal(anchor_data, expected_anchor_data) @pytest.mark.parametrize( "view_data,expected_coords", [(view_data_list, [[10, 10]]), (view_data_ndarray, coords)], ) def test_bbox_lower_right(view_data, expected_coords): """Unit test for _calculate_anchor_lower_right. Roundtrip test in test_get_text_anchors""" expected_anchor_data = (expected_coords, 'right', 'bottom') anchor_data = _calculate_anchor_lower_right(view_data, ndisplay=2) np.testing.assert_equal(anchor_data, expected_anchor_data) @pytest.mark.parametrize( "anchor_type,ndisplay,expected_coords", [ (Anchor.CENTER, 2, [[5, 5]]), (Anchor.UPPER_LEFT, 2, [[0, 0]]), (Anchor.UPPER_RIGHT, 2, [[0, 10]]), (Anchor.LOWER_LEFT, 2, [[10, 0]]), (Anchor.LOWER_RIGHT, 2, [[10, 10]]), (Anchor.CENTER, 3, [[5, 5]]), (Anchor.UPPER_LEFT, 3, [[5, 5]]), (Anchor.UPPER_RIGHT, 3, [[5, 5]]), (Anchor.LOWER_LEFT, 3, [[5, 5]]), (Anchor.LOWER_RIGHT, 3, [[5, 5]]), ], ) def test_get_text_anchors(anchor_type, ndisplay, expected_coords): """Round trip tests for getting anchor coordinates.""" coords = [np.array([[0, 0], [10, 0], [0, 10], [10, 10]])] anchor_coords, _, _ = get_text_anchors( coords, anchor=anchor_type, ndisplay=ndisplay ) np.testing.assert_equal(anchor_coords, expected_coords) def test_bbox_centers_exception(): """_calculate_bbox_centers should raise a TypeError for non ndarray or list inputs""" with pytest.raises(TypeError): _ = _calculate_bbox_centers({'bad_data_type': True}) def test_bbox_extents_exception(): """_calculate_bbox_extents should raise a TypeError for non ndarray or list inputs""" with pytest.raises(TypeError): _ = _calculate_bbox_extents({'bad_data_type': True}) napari-0.5.0a1/napari/layers/utils/_text_constants.py000066400000000000000000000012251437041365600227230ustar00rootroot00000000000000from enum import auto from napari.utils.misc import StringEnum class Anchor(StringEnum): """ Anchor: The anchor position for text CENTER The text origin is centered on the layer item bounding box. UPPER_LEFT The text origin is on the upper left corner of the bounding box UPPER_RIGHT The text origin is on the upper right corner of the bounding box LOWER_LEFT The text origin is on the lower left corner of the bounding box LOWER_RIGHT The text origin is on the lower right corner of the bounding box """ CENTER = auto() UPPER_LEFT = auto() UPPER_RIGHT = auto() LOWER_LEFT = auto() LOWER_RIGHT = auto() napari-0.5.0a1/napari/layers/utils/_text_utils.py000066400000000000000000000113371437041365600220540ustar00rootroot00000000000000from typing import Tuple, Union import numpy as np from napari.layers.utils._text_constants import Anchor from napari.utils.translations import trans def get_text_anchors( view_data: Union[np.ndarray, list], ndisplay: int, anchor: Anchor = Anchor.CENTER, ) -> np.ndarray: # Explicitly convert to an Anchor so that string values can be used. text_anchor_func = TEXT_ANCHOR_CALCULATION[Anchor(anchor)] text_coords, anchor_x, anchor_y = text_anchor_func(view_data, ndisplay) return text_coords, anchor_x, anchor_y def _calculate_anchor_center( view_data: Union[np.ndarray, list], ndisplay: int ) -> Tuple[np.ndarray, str, str]: text_coords = _calculate_bbox_centers(view_data) anchor_x = 'center' anchor_y = 'center' return text_coords, anchor_x, anchor_y def _calculate_bbox_centers(view_data: Union[np.ndarray, list]) -> np.ndarray: if isinstance(view_data, np.ndarray): if view_data.ndim == 2: # if the data are a list of coordinates, just return the coord (e.g., points) bbox_centers = view_data else: bbox_centers = np.mean(view_data, axis=0) elif isinstance(view_data, list): bbox_centers = np.array( [np.mean(coords, axis=0) for coords in view_data] ) else: raise TypeError( trans._( 'view_data should be a numpy array or list when using Anchor.CENTER', deferred=True, ) ) return bbox_centers def _calculate_anchor_upper_left( view_data: Union[np.ndarray, list], ndisplay: int ) -> Tuple[np.ndarray, str, str]: if ndisplay == 2: bbox_min, bbox_max = _calculate_bbox_extents(view_data) text_anchors = np.array([bbox_min[:, 0], bbox_min[:, 1]]).T anchor_x = 'left' anchor_y = 'top' else: # in 3D, use centered anchor text_anchors, anchor_x, anchor_y = _calculate_anchor_center( view_data, ndisplay ) return text_anchors, anchor_x, anchor_y def _calculate_anchor_upper_right( view_data: Union[np.ndarray, list], ndisplay: int ) -> Tuple[np.ndarray, str, str]: if ndisplay == 2: bbox_min, bbox_max = _calculate_bbox_extents(view_data) text_anchors = np.array([bbox_min[:, 0], bbox_max[:, 1]]).T anchor_x = 'right' anchor_y = 'top' else: # in 3D, use centered anchor text_anchors, anchor_x, anchor_y = _calculate_anchor_center( view_data, ndisplay ) return text_anchors, anchor_x, anchor_y def _calculate_anchor_lower_left( view_data: Union[np.ndarray, list], ndisplay: int ) -> Tuple[np.ndarray, str, str]: if ndisplay == 2: bbox_min, bbox_max = _calculate_bbox_extents(view_data) text_anchors = np.array([bbox_max[:, 0], bbox_min[:, 1]]).T anchor_x = 'left' anchor_y = 'bottom' else: # in 3D, use centered anchor text_anchors, anchor_x, anchor_y = _calculate_anchor_center( view_data, ndisplay ) return text_anchors, anchor_x, anchor_y def _calculate_anchor_lower_right( view_data: Union[np.ndarray, list], ndisplay: int ) -> Tuple[np.ndarray, str, str]: if ndisplay == 2: bbox_min, bbox_max = _calculate_bbox_extents(view_data) text_anchors = np.array([bbox_max[:, 0], bbox_max[:, 1]]).T anchor_x = 'right' anchor_y = 'bottom' else: # in 3D, use centered anchor text_anchors, anchor_x, anchor_y = _calculate_anchor_center( view_data, ndisplay ) return text_anchors, anchor_x, anchor_y def _calculate_bbox_extents(view_data: Union[np.ndarray, list]) -> np.ndarray: """Calculate the extents of the bounding box""" if isinstance(view_data, np.ndarray): if view_data.ndim == 2: # if the data are a list of coordinates, just return the coord (e.g., points) bbox_min = view_data bbox_max = view_data else: bbox_min = np.min(view_data, axis=0) bbox_max = np.max(view_data, axis=0) elif isinstance(view_data, list): bbox_min = np.array([np.min(coords, axis=0) for coords in view_data]) bbox_max = np.array([np.max(coords, axis=0) for coords in view_data]) else: raise TypeError( trans._( 'view_data should be a numpy array or list', deferred=True, ) ) return bbox_min, bbox_max TEXT_ANCHOR_CALCULATION = { Anchor.CENTER: _calculate_anchor_center, Anchor.UPPER_LEFT: _calculate_anchor_upper_left, Anchor.UPPER_RIGHT: _calculate_anchor_upper_right, Anchor.LOWER_LEFT: _calculate_anchor_lower_left, Anchor.LOWER_RIGHT: _calculate_anchor_lower_right, } napari-0.5.0a1/napari/layers/utils/color_encoding.py000066400000000000000000000177301437041365600225000ustar00rootroot00000000000000from typing import ( Any, Literal, Optional, Protocol, Tuple, Union, runtime_checkable, ) import numpy as np from pydantic import Field, parse_obj_as, validator from napari.layers.utils.color_transformations import ColorType from napari.layers.utils.style_encoding import ( StyleEncoding, _ConstantStyleEncoding, _DerivedStyleEncoding, _ManualStyleEncoding, ) from napari.utils import Colormap from napari.utils.color import ColorArray, ColorValue from napari.utils.colormaps import ValidColormapArg, ensure_colormap from napari.utils.colormaps.categorical_colormap import CategoricalColormap from napari.utils.translations import trans """The default color to use, which may also be used a safe fallback color.""" DEFAULT_COLOR = ColorValue.validate('cyan') @runtime_checkable class ColorEncoding(StyleEncoding[ColorValue, ColorArray], Protocol): """Encodes colors from features.""" @classmethod def __get_validators__(cls): yield cls.validate @classmethod def validate( cls, value: Union['ColorEncoding', dict, str, ColorType] ) -> 'ColorEncoding': """Validates and coerces a value to a ColorEncoding. Parameters ---------- value : ColorEncodingArgument The value to validate and coerce. If this is already a ColorEncoding, it is returned as is. If this is a dict, then it should represent one of the built-in color encodings. If this a string, then a DirectColorEncoding is returned. If this a single color, a ConstantColorEncoding is returned. If this is a sequence of colors, a ManualColorEncoding is returned. Returns ------- ColorEncoding Raises ------ TypeError If the value is not a supported type. ValidationError If the value cannot be parsed into a ColorEncoding. """ if isinstance(value, ColorEncoding): return value if isinstance(value, dict): return parse_obj_as( Union[ ConstantColorEncoding, ManualColorEncoding, DirectColorEncoding, NominalColorEncoding, QuantitativeColorEncoding, ], value, ) try: color_array = ColorArray.validate(value) except (ValueError, AttributeError, KeyError) as e: raise TypeError( trans._( 'value should be a ColorEncoding, a dict, a color, or a sequence of colors', deferred=True, ) ) from e if color_array.shape[0] == 1: return ConstantColorEncoding(constant=value) return ManualColorEncoding(array=color_array, default=DEFAULT_COLOR) class ConstantColorEncoding(_ConstantStyleEncoding[ColorValue, ColorArray]): """Encodes color values from a single constant color. Attributes ---------- constant : ColorValue The constant color RGBA value. """ encoding_type: Literal['ConstantColorEncoding'] = 'ConstantColorEncoding' constant: ColorValue class ManualColorEncoding(_ManualStyleEncoding[ColorValue, ColorArray]): """Encodes color values manually in an array attribute. Attributes ---------- array : ColorArray The array of color values. Can be written to directly to make persistent updates. default : ColorValue The default color value. """ encoding_type: Literal['ManualColorEncoding'] = 'ManualColorEncoding' array: ColorArray default: ColorValue = Field(default_factory=lambda: DEFAULT_COLOR) class DirectColorEncoding(_DerivedStyleEncoding[ColorValue, ColorArray]): """Encodes color values directly from a feature column. Attributes ---------- feature : str The name of the feature that contains the desired color values. fallback : ColorArray The safe constant fallback color to use if the feature column does not contain valid color values. """ encoding_type: Literal['DirectColorEncoding'] = 'DirectColorEncoding' feature: str fallback: ColorValue = Field(default_factory=lambda: DEFAULT_COLOR) def __call__(self, features: Any) -> ColorArray: # A column-like may be a series or have an object dtype (e.g. color names), # neither of which transform_color handles, so convert to a list. return ColorArray.validate(list(features[self.feature])) class NominalColorEncoding(_DerivedStyleEncoding[ColorValue, ColorArray]): """Encodes color values from a nominal feature whose values are mapped to colors. Attributes ---------- feature : str The name of the feature that contains the nominal values to be mapped to colors. colormap : CategoricalColormap Maps the feature values to colors. fallback : ColorValue The safe constant fallback color to use if mapping the feature values to colors fails. """ encoding_type: Literal['NominalColorEncoding'] = 'NominalColorEncoding' feature: str colormap: CategoricalColormap fallback: ColorValue = Field(default_factory=lambda: DEFAULT_COLOR) def __call__(self, features: Any) -> ColorArray: # map is not expecting some column-likes (e.g. pandas.Series), so ensure # this is a numpy array first. values = np.asarray(features[self.feature]) return self.colormap.map(values) class QuantitativeColorEncoding(_DerivedStyleEncoding[ColorValue, ColorArray]): """Encodes color values from a quantitative feature whose values are mapped to colors. Attributes ---------- feature : str The name of the feature that contains the nominal values to be mapped to colors. colormap : Colormap Maps feature values to colors. contrast_limits : Optional[Tuple[float, float]] The (min, max) feature values that should respectively map to the first and last colors in the colormap. If None, then this will attempt to calculate these values from the feature values each time this generates color values. If that attempt fails, these are effectively (0, 1). fallback : ColorValue The safe constant fallback color to use if mapping the feature values to colors fails. """ encoding_type: Literal[ 'QuantitativeColorEncoding' ] = 'QuantitativeColorEncoding' feature: str colormap: Colormap contrast_limits: Optional[Tuple[float, float]] = None fallback: ColorValue = Field(default_factory=lambda: DEFAULT_COLOR) def __call__(self, features: Any) -> ColorArray: values = features[self.feature] contrast_limits = self.contrast_limits or _calculate_contrast_limits( values ) if contrast_limits is not None: values = np.interp(values, contrast_limits, (0, 1)) return self.colormap.map(values) @validator('colormap', pre=True, always=True) def _check_colormap(cls, colormap: ValidColormapArg) -> Colormap: return ensure_colormap(colormap) @validator('contrast_limits', pre=True, always=True) def _check_contrast_limits( cls, contrast_limits ) -> Optional[Tuple[float, float]]: if (contrast_limits is not None) and ( contrast_limits[0] >= contrast_limits[1] ): raise ValueError( trans._( 'contrast_limits must be a strictly increasing pair of values', deferred=True, ) ) return contrast_limits def _calculate_contrast_limits( values: np.ndarray, ) -> Optional[Tuple[float, float]]: contrast_limits = None if values.size > 0: min_value = np.min(values) max_value = np.max(values) # Use < instead of != to handle nans. if min_value < max_value: contrast_limits = (min_value, max_value) return contrast_limits napari-0.5.0a1/napari/layers/utils/color_manager.py000066400000000000000000000547351437041365600223320ustar00rootroot00000000000000from copy import deepcopy from dataclasses import dataclass from typing import Any, Dict, Optional, Tuple, Union import numpy as np from pydantic import Field, root_validator, validator from napari.layers.utils._color_manager_constants import ColorMode from napari.layers.utils.color_manager_utils import ( _validate_colormap_mode, _validate_cycle_mode, guess_continuous, is_color_mapped, ) from napari.layers.utils.color_transformations import ( normalize_and_broadcast_colors, transform_color, transform_color_with_defaults, ) from napari.utils.colormaps import Colormap from napari.utils.colormaps.categorical_colormap import CategoricalColormap from napari.utils.colormaps.colormap_utils import ColorType, ensure_colormap from napari.utils.events import EventedModel from napari.utils.events.custom_types import Array from napari.utils.translations import trans @dataclass class ColorProperties: """The property values that are used for setting colors in ColorMode.COLORMAP and ColorMode.CYCLE. Attributes ---------- name : str The name of the property being used. values : np.ndarray The array containing the property values. current_value : Optional[Any] the value for the next item to be added. """ name: str values: np.ndarray current_value: Optional[Any] = None @classmethod def __get_validators__(cls): yield cls.validate_type @classmethod def validate_type(cls, val): if val is None: color_properties = val elif isinstance(val, dict): if len(val) == 0: color_properties = None else: try: # ensure the values are a numpy array val['values'] = np.asarray(val['values']) color_properties = cls(**val) except ValueError as e: raise ValueError( trans._( 'color_properties dictionary should have keys: name, values, and optionally current_value', deferred=True, ) ) from e elif isinstance(val, cls): color_properties = val else: raise TypeError( trans._( 'color_properties should be None, a dict, or ColorProperties object', deferred=True, ) ) return color_properties def _json_encode(self): return { 'name': self.name, 'values': self.values.tolist(), 'current_value': self.current_value, } def __eq__(self, other): if isinstance(other, ColorProperties): name_eq = self.name == other.name values_eq = np.array_equal(self.values, other.values) current_value_eq = np.array_equal( self.current_value, other.current_value ) return np.all([name_eq, values_eq, current_value_eq]) else: return False class ColorManager(EventedModel): """A class for controlling the display colors for annotations in napari. Attributes ---------- current_color : Optional[np.ndarray] A (4,) color array for the color of the next items to be added. mode : ColorMode The mode for setting colors. ColorMode.DIRECT: colors are set by passing color values to ColorManager.colors ColorMode.COLORMAP: colors are set via the continuous_colormap applied to the color_properties ColorMode.CYCLE: colors are set vie the categorical_colormap appied to the color_properties. This should be used for categorical properties only. color_properties : Optional[ColorProperties] The property values that are used for setting colors in ColorMode.COLORMAP and ColorMode.CYCLE. The ColorProperties dataclass has 3 fields: name, values, and current_value. name (str) is the name of the property being used. values (np.ndarray) is an array containing the property values. current_value contains the value for the next item to be added. color_properties can be set as either a ColorProperties object or a dictionary where the keys are the field values and the values are the field values (i.e., a dictionary that would be valid in ColorProperties(**input_dictionary) ). continuous_colormap : Colormap The napari colormap object used in ColorMode.COLORMAP mode. This can also be set using the name of a known colormap as a string. contrast_limits : Tuple[float, float] The min and max value for the colormap being applied to the color_properties in ColorMonde.COLORMAP mode. Set as a tuple (min, max). categorical_colormap : CategoricalColormap The napari CategoricalColormap object used in ColorMode.CYCLE mode. To set a direct mapping between color_property values and colors, pass a dictionary where the keys are the property values and the values are colors (either string names or (4,) color arrays). To use a color cycle, pass a list or array of colors. You can also pass the CategoricalColormap keyword arguments as a dictionary. colors : np.ndarray The colors in a Nx4 color array, where N is the number of colors. """ # fields current_color: Optional[Array[float, (4,)]] = None color_mode: ColorMode = ColorMode.DIRECT color_properties: Optional[ColorProperties] = None continuous_colormap: Colormap = ensure_colormap('viridis') contrast_limits: Optional[Tuple[float, float]] = None categorical_colormap: CategoricalColormap = CategoricalColormap.from_array( [0, 0, 0, 1] ) colors: Array[float, (-1, 4)] = Field( default_factory=lambda: np.empty((0, 4)) ) # validators @validator('continuous_colormap', pre=True) def _ensure_continuous_colormap(cls, v): return ensure_colormap(v) @validator('colors', pre=True) def _ensure_color_array(cls, v): if len(v) > 0: return transform_color(v) else: return np.empty((0, 4)) @validator('current_color', pre=True) def _coerce_current_color(cls, v): if v is None: return v elif len(v) == 0: return None else: return transform_color(v)[0] @root_validator() def _validate_colors(cls, values): color_mode = values['color_mode'] if color_mode == ColorMode.CYCLE: colors, values = _validate_cycle_mode(values) elif color_mode == ColorMode.COLORMAP: colors, values = _validate_colormap_mode(values) elif color_mode == ColorMode.DIRECT: colors = values['colors'] # FIXME Local variable 'colors' might be referenced before assignment # set the current color to the last color/property value # if it wasn't already set if values['current_color'] is None and len(colors) > 0: values['current_color'] = colors[-1] if color_mode in [ColorMode.CYCLE, ColorMode.COLORMAP]: property_values = values['color_properties'] property_values.current_value = property_values.values[-1] values['color_properties'] = property_values values['colors'] = colors return values def _set_color( self, color: ColorType, n_colors: int, properties: Dict[str, np.ndarray], current_properties: Dict[str, np.ndarray], ): """Set a color property. This is convenience function Parameters ---------- color : (N, 4) array or str The value for setting edge or face_color n_colors : int The number of colors that needs to be set. Typically len(data). properties : Dict[str, np.ndarray] The layer property values current_properties : Dict[str, np.ndarray] The layer current property values """ # if the provided color is a string, first check if it is a key in the properties. # otherwise, assume it is the name of a color if is_color_mapped(color, properties): # note that we set ColorProperties.current_value by indexing rather than # np.squeeze since the current_property values have shape (1,) and # np.squeeze would return an array with shape (). # see https://github.com/napari/napari/pull/3110#discussion_r680680779 self.color_properties = ColorProperties( name=color, values=properties[color], current_value=current_properties[color][0], ) if guess_continuous(properties[color]): self.color_mode = ColorMode.COLORMAP else: self.color_mode = ColorMode.CYCLE else: transformed_color = transform_color_with_defaults( num_entries=n_colors, colors=color, elem_name="color", default="white", ) colors = normalize_and_broadcast_colors( n_colors, transformed_color ) self.color_mode = ColorMode.DIRECT self.colors = colors def _refresh_colors( self, properties: Dict[str, np.ndarray], update_color_mapping: bool = False, ): """Calculate and update colors if using a cycle or color map Parameters ---------- properties : Dict[str, np.ndarray] The layer properties to use to update the colors. update_color_mapping : bool If set to True, the function will recalculate the color cycle map or colormap (whichever is being used). If set to False, the function will use the current color cycle map or color map. For example, if you are adding/modifying points and want them to be colored with the same mapping as the other points (i.e., the new points shouldn't affect the color cycle map or colormap), set update_color_mapping=False. Default value is False. """ if self.color_mode in [ColorMode.CYCLE, ColorMode.COLORMAP]: property_name = self.color_properties.name current_value = self.color_properties.current_value property_values = properties[property_name] self.color_properties = ColorProperties( name=property_name, values=property_values, current_value=current_value, ) if update_color_mapping is True: self.contrast_limits = None self.events.color_properties() def _add( self, color: Optional[ColorType] = None, n_colors: int = 1, update_clims: bool = False, ): """Add colors Parameters ---------- color : Optional[ColorType] The color to add. If set to None, the value of self.current_color will be used. The default value is None. n_colors : int The number of colors to add. The default value is 1. update_clims : bool If in colormap mode, update the contrast limits when adding the new values (i.e., reset the range to 0-new_max_value). """ if self.color_mode == ColorMode.DIRECT: if color is None: new_color = self.current_color else: new_color = color transformed_color = transform_color_with_defaults( num_entries=n_colors, colors=new_color, elem_name="color", default="white", ) broadcasted_colors = normalize_and_broadcast_colors( n_colors, transformed_color ) self.colors = np.concatenate((self.colors, broadcasted_colors)) else: # add the new value color_properties color_property_name = self.color_properties.name current_value = self.color_properties.current_value if color is None: color = current_value new_color_property_values = np.concatenate( (self.color_properties.values, np.repeat(color, n_colors)), axis=0, ) self.color_properties = ColorProperties( name=color_property_name, values=new_color_property_values, current_value=current_value, ) if update_clims and self.color_mode == ColorMode.COLORMAP: self.contrast_limits = None def _remove(self, indices_to_remove: Union[set, list, np.ndarray]): """Remove the indicated color elements Parameters ---------- indices_to_remove : set, list, np.ndarray The indices of the text elements to remove. """ selected_indices = list(indices_to_remove) if len(selected_indices) > 0: if self.color_mode == ColorMode.DIRECT: self.colors = np.delete(self.colors, selected_indices, axis=0) else: # remove the color_properties color_property_name = self.color_properties.name current_value = self.color_properties.current_value new_color_property_values = np.delete( self.color_properties.values, selected_indices ) self.color_properties = ColorProperties( name=color_property_name, values=new_color_property_values, current_value=current_value, ) def _paste(self, colors: np.ndarray, properties: Dict[str, np.ndarray]): """Append colors to the ColorManager. Uses the color values if in direct mode and the properties in colormap or cycle mode. This method is for compatibility with the paste functionality in the layers. Parameters ---------- colors : np.ndarray The (Nx4) color array of color values to add. These values are only used if the color mode is direct. properties : Dict[str, np.ndarray] The property values to add. These are used if the color mode is colormap or cycle. """ if self.color_mode == ColorMode.DIRECT: self.colors = np.concatenate( (self.colors, transform_color(colors)) ) else: color_property_name = self.color_properties.name current_value = self.color_properties.current_value old_properties = self.color_properties.values values_to_add = properties[color_property_name] new_color_property_values = np.concatenate( (old_properties, values_to_add), axis=0, ) self.color_properties = ColorProperties( name=color_property_name, values=new_color_property_values, current_value=current_value, ) def _update_current_properties( self, current_properties: Dict[str, np.ndarray] ): """This is updates the current_value of the color_properties when the layer current_properties is updated. This is a convenience method that is generally only called by the layer. Parameters ---------- current_properties : Dict[str, np.ndarray] The new current property values """ if self.color_properties is not None: current_property_name = self.color_properties.name current_property_values = self.color_properties.values if current_property_name in current_properties: # note that we set ColorProperties.current_value by indexing rather than # np.squeeze since the current_property values have shape (1,) and # np.squeeze would return an array with shape (). # see https://github.com/napari/napari/pull/3110#discussion_r680680779 new_current_value = current_properties[current_property_name][ 0 ] if new_current_value != self.color_properties.current_value: self.color_properties = ColorProperties( name=current_property_name, values=current_property_values, current_value=new_current_value, ) def _update_current_color( self, current_color: np.ndarray, update_indices: list = () ): """Update the current color and update the colors if requested. This is a convenience method and is generally called by the layer. Parameters ---------- current_color : np.ndarray The new current color value. update_indices : list The indices of the color elements to update. If the list has length 0, no colors are updated. If the ColorManager is not in DIRECT mode, updating the values will change the mode to DIRECT. """ self.current_color = transform_color(current_color)[0] if update_indices: self.color_mode = ColorMode.DIRECT cur_colors = self.colors.copy() cur_colors[update_indices] = self.current_color self.colors = cur_colors @classmethod def _from_layer_kwargs( cls, colors: Union[dict, str, np.ndarray], properties: Dict[str, np.ndarray], n_colors: Optional[int] = None, continuous_colormap: Optional[Union[str, Colormap]] = None, contrast_limits: Optional[Tuple[float, float]] = None, categorical_colormap: Optional[ Union[CategoricalColormap, list, np.ndarray] ] = None, color_mode: Optional[Union[ColorMode, str]] = None, current_color: Optional[np.ndarray] = None, default_color_cycle: ColorType = None, ): """Initialize a ColorManager object from layer kwargs. This is a convenience function to coerce possible inputs into ColorManager kwargs """ if default_color_cycle is None: default_color_cycle = np.array([1, 1, 1, 1]) properties = {k: np.asarray(v) for k, v in properties.items()} if isinstance(colors, dict): # if the kwargs are passed as a dictionary, unpack them color_values = colors.get('colors', None) current_color = colors.get('current_color', current_color) color_mode = colors.get('color_mode', color_mode) color_properties = colors.get('color_properties', None) continuous_colormap = colors.get( 'continuous_colormap', continuous_colormap ) contrast_limits = colors.get('contrast_limits', contrast_limits) categorical_colormap = colors.get( 'categorical_colormap', categorical_colormap ) if isinstance(color_properties, str): # if the color properties were given as a property name, # coerce into ColorProperties try: prop_values = properties[color_properties] prop_name = color_properties color_properties = ColorProperties( name=prop_name, values=prop_values ) except KeyError as e: raise KeyError( trans._( 'if color_properties is a string, it should be a property name', deferred=True, ) ) from e else: color_values = colors color_properties = None if categorical_colormap is None: categorical_colormap = deepcopy(default_color_cycle) color_kwargs = { 'categorical_colormap': categorical_colormap, 'continuous_colormap': continuous_colormap, 'contrast_limits': contrast_limits, 'current_color': current_color, 'n_colors': n_colors, } if color_properties is None: if is_color_mapped(color_values, properties): if n_colors == 0: color_properties = ColorProperties( name=color_values, values=np.empty( 0, dtype=properties[color_values].dtype ), current_value=properties[color_values][0], ) else: color_properties = ColorProperties( name=color_values, values=properties[color_values] ) if color_mode is None: if guess_continuous(color_properties.values): color_mode = ColorMode.COLORMAP else: color_mode = ColorMode.CYCLE color_kwargs.update( { 'color_mode': color_mode, 'color_properties': color_properties, } ) else: # direct mode if n_colors == 0: if current_color is None: current_color = transform_color(color_values)[0] color_kwargs.update( { 'color_mode': ColorMode.DIRECT, 'current_color': current_color, } ) else: transformed_color = transform_color_with_defaults( num_entries=n_colors, colors=color_values, elem_name="colors", default="white", ) colors = normalize_and_broadcast_colors( n_colors, transformed_color ) color_kwargs.update( {'color_mode': ColorMode.DIRECT, 'colors': colors} ) else: color_kwargs.update( { 'color_mode': color_mode, 'color_properties': color_properties, } ) return cls(**color_kwargs) napari-0.5.0a1/napari/layers/utils/color_manager_utils.py000066400000000000000000000112531437041365600235360ustar00rootroot00000000000000from typing import Any, Dict, Tuple, Union import numpy as np from napari.utils.colormaps import Colormap from napari.utils.translations import trans def guess_continuous(property: np.ndarray) -> bool: """Guess if the property is continuous (return True) or categorical (return False) The property is guessed as continuous if it is a float or contains over 16 elements. Parameters ---------- property : np.ndarray The property values to guess if they are continuous Returns ------- continuous : bool True of the property is guessed to be continuous, False if not. """ # if the property is a floating type, guess continuous if ( issubclass(property.dtype.type, np.floating) or len(np.unique(property)) > 16 ): return True else: return False def is_color_mapped(color, properties): """determines if the new color argument is for directly setting or cycle/colormap""" if isinstance(color, str): if color in properties: return True else: return False elif isinstance(color, dict): return True elif isinstance(color, (list, np.ndarray)): return False else: raise ValueError( trans._( 'face_color should be the name of a color, an array of colors, or the name of an property', deferred=True, ) ) def map_property( prop: np.ndarray, colormap: Colormap, contrast_limits: Union[None, Tuple[float, float]] = None, ) -> Tuple[np.ndarray, Tuple[float, float]]: """Apply a colormap to a property Parameters ---------- prop : np.ndarray The property to be colormapped colormap : napari.utils.Colormap The colormap object to apply to the property contrast_limits : Union[None, Tuple[float, float]] The contrast limits for applying the colormap to the property. If a 2-tuple is provided, it should be provided as (lower_bound, upper_bound). If None is provided, the contrast limits will be set to (property.min(), property.max()). Default value is None. """ if contrast_limits is None: contrast_limits = (prop.min(), prop.max()) normalized_properties = np.interp(prop, contrast_limits, (0, 1)) mapped_properties = colormap.map(normalized_properties) return mapped_properties, contrast_limits def _validate_colormap_mode( values: Dict[str, Any] ) -> Tuple[np.ndarray, Dict[str, Any]]: """Validate the ColorManager field values specific for colormap mode This is called by the root_validator in ColorManager Parameters ---------- values : dict The field values that are passed to the ColorManager root validator Returns ------- colors : np.ndarray The (Nx4) color array to set as ColorManager.colors values : dict """ color_properties = values['color_properties'].values cmap = values['continuous_colormap'] if len(color_properties) > 0: if values['contrast_limits'] is None: colors, contrast_limits = map_property( prop=color_properties, colormap=cmap, ) values['contrast_limits'] = contrast_limits else: colors, _ = map_property( prop=color_properties, colormap=cmap, contrast_limits=values['contrast_limits'], ) else: colors = np.empty((0, 4)) current_prop_value = values['color_properties'].current_value if current_prop_value is not None: values['current_color'] = cmap.map(current_prop_value)[0] if len(colors) == 0: colors = np.empty((0, 4)) return colors, values def _validate_cycle_mode( values: Dict[str, Any] ) -> Tuple[np.ndarray, Dict[str, Any]]: """Validate the ColorManager field values specific for color cycle mode This is called by the root_validator in ColorManager Parameters ---------- values : dict The field values that are passed to the ColorManager root validator Returns ------- colors : np.ndarray The (Nx4) color array to set as ColorManager.colors values : dict """ color_properties = values['color_properties'].values cmap = values['categorical_colormap'] if len(color_properties) == 0: colors = np.empty((0, 4)) current_prop_value = values['color_properties'].current_value if current_prop_value is not None: values['current_color'] = cmap.map(current_prop_value)[0] else: colors = cmap.map(color_properties) values['categorical_colormap'] = cmap return colors, values napari-0.5.0a1/napari/layers/utils/color_transformations.py000066400000000000000000000116731437041365600241430ustar00rootroot00000000000000"""This file contains functions which are designed to assist Layer objects transform, normalize and broadcast the color inputs they receive into a more standardized format - a numpy array with N rows, N being the number of data points, and a dtype of np.float32. """ import warnings from itertools import cycle from typing import Union import numpy as np from napari.utils.colormaps.colormap_utils import ColorType from napari.utils.colormaps.standardize_color import transform_color from napari.utils.translations import trans def transform_color_with_defaults( num_entries: int, colors: ColorType, elem_name: str, default: str ) -> np.ndarray: """Helper method to return an Nx4 np.array from an arbitrary user input. Parameters ---------- num_entries : int The number of data elements in the layer colors : ColorType The wanted colors for each of the data points elem_name : str Element we're trying to set the color, for example, `face_color` or `track_colors`. This is used to provide context to user warnings. default : str The default color for that element in the layer Returns ------- transformed : np.ndarray Nx4 numpy array with a dtype of np.float32 """ try: transformed = transform_color(colors) except (AttributeError, ValueError, KeyError): warnings.warn( trans._( "The provided {elem_name} parameter contained illegal values, resetting all {elem_name} values to {default}.", deferred=True, elem_name=elem_name, default=default, ) ) transformed = transform_color(default) else: if (len(transformed) != 1) and (len(transformed) != num_entries): warnings.warn( trans._( "The provided {elem_name} parameter has {length} entries, while the data contains {num_entries} entries. Setting {elem_name} to {default}.", deferred=True, elem_name=elem_name, length=len(colors), num_entries=num_entries, default=default, ) ) transformed = transform_color(default) return transformed def transform_color_cycle( color_cycle: Union[ColorType, cycle], elem_name: str, default: str ) -> cycle: """Helper method to return an Nx4 np.array from an arbitrary user input. Parameters ---------- color_cycle : ColorType, cycle The desired colors for each of the data points elem_name : str Whether we're trying to set the face color or edge color of the layer default : str The default color for that element in the layer Returns ------- transformed_color_cycle : cycle cycle of Nx4 numpy arrays with a dtype of np.float32 transformed_colors : np.ndarray input array of colors transformed to RGBA """ transformed_colors = transform_color_with_defaults( num_entries=len(color_cycle), colors=color_cycle, elem_name=elem_name, default=default, ) transformed_color_cycle = cycle(transformed_colors) return transformed_color_cycle, transformed_colors def normalize_and_broadcast_colors( num_entries: int, colors: ColorType ) -> np.ndarray: """Takes an input color array and forces into being the length of ``data``. Used when a single color is supplied for many input objects, but we need Layer.current_face_color or Layer.current_edge_color to have the shape of the actual data. Note: This function can't robustly parse user input, and thus should always be used on the output of ``transform_color_with_defaults``. Parameters ---------- num_entries : int The number of data elements in the layer colors : ColorType The user's input after being normalized by transform_color_with_defaults Returns ------- tiled : np.ndarray A tiled version (if needed) of the original input """ # len == 0 data is handled somewhere else if (len(colors) == num_entries) or (num_entries == 0): return np.asarray(colors) # If the user has supplied a list of colors, but its length doesn't # match the length of the data, we warn them and return a single # color for all inputs if len(colors) != 1: warnings.warn( trans._( "The number of supplied colors mismatch the number of given data points. Length of data is {num_entries}, while the number of colors is {length}. Color for all points is reset to white.", deferred=True, num_entries=num_entries, length=len(colors), ) ) tiled = np.ones((num_entries, 4), dtype=np.float32) return tiled # All that's left is to deal with length=1 color inputs tiled = np.tile(colors.ravel(), (num_entries, 1)) return tiled napari-0.5.0a1/napari/layers/utils/interaction_box.py000066400000000000000000000105121437041365600226720ustar00rootroot00000000000000from __future__ import annotations from functools import lru_cache from typing import TYPE_CHECKING, Optional, Tuple import numpy as np from napari.layers.base._base_constants import InteractionBoxHandle if TYPE_CHECKING: from napari.layers import Layer @lru_cache def generate_interaction_box_vertices( top_left: Tuple[float, float], bot_right: Tuple[float, float], handles: bool = True, ) -> np.ndarray: """ Generate coordinates for all the handles in InteractionBoxHandle. Coordinates are assumed to follow vispy "y down" convention. Parameters ---------- top_left : Tuple[float, float] Top-left corner of the box bot_right : Tuple[float, float] Bottom-right corner of the box handles : bool Whether to also return indices for the transformation handles. Returns ------- np.ndarray Coordinates of the vertices and handles of the interaction box. """ x0, y0 = top_left x1, y1 = bot_right vertices = np.array( [ [x0, y0], [x0, y1], [x1, y0], [x1, y1], ] ) if handles: # add handles at the midpoint of each side middle_vertices = np.mean([vertices, vertices[[2, 0, 3, 1]]], axis=0) box_height = vertices[0, 1] - vertices[1, 1] vertices = np.concatenate([vertices, middle_vertices]) # add the extra handle for rotation extra_vertex = [middle_vertices[0] + [0, box_height * 0.1]] vertices = np.concatenate([vertices, extra_vertex]) return vertices def generate_transform_box_from_layer( layer: Layer, dims_displayed: Tuple[int, int] ) -> np.ndarray: """ Generate coordinates for the handles of a layer's transform box. Parameters ---------- layer : Layer Layer whose transform box to generate. dims_displayed : Tuple[int, ...] Dimensions currently displayed (must be 2). Returns ------- np.ndarray Vertices and handles of the interaction box in data coordinates. """ bounds = layer._display_bounding_box(dims_displayed) # TODO: can we do this differently? # avoid circular import from napari.layers.image.image import _ImageBase if isinstance(layer, _ImageBase): bounds -= 0.5 # generates in vispy canvas pos, so invert x and y, and then go back top_left, bot_right = (tuple(point) for point in bounds.T[:, ::-1]) return generate_interaction_box_vertices( top_left, bot_right, handles=True )[:, ::-1] def calculate_bounds_from_contained_points( points: np.ndarray, ) -> Tuple[Tuple[float, float], Tuple[float, float]]: """ Calculate the top-left and bottom-right corners of an axis-aligned bounding box. Parameters ---------- points : np.ndarray Array of point coordinates. Returns ------- Tuple[Tuple[float, float], Tuple[float, float]] Top-left and bottom-right corners of the bounding box. """ if points is None: return None points = np.atleast_2d(points) if points.ndim != 2: raise ValueError('only 2D coordinates are accepted') x0 = points[:, 0].min() x1 = points[:, 0].max() y0 = points[:, 1].min() y1 = points[:, 1].max() return (x0, x1), (y0, y1) def get_nearby_handle( position: np.ndarray, handle_coordinates: np.ndarray ) -> Optional[InteractionBoxHandle]: """ Get the InteractionBoxHandle close to the given position, within tolerance. Parameters ---------- position : np.ndarray Position to query for. handle_coordinates : np.ndarray Coordinates of all the handles (except INSIDE). Returns ------- Optional[InteractionBoxHandle] The nearby handle if any, or InteractionBoxHandle.INSIDE if inside the box. """ top_left = handle_coordinates[InteractionBoxHandle.TOP_LEFT] bot_right = handle_coordinates[InteractionBoxHandle.BOTTOM_RIGHT] dist = np.linalg.norm(position - handle_coordinates, axis=1) tolerance = dist.max() / 100 close_to_vertex = np.isclose(dist, 0, atol=tolerance) if np.any(close_to_vertex): idx = np.argmax(close_to_vertex) return InteractionBoxHandle(idx) elif np.all((position >= top_left) & (position <= bot_right)): return InteractionBoxHandle.INSIDE else: return None napari-0.5.0a1/napari/layers/utils/interactivity_utils.py000066400000000000000000000147421437041365600236320ustar00rootroot00000000000000from __future__ import annotations from typing import TYPE_CHECKING, List, Tuple, Union import numpy as np from napari.utils.geometry import ( point_in_bounding_box, project_points_onto_plane, ) if TYPE_CHECKING: from napari.layers.image.image import Image def displayed_plane_from_nd_line_segment( start_point: np.ndarray, end_point: np.ndarray, dims_displayed: Union[List[int], np.ndarray], ) -> Tuple[np.ndarray, np.ndarray]: """Get the plane defined by start_point and the normal vector that goes from start_point to end_point. Note the start_point and end_point are nD and the returned plane is in the displayed dimensions (i.e., 3D). Parameters ---------- start_point : np.ndarray The start point of the line segment in nD coordinates. end_point : np.ndarray The end point of the line segment in nD coordinates.. dims_displayed : Union[List[int], np.ndarray] The dimensions of the data array currently in view. Returns ------- plane_point : np.ndarray The point on the plane that intersects the click ray. This is returned in data coordinates with only the dimensions that are displayed. plane_normal : np.ndarray The normal unit vector for the plane. It points in the direction of the click in data coordinates. """ plane_point = start_point[dims_displayed] end_position_view = end_point[dims_displayed] ray_direction = end_position_view - plane_point plane_normal = ray_direction / np.linalg.norm(ray_direction) return plane_point, plane_normal def drag_data_to_projected_distance( start_position, end_position, view_direction, vector ): """Calculate the projected distance between two mouse events. Project the drag vector between two mouse events onto a 3D vector specified in data coordinates. The general strategy is to 1) find mouse drag start and end positions, project them onto a pseudo-canvas (a plane aligned with the canvas) in data coordinates. 2) project the mouse drag vector onto the (normalised) vector in data coordinates Parameters ---------- start_position : np.ndarray Starting point of the drag vector in data coordinates end_position : np.ndarray End point of the drag vector in data coordinates view_direction : np.ndarray Vector defining the plane normal of the plane onto which the drag vector is projected. vector : np.ndarray (3,) unit vector or (n, 3) array thereof on which to project the drag vector from start_event to end_event. This argument is defined in data coordinates. Returns ------- projected_distance : (1, ) or (n, ) np.ndarray of float """ # enforce at least 2d input vector = np.atleast_2d(vector) # Store the start and end positions in world coordinates start_position = np.asarray(start_position) end_position = np.asarray(end_position) # Project the start and end positions onto a pseudo-canvas, a plane # parallel to the rendered canvas in data coordinates. end_position_canvas, _ = project_points_onto_plane( end_position, start_position, view_direction ) # Calculate the drag vector on the pseudo-canvas. drag_vector_canvas = np.squeeze(end_position_canvas - start_position) # Project the drag vector onto the specified vector(s), return the distance return np.einsum('j, ij -> i', drag_vector_canvas, vector).squeeze() def orient_plane_normal_around_cursor(layer: Image, plane_normal: tuple): """Orient a rendering plane by rotating it around the cursor. If the cursor ray does not intersect the plane, the position will remain unchanged. Parameters ---------- layer : Image The layer on which the rendering plane is to be rotated plane_normal : 3-tuple The target plane normal in scene coordinates. """ # avoid circular imports import napari from napari.layers.image._image_constants import VolumeDepiction viewer = napari.viewer.current_viewer() # early exit if viewer.dims.ndisplay != 3 or layer.depiction != VolumeDepiction.PLANE: return # find cursor-plane intersection in data coordinates cursor_position = layer._world_to_displayed_data( position=viewer.cursor.position, dims_displayed=layer._slice_input.displayed, ) view_direction = layer._world_to_displayed_data_ray( viewer.camera.view_direction, dims_displayed=[-3, -2, -1] ) intersection = layer.plane.intersect_with_line( line_position=cursor_position, line_direction=view_direction ) # check if intersection is within data extents for displayed dimensions bounding_box = layer.extent.data[:, layer._slice_input.displayed] # update plane position if point_in_bounding_box(intersection, bounding_box): layer.plane.position = intersection # update plane normal layer.plane.normal = layer._world_to_displayed_data_ray( plane_normal, dims_displayed=layer._slice_input.displayed ) def nd_line_segment_to_displayed_data_ray( start_point: np.ndarray, end_point: np.ndarray, dims_displayed: Union[List[int], np.ndarray], ) -> Tuple[np.ndarray, np.ndarray]: """Convert the start and end point of the line segment of a mouse click ray intersecting a data cube to a ray (i.e., start position and direction) in displayed data coordinates Note: the ray starts 0.1 data units outside of the data volume. Parameters ---------- start_point : np.ndarray The start position of the ray used to interrogate the data. end_point : np.ndarray The end position of the ray used to interrogate the data. dims_displayed : List[int] The indices of the dimensions currently displayed in the Viewer. Returns ------- start_position : np.ndarray The start position of the ray in displayed data coordinates ray_direction : np.ndarray The unit vector describing the ray direction. """ # get the ray in the displayed data coordinates start_position = start_point[dims_displayed] end_position = end_point[dims_displayed] ray_direction = end_position - start_position ray_direction = ray_direction / np.linalg.norm(ray_direction) # step the start position back a little bit to be able to detect shapes # that contain the start_position start_position = start_position - 0.1 * ray_direction return start_position, ray_direction napari-0.5.0a1/napari/layers/utils/layer_utils.py000066400000000000000000000756421437041365600220560ustar00rootroot00000000000000from __future__ import annotations import functools import inspect from typing import Any, Dict, List, Optional, Sequence, Union import dask import numpy as np import pandas as pd from napari.utils.action_manager import action_manager from napari.utils.events.custom_types import Array from napari.utils.transforms import Affine from napari.utils.translations import trans def register_layer_action( keymapprovider, description: str, repeatable: bool = False, shortcuts: str = None, ): """ Convenient decorator to register an action with the current Layers It will use the function name as the action name. We force the description to be given instead of function docstring for translation purpose. Parameters ---------- keymapprovider : KeymapProvider class on which to register the keybindings – this will typically be the instance in focus that will handle the keyboard shortcut. description : str The description of the action, this will typically be translated and will be what will be used in tooltips. repeatable : bool A flag indicating whether the action autorepeats when key is held shortcuts : str | List[str] Shortcut to bind by default to the action we are registering. Returns ------- function: Actual decorator to apply to a function. Given decorator returns the function unmodified to allow decorator stacking. """ def _inner(func): nonlocal shortcuts name = 'napari:' + func.__name__ action_manager.register_action( name=name, command=func, description=description, keymapprovider=keymapprovider, repeatable=repeatable, ) if shortcuts: if isinstance(shortcuts, str): shortcuts = [shortcuts] for shortcut in shortcuts: action_manager.bind_shortcut(name, shortcut) return func return _inner def register_layer_attr_action( keymapprovider, description: str, attribute_name: str, shortcuts=None, ): """ Convenient decorator to register an action with the current Layers. This will get and restore attribute from function first argument. It will use the function name as the action name. We force the description to be given instead of function docstring for translation purpose. Parameters ---------- keymapprovider : KeymapProvider class on which to register the keybindings – this will typically be the instance in focus that will handle the keyboard shortcut. description : str The description of the action, this will typically be translated and will be what will be used in tooltips. attribute_name : str The name of the attribute to be restored if key is hold over `get_settings().get_settings().application.hold_button_delay. shortcuts : str | List[str] Shortcut to bind by default to the action we are registering. Returns ------- function: Actual decorator to apply to a function. Given decorator returns the function unmodified to allow decorator stacking. """ def _handle(func): sig = inspect.signature(func) try: first_variable_name = next(iter(sig.parameters)) except StopIteration as e: raise RuntimeError( trans._( "If actions has no arguments there is no way to know what to set the attribute to.", deferred=True, ), ) from e @functools.wraps(func) def _wrapper(*args, **kwargs): if args: obj = args[0] else: obj = kwargs[first_variable_name] prev_mode = getattr(obj, attribute_name) func(*args, **kwargs) def _callback(): setattr(obj, attribute_name, prev_mode) return _callback repeatable = False # attribute actions are always non-repeatable register_layer_action( keymapprovider, description, repeatable, shortcuts )(_wrapper) return func return _handle def _nanmin(array): """ call np.min but fall back to avoid nan and inf if necessary """ min = np.min(array) if not np.isfinite(min): masked = array[np.isfinite(array)] if masked.size == 0: return 0 min = np.min(masked) return min def _nanmax(array): """ call np.max but fall back to avoid nan and inf if necessary """ max = np.max(array) if not np.isfinite(max): masked = array[np.isfinite(array)] if masked.size == 0: return 1 max = np.max(masked) return max def calc_data_range(data, rgb=False): """Calculate range of data values. If all values are equal return [0, 1]. Parameters ---------- data : array Data to calculate range of values over. rgb : bool Flag if data is rgb. Returns ------- values : list of float Range of values. Notes ----- If the data type is uint8, no calculation is performed, and 0-255 is returned. """ if data.dtype == np.uint8: return [0, 255] if data.size > 1e7 and (data.ndim == 1 or (rgb and data.ndim == 2)): # If data is very large take the average of start, middle and end. center = int(data.shape[0] // 2) slices = [ slice(0, 4096), slice(center - 2048, center + 2048), slice(-4096, None), ] reduced_data = [ [_nanmax(data[sl]) for sl in slices], [_nanmin(data[sl]) for sl in slices], ] elif data.size > 1e7: # If data is very large take the average of the top, bottom, and # middle slices offset = 2 + int(rgb) bottom_plane_idx = (0,) * (data.ndim - offset) middle_plane_idx = tuple(s // 2 for s in data.shape[:-offset]) top_plane_idx = tuple(s - 1 for s in data.shape[:-offset]) idxs = [bottom_plane_idx, middle_plane_idx, top_plane_idx] # If each plane is also very large, look only at a subset of the image if ( np.prod(data.shape[-offset:]) > 1e7 and data.shape[-offset] > 64 and data.shape[-offset + 1] > 64 ): # Find a central patch of the image to take center = [int(s // 2) for s in data.shape[-offset:]] central_slice = tuple(slice(c - 31, c + 31) for c in center[:2]) reduced_data = [ [_nanmax(data[idx + central_slice]) for idx in idxs], [_nanmin(data[idx + central_slice]) for idx in idxs], ] else: reduced_data = [ [_nanmax(data[idx]) for idx in idxs], [_nanmin(data[idx]) for idx in idxs], ] # compute everything in one go reduced_data = dask.compute(*reduced_data) else: reduced_data = data min_val = _nanmin(reduced_data) max_val = _nanmax(reduced_data) if min_val == max_val: min_val = 0 max_val = 1 return [float(min_val), float(max_val)] def segment_normal(a, b, p=(0, 0, 1)): """Determines the unit normal of the vector from a to b. Parameters ---------- a : np.ndarray Length 2 array of first point or Nx2 array of points b : np.ndarray Length 2 array of second point or Nx2 array of points p : 3-tuple, optional orthogonal vector for segment calculation in 3D. Returns ------- unit_norm : np.ndarray Length the unit normal of the vector from a to b. If a == b, then returns [0, 0] or Nx2 array of vectors """ d = b - a if d.ndim == 1: if len(d) == 2: normal = np.array([d[1], -d[0]]) else: normal = np.cross(d, p) norm = np.linalg.norm(normal) if norm == 0: norm = 1 else: if d.shape[1] == 2: normal = np.stack([d[:, 1], -d[:, 0]], axis=0).transpose(1, 0) else: normal = np.cross(d, p) norm = np.linalg.norm(normal, axis=1, keepdims=True) ind = norm == 0 norm[ind] = 1 unit_norm = normal / norm return unit_norm def convert_to_uint8(data: np.ndarray) -> np.ndarray: """ Convert array content to uint8. If all negative values are changed on 0. If values are integer and bellow 256 it is simple casting otherwise maximum value for this data type is picked and values are scaled by 255/maximum type value. Binary images ar converted to [0,255] images. float images are multiply by 255 and then casted to uint8. Based on skimage.util.dtype.convert but limited to output type uint8 """ out_dtype = np.dtype(np.uint8) out_max = np.iinfo(out_dtype).max if data.dtype == out_dtype: return data in_kind = data.dtype.kind if in_kind == "b": return data.astype(out_dtype) * 255 if in_kind == "f": image_out = np.multiply(data, out_max, dtype=data.dtype) np.rint(image_out, out=image_out) np.clip(image_out, 0, out_max, out=image_out) return image_out.astype(out_dtype) if in_kind in "ui": if in_kind == "u": if data.max() < out_max: return data.astype(out_dtype) return np.right_shift(data, (data.dtype.itemsize - 1) * 8).astype( out_dtype ) else: np.maximum(data, 0, out=data, dtype=data.dtype) if data.dtype == np.int8: return (data * 2).astype(np.uint8) if data.max() < out_max: return data.astype(out_dtype) return np.right_shift( data, (data.dtype.itemsize - 1) * 8 - 1 ).astype(out_dtype) def get_current_properties( properties: Dict[str, np.ndarray], choices: Dict[str, np.ndarray], num_data: int = 0, ) -> Dict[str, Any]: """Get the current property values from the properties or choices. Parameters ---------- properties : dict[str, np.ndarray] The property values. choices : dict[str, np.ndarray] The property value choices. num_data : int The length of data that the properties represent (e.g. number of points). Returns ------- dict[str, Any] A dictionary where the key is the property name and the value is the current value of that property. """ current_properties = {} if num_data > 0: current_properties = { k: np.asarray([v[-1]]) for k, v in properties.items() } elif num_data == 0 and len(choices) > 0: current_properties = { k: np.asarray([v[0]]) for k, v in choices.items() } return current_properties def dataframe_to_properties( dataframe: pd.DataFrame, ) -> Dict[str, np.ndarray]: """Convert a dataframe to a properties dictionary. Parameters ---------- dataframe : DataFrame The dataframe object to be converted to a properties dictionary Returns ------- dict[str, np.ndarray] A properties dictionary where the key is the property name and the value is an ndarray with the property value for each point. """ return {col: np.asarray(dataframe[col]) for col in dataframe} def validate_properties( properties: Optional[Union[Dict[str, Array], pd.DataFrame]], expected_len: Optional[int] = None, ) -> Dict[str, np.ndarray]: """Validate the type and size of properties and coerce values to numpy arrays. Parameters ---------- properties : dict[str, Array] or DataFrame The property values. expected_len : int The expected length of each property value array. Returns ------- Dict[str, np.ndarray] The property values. """ if properties is None or len(properties) == 0: return {} if not isinstance(properties, dict): properties = dataframe_to_properties(properties) lens = [len(v) for v in properties.values()] if expected_len is None: expected_len = lens[0] if any(v != expected_len for v in lens): raise ValueError( trans._( "the number of items must be equal for all properties", deferred=True, ) ) return {k: np.asarray(v) for k, v in properties.items()} def _validate_property_choices(property_choices): if property_choices is None: property_choices = {} return {k: np.unique(v) for k, v in property_choices.items()} def _coerce_current_properties_value( value: Union[float, str, int, bool, list, tuple, np.ndarray] ) -> np.ndarray: """Coerce a value in a current_properties dictionary into the correct type. Parameters ---------- value : Union[float, str, int, bool, list, tuple, np.ndarray] The value to be coerced. Returns ------- coerced_value : np.ndarray The value in a 1D numpy array with length 1. """ if isinstance(value, (np.ndarray, list, tuple)): if len(value) != 1: raise ValueError( trans._( 'current_properties values should have length 1.', deferred=True, ) ) coerced_value = np.asarray(value) else: coerced_value = np.array([value]) return coerced_value def coerce_current_properties( current_properties: Dict[ str, Union[float, str, int, bool, list, tuple, np.ndarray] ] ) -> Dict[str, np.ndarray]: """Coerce a current_properties dictionary into the correct type. Parameters ---------- current_properties : Dict[str, Union[float, str, int, bool, list, tuple, np.ndarray]] The current_properties dictionary to be coerced. Returns ------- coerced_current_properties : Dict[str, np.ndarray] The current_properties dictionary with string keys and 1D numpy array with length 1 values. """ coerced_current_properties = { k: _coerce_current_properties_value(v) for k, v in current_properties.items() } return coerced_current_properties def compute_multiscale_level( requested_shape, shape_threshold, downsample_factors ): """Computed desired level of the multiscale given requested field of view. The level of the multiscale should be the lowest resolution such that the requested shape is above the shape threshold. By passing a shape threshold corresponding to the shape of the canvas on the screen this ensures that we have at least one data pixel per screen pixel, but no more than we need. Parameters ---------- requested_shape : tuple Requested shape of field of view in data coordinates shape_threshold : tuple Maximum size of a displayed tile in pixels. downsample_factors : list of tuple Downsampling factors for each level of the multiscale. Must be increasing for each level of the multiscale. Returns ------- level : int Level of the multiscale to be viewing. """ # Scale shape by downsample factors scaled_shape = requested_shape / downsample_factors # Find the highest level (lowest resolution) allowed locations = np.argwhere(np.all(scaled_shape > shape_threshold, axis=1)) if len(locations) > 0: level = locations[-1][0] else: level = 0 return level def compute_multiscale_level_and_corners( corner_pixels, shape_threshold, downsample_factors ): """Computed desired level and corners of a multiscale view. The level of the multiscale should be the lowest resolution such that the requested shape is above the shape threshold. By passing a shape threshold corresponding to the shape of the canvas on the screen this ensures that we have at least one data pixel per screen pixel, but no more than we need. Parameters ---------- corner_pixels : array (2, D) Requested corner pixels at full resolution. shape_threshold : tuple Maximum size of a displayed tile in pixels. downsample_factors : list of tuple Downsampling factors for each level of the multiscale. Must be increasing for each level of the multiscale. Returns ------- level : int Level of the multiscale to be viewing. corners : array (2, D) Needed corner pixels at target resolution. """ requested_shape = corner_pixels[1] - corner_pixels[0] level = compute_multiscale_level( requested_shape, shape_threshold, downsample_factors ) corners = corner_pixels / downsample_factors[level] corners = np.array([np.floor(corners[0]), np.ceil(corners[1])]).astype(int) return level, corners def coerce_affine(affine, *, ndim, name=None): """Coerce a user input into an affine transform object. If the input is already an affine transform object, that same object is returned with a name change if the given name is not None. If the input is None, an identity affine transform object of the given dimensionality is returned. Parameters ---------- affine : array-like or napari.utils.transforms.Affine An existing affine transform object or an array-like that is its transform matrix. ndim : int The desired dimensionality of the transform. Ignored is affine is an Affine transform object. name : str The desired name of the transform. Returns ------- napari.utils.transforms.Affine The input coerced into an affine transform object. """ if affine is None: affine = Affine(affine_matrix=np.eye(ndim + 1), ndim=ndim) elif isinstance(affine, np.ndarray): affine = Affine(affine_matrix=affine, ndim=ndim) elif isinstance(affine, list): affine = Affine(affine_matrix=np.array(affine), ndim=ndim) elif not isinstance(affine, Affine): raise TypeError( trans._( 'affine input not recognized. must be either napari.utils.transforms.Affine or ndarray. Got {dtype}', deferred=True, dtype=type(affine), ) ) if name is not None: affine.name = name return affine def dims_displayed_world_to_layer( dims_displayed_world: List[int], ndim_world: int, ndim_layer: int, ) -> List[int]: """Convert the dims_displayed from world dims to the layer dims. This accounts differences in the number of dimensions in the world dims versus the layer and for transpose and rolls. Parameters ---------- dims_displayed_world : List[int] The dims_displayed in world coordinates (i.e., from viewer.dims.displayed). ndim_world : int The number of dimensions in the world coordinates (i.e., viewer.dims.ndim) ndim_layer : int The number of dimensions in layer the layer (i.e., layer.ndim). """ if ndim_world > len(dims_displayed_world): all_dims = list(range(ndim_world)) not_in_dims_displayed = [ d for d in all_dims if d not in dims_displayed_world ] order = not_in_dims_displayed + dims_displayed_world else: order = dims_displayed_world offset = ndim_world - ndim_layer order = np.array(order) if offset <= 0: order = list(range(-offset)) + list(order - offset) else: order = list(order[order >= offset] - offset) n_display_world = len(dims_displayed_world) if n_display_world > ndim_layer: n_display_layer = ndim_layer else: n_display_layer = n_display_world dims_displayed = order[-n_display_layer:] return dims_displayed def get_extent_world(data_extent, data_to_world, centered=False): """Range of layer in world coordinates base on provided data_extent Parameters ---------- data_extent : array, shape (2, D) Extent of layer in data coordinates. data_to_world : napari.utils.transforms.Affine The transform from data to world coordinates. centered : bool If pixels should be centered. By default False. Returns ------- extent_world : array, shape (2, D) """ D = data_extent.shape[1] # subtract 0.5 to get from pixel center to pixel edge offset = 0.5 * bool(centered) pixel_extents = tuple(d - offset for d in data_extent.T) full_data_extent = np.array(np.meshgrid(*pixel_extents)).T.reshape(-1, D) full_world_extent = data_to_world(full_data_extent) world_extent = np.array( [ np.min(full_world_extent, axis=0), np.max(full_world_extent, axis=0), ] ) return world_extent def features_to_pandas_dataframe(features: Any) -> pd.DataFrame: """Coerces a layer's features property to a pandas DataFrame. In general, this may copy the data from features into the returned DataFrame so there is no guarantee that changing element values in the returned DataFrame will also change values in the features property. Parameters ---------- features The features property of a layer. Returns ------- pd.DataFrame A pandas DataFrame that stores the given features. """ return features class _FeatureTable: """Stores feature values and their defaults. Parameters ---------- values : Optional[Union[Dict[str, np.ndarray], pd.DataFrame]] The features values, which will be passed to the pandas DataFrame initializer. If this is a pandas DataFrame with a non-default index, that index (except its length) will be ignored. num_data : Optional[int] The number of the elements in the layer calling this, such as the number of points, which is used to check that the features table has the expected number of rows. If None, then the default DataFrame index is used. """ def __init__( self, values: Optional[Union[Dict[str, np.ndarray], pd.DataFrame]] = None, *, num_data: Optional[int] = None, ) -> None: self._values = _validate_features(values, num_data=num_data) self._defaults = self._make_defaults() @property def values(self) -> pd.DataFrame: """The feature values table.""" return self._values def set_values(self, values, *, num_data=None) -> None: """Sets the feature values table.""" self._values = _validate_features(values, num_data=num_data) self._defaults = self._make_defaults() def _make_defaults(self) -> pd.DataFrame: """Makes the default values table from the feature values.""" return pd.DataFrame( { name: _get_default_column(column) for name, column in self._values.items() }, index=range(1), copy=True, ) @property def defaults(self) -> pd.DataFrame: """The default values one-row table.""" return self._defaults def properties(self) -> Dict[str, np.ndarray]: """Converts this to a deprecated properties dictionary. This will reference the features data when possible, but in general the returned dictionary may contain copies of those data. Returns ------- Dict[str, np.ndarray] The properties dictionary equivalent to the given features. """ return _features_to_properties(self._values) def choices(self) -> Dict[str, np.ndarray]: """Converts this to a deprecated property choices dictionary. Only categorical features will have corresponding entries in the dictionary. Returns ------- Dict[str, np.ndarray] The property choices dictionary equivalent to this. """ return { name: series.dtype.categories.to_numpy() for name, series in self._values.items() if isinstance(series.dtype, pd.CategoricalDtype) } def currents(self) -> Dict[str, np.ndarray]: """Converts the defaults table to a deprecated current properties dictionary.""" return _features_to_properties(self._defaults) def set_currents( self, currents: Dict[str, np.ndarray], *, update_indices: Optional[List[int]] = None, ) -> None: """Sets the default values using the deprecated current properties dictionary. May also update some of the feature values to be equal to the new default values. Parameters ---------- currents : Dict[str, np.ndarray] The new current property values. update_indices : Optional[List[int]] If not None, the all features values at the given row indices will be set to the corresponding new current/default feature values. """ currents = coerce_current_properties(currents) self._defaults = _validate_features(currents, num_data=1) if update_indices is not None: for k in self._defaults: self._values[k][update_indices] = self._defaults[k][0] def resize( self, size: int, ) -> None: """Resize this padding with default values if required. Parameters ---------- size : int The new size (number of rows) of the features table. """ current_size = self._values.shape[0] if size < current_size: self.remove(range(size, current_size)) elif size > current_size: to_append = self._defaults.iloc[np.zeros(size - current_size)] self.append(to_append) def append(self, to_append: pd.DataFrame) -> None: """Append new feature rows to this. Parameters ---------- to_append : pd.DataFrame The features to append. """ self._values = pd.concat([self._values, to_append], ignore_index=True) def remove(self, indices: Any) -> None: """Remove rows from this by index. Parameters ---------- indices : Any The indices of the rows to remove. Must be usable as the labels parameter to pandas.DataFrame.drop. """ self._values = self._values.drop(labels=indices, axis=0).reset_index( drop=True ) def reorder(self, order: Sequence[int]) -> None: """Reorders the rows of the feature values table.""" self._values = self._values.iloc[order].reset_index(drop=True) @classmethod def from_layer( cls, *, features: Optional[Union[Dict[str, np.ndarray], pd.DataFrame]] = None, properties: Optional[ Union[Dict[str, np.ndarray], pd.DataFrame] ] = None, property_choices: Optional[Dict[str, np.ndarray]] = None, num_data: Optional[int] = None, ) -> _FeatureTable: """Coerces a layer's keyword arguments to a feature manager. Parameters ---------- features : Optional[Union[Dict[str, np.ndarray], pd.DataFrame]] The features input to a layer. properties : Optional[Union[Dict[str, np.ndarray], pd.DataFrame]] The properties input to a layer. property_choices : Optional[Dict[str, np.ndarray]] The property choices input to a layer. num_data : Optional[int] The number of the elements in the layer calling this, such as the number of points. Returns ------- _FeatureTable The feature manager created from the given layer keyword arguments. Raises ------ ValueError If the input property columns are not all the same length, or if that length is not equal to the given num_data. """ if properties is not None or property_choices is not None: features = _features_from_properties( properties=properties, property_choices=property_choices, num_data=num_data, ) return cls(features, num_data=num_data) def _get_default_column(column: pd.Series) -> pd.Series: """Get the default column of length 1 from a data column.""" value = None if column.size > 0: value = column.iloc[-1] elif isinstance(column.dtype, pd.CategoricalDtype): choices = column.dtype.categories if choices.size > 0: value = choices[0] return pd.Series(data=value, dtype=column.dtype, index=range(1)) def _validate_features( features: Optional[Union[Dict[str, np.ndarray], pd.DataFrame]], *, num_data: Optional[int] = None, ) -> pd.DataFrame: """Validates and coerces a features table into a pandas DataFrame. See Also -------- :class:`_FeatureTable` : See initialization for parameter descriptions. """ if isinstance(features, pd.DataFrame): features = features.reset_index(drop=True) elif isinstance(features, dict): # Convert all array-like objects into a numpy array. # This section was introduced due to an unexpected behavior when using # a pandas Series with mixed indices as input. # This way should handle all array-like objects correctly. # See https://github.com/napari/napari/pull/4755 for more details. features = { key: np.array(value, copy=False) for key, value in features.items() } index = None if num_data is None else range(num_data) return pd.DataFrame(data=features, index=index) def _features_from_properties( *, properties: Optional[Union[Dict[str, np.ndarray], pd.DataFrame]] = None, property_choices: Optional[Dict[str, np.ndarray]] = None, num_data: Optional[int] = None, ) -> pd.DataFrame: """Validates and coerces deprecated properties input into a features DataFrame. See Also -------- :meth:`_FeatureTable.from_layer` """ # Create categorical series for any choices provided. if property_choices is not None: properties = pd.DataFrame(data=properties) for name, choices in property_choices.items(): dtype = pd.CategoricalDtype(categories=choices) num_values = properties.shape[0] if num_data is None else num_data values = ( properties[name] if name in properties else [None] * num_values ) properties[name] = pd.Series(values, dtype=dtype) return _validate_features(properties, num_data=num_data) def _features_to_properties(features: pd.DataFrame) -> Dict[str, np.ndarray]: """Converts a features DataFrame to a deprecated properties dictionary. See Also -------- :meth:`_FeatureTable.properties` """ return {name: series.to_numpy() for name, series in features.items()} def _unique_element(array: Array) -> Optional[Any]: """ Returns the unique element along the 0th axis, if it exists; otherwise, returns None. This is faster than np.unique, does not require extra tricks for nD arrays, and does not fail for non-sortable elements. """ if len(array) == 0: return None el = array[0] if np.any(array[1:] != el): return None return el napari-0.5.0a1/napari/layers/utils/plane.py000066400000000000000000000140141437041365600206030ustar00rootroot00000000000000from typing import Tuple import numpy as np from pydantic import validator from napari.utils.events import EventedModel, SelectableEventedList from napari.utils.geometry import intersect_line_with_plane_3d from napari.utils.translations import trans class Plane(EventedModel): """Defines a Plane in 3D. A Plane is defined by a position, a normal vector and can be toggled on or off. Attributes ---------- position : 3-tuple A 3D position on the plane, defined in sliced data coordinates (currently displayed dims). normal : 3-tuple A 3D unit vector normal to the plane, defined in sliced data coordinates (currently displayed dims). enabled : bool Whether the plane is considered enabled. """ normal: Tuple[float, float, float] = (1, 0, 0) position: Tuple[float, float, float] = (0, 0, 0) @validator('normal') def _normalise_vector(cls, v): return tuple(v / np.linalg.norm(v)) @validator('normal', 'position', pre=True) def _ensure_tuple(cls, v): return tuple(v) def shift_along_normal_vector(self, distance: float): """Shift the plane along its normal vector by a given distance.""" self.position += distance * self.normal def intersect_with_line( self, line_position: np.ndarray, line_direction: np.ndarray ) -> np.ndarray: """Calculate a 3D line-plane intersection.""" return intersect_line_with_plane_3d( line_position, line_direction, self.position, self.normal ) @classmethod def from_points(cls, a, b, c, enabled=True): """Derive a Plane from three points. Parameters ---------- a : ArrayLike (3,) array containing coordinates of a point b : ArrayLike (3,) array containing coordinates of a point c : ArrayLike (3,) array containing coordinates of a point Returns ------- plane : Plane """ a = np.array(a) b = np.array(b) c = np.array(c) abc = np.row_stack((a, b, c)) ab = b - a ac = c - a plane_normal = np.cross(ab, ac) plane_position = np.mean(abc, axis=0) return cls( position=plane_position, normal=plane_normal, enabled=enabled ) def as_array(self): """Return a (2, 3) array representing the plane. [0, :] : plane position [1, :] : plane normal """ return np.stack([self.position, self.normal]) @classmethod def from_array(cls, array, enabled=True): """Construct a plane from a (2, 3) array. [0, :] : plane position [1, :] : plane normal """ return cls(position=array[0], normal=array[1], enabled=enabled) def __hash__(self): return id(self) class SlicingPlane(Plane): """Defines a draggable plane in 3D with a defined thickness. A slicing plane is defined by a position, a normal vector and a thickness value. Attributes ---------- position : 3-tuple A 3D position on the plane, defined in sliced data coordinates (currently displayed dims). normal : 3-tuple A 3D unit vector normal to the plane, defined in sliced data coordinates (currently displayed dims). thickness : float Thickness of the slice. """ thickness: float = 0.0 class ClippingPlane(Plane): """Defines a clipping plane in 3D. A clipping plane is defined by a position, a normal vector and can be toggled on or off. Attributes ---------- position : 3-tuple A 3D position on the plane, defined in sliced data coordinates (currently displayed dims). normal : 3-tuple A 3D unit vector normal to the plane, defined in sliced data coordinates (currently displayed dims). enabled : bool Whether the plane is considered enabled. """ enabled: bool = True class ClippingPlaneList(SelectableEventedList): """A list of planes with some utility methods.""" def as_array(self): """Return a (N, 2, 3) array of clipping planes. [i, 0, :] : ith plane position [i, 1, :] : ith plane normal """ arrays = [] for plane in self: if plane.enabled: arrays.append(plane.as_array()) if not arrays: return np.empty((0, 2, 3)) return np.stack(arrays) @classmethod def from_array(cls, array, enabled=True): """Construct the PlaneList from an (N, 2, 3) array. [i, 0, :] : ith plane position [i, 1, :] : ith plane normal """ if array.ndim != 3 or array.shape[1:] != (2, 3): raise ValueError( trans._( 'Planes can only be constructed from arrays of shape (N, 2, 3), not {shape}', deferred=True, shape=array.shape, ) ) planes = [ ClippingPlane.from_array(sub_arr, enabled=enabled) for sub_arr in array ] return cls(planes) @classmethod def from_bounding_box(cls, center, dimensions, enabled=True): """ generate 6 planes positioned to form a bounding box, with normals towards the center Parameters ---------- center : ArrayLike (3,) array, coordinates of the center of the box dimensions : ArrayLike (3,) array, dimensions of the box Returns ------- list : ClippingPlaneList """ planes = [] for axis in range(3): for direction in (-1, 1): shift = (dimensions[axis] / 2) * direction position = np.array(center) position[axis] += shift normal = np.zeros(3) normal[axis] = -direction planes.append( ClippingPlane( position=position, normal=normal, enabled=enabled ) ) return cls(planes) napari-0.5.0a1/napari/layers/utils/stack_utils.py000066400000000000000000000230251437041365600220330ustar00rootroot00000000000000from __future__ import annotations import itertools from typing import TYPE_CHECKING, List import numpy as np from napari.layers import Image from napari.layers.image._image_utils import guess_multiscale from napari.utils.colormaps import CYMRGB, MAGENTA_GREEN, Colormap from napari.utils.misc import ensure_iterable, ensure_sequence_of_iterables from napari.utils.translations import trans if TYPE_CHECKING: from napari.types import FullLayerData def slice_from_axis(array, *, axis, element): """Take a single index slice from array using slicing. Equivalent to :func:`np.take`, but using slicing, which ensures that the output is a view of the original array. Parameters ---------- array : NumPy or other array Input array to be sliced. axis : int The axis along which to slice. element : int The element along that axis to grab. Returns ------- sliced : NumPy or other array The sliced output array, which has one less dimension than the input. """ slices = [slice(None) for i in range(array.ndim)] slices[axis] = element return array[tuple(slices)] def split_channels( data: np.ndarray, channel_axis: int, **kwargs, ) -> List[FullLayerData]: """Split the data array into separate arrays along an axis. Keyword arguments will override any parameters altered or set in this function. Colormap, blending, or multiscale are set as follows if not overridden by a keyword: - colormap : (magenta, green) for 2 channels, (CYMRGB) for more than 2 - blending : translucent for first channel, additive for others - multiscale : determined by layers.image._image_utils.guess_multiscale. Colormap, blending and multiscale will be set and returned in meta if not in kwargs. If any other key is not present in kwargs it will not be returned in the meta dictionary of the returned LaterData tuple. For example, if gamma is not in kwargs then meta will not have a gamma key. Parameters ---------- data : array or list of array channel_axis : int Axis to split the image along. **kwargs : dict Keyword arguments will override the default image meta keys returned in each layer data tuple. Returns ------- List of LayerData tuples: [(data: array, meta: Dict, type: str )] """ # Determine if data is a multiscale multiscale = kwargs.get('multiscale') if not multiscale: multiscale, data = guess_multiscale(data) kwargs['multiscale'] = multiscale n_channels = (data[0] if multiscale else data).shape[channel_axis] # Use original blending mode or for multichannel use translucent for first channel then additive kwargs['blending'] = kwargs.get('blending') or ['translucent_no_depth'] + [ 'additive' ] * (n_channels - 1) kwargs.setdefault('colormap', None) # these arguments are *already* iterables in the single-channel case. iterable_kwargs = { 'scale', 'translate', 'affine', 'contrast_limits', 'metadata', 'plane', 'experimental_clipping_planes', } # turn the kwargs dict into a mapping of {key: iterator} # so that we can use {k: next(v) for k, v in kwargs.items()} below for key, val in kwargs.items(): if key == 'colormap' and val is None: if n_channels == 1: kwargs[key] = iter(['gray']) elif n_channels == 2: kwargs[key] = iter(MAGENTA_GREEN) else: kwargs[key] = itertools.cycle(CYMRGB) # make sure that iterable_kwargs are a *sequence* of iterables # for the multichannel case. For example: if scale == (1, 2) & # n_channels = 3, then scale should == [(1, 2), (1, 2), (1, 2)] elif key in iterable_kwargs or ( key == 'colormap' and isinstance(val, Colormap) ): kwargs[key] = iter( ensure_sequence_of_iterables( val, n_channels, repeat_empty=True, allow_none=True, ) ) else: kwargs[key] = iter(ensure_iterable(val)) layerdata_list = list() for i in range(n_channels): if multiscale: image = [ slice_from_axis(data[j], axis=channel_axis, element=i) for j in range(len(data)) ] else: image = slice_from_axis(data, axis=channel_axis, element=i) i_kwargs = {} for key, val in kwargs.items(): try: i_kwargs[key] = next(val) except StopIteration as e: raise IndexError( trans._( "Error adding multichannel image with data shape {data_shape!r}.\nRequested channel_axis ({channel_axis}) had length {n_channels}, but the '{key}' argument only provided {i} values. ", deferred=True, data_shape=data.shape, channel_axis=channel_axis, n_channels=n_channels, key=key, i=i, ) ) from e layerdata = (image, i_kwargs, 'image') layerdata_list.append(layerdata) return layerdata_list def stack_to_images(stack: Image, axis: int, **kwargs) -> List[Image]: """Splits a single Image layer into a list layers along axis. Some image layer properties will be changed unless specified as an item in kwargs. Properties such as colormap and contrast_limits are set on individual channels. Properties will be changed as follows (unless overridden with a kwarg): - colormap : (magenta, green) for 2 channels, (CYMRGB) for more than 2 - blending : additive - contrast_limits : min and max of the image All other properties, such as scale and translate will be propagated from the original stack, unless a keyword argument passed for that property. Parameters ---------- stack : napari.layers.Image The image stack to be split into a list of image layers axis : int The axis to split along. Returns ------- imagelist: list List of Image objects """ data, meta, _ = stack.as_layer_data_tuple() for key in ("contrast_limits", "colormap", "blending"): del meta[key] name = stack.name num_dim = 3 if stack.rgb else stack.ndim if num_dim < 3: raise ValueError( trans._( "The image needs more than 2 dimensions for splitting", deferred=True, ) ) if axis >= num_dim: raise ValueError( trans._( "Can't split along axis {axis}. The image has {num_dim} dimensions", deferred=True, axis=axis, num_dim=num_dim, ) ) if kwargs.get("colormap"): kwargs['colormap'] = itertools.cycle(kwargs['colormap']) if meta['rgb']: if axis in [num_dim - 1, -1]: kwargs['rgb'] = False # split channels as grayscale else: kwargs['rgb'] = True # split some other axis, remain rgb meta['scale'].pop(axis) meta['translate'].pop(axis) else: kwargs['rgb'] = False meta['scale'].pop(axis) meta['translate'].pop(axis) meta['rotate'] = None meta['shear'] = None meta['affine'] = None meta.update(kwargs) imagelist = [] layerdata_list = split_channels(data, axis, **meta) for i, tup in enumerate(layerdata_list): idata, imeta, _ = tup layer_name = f'{name} layer {i}' imeta['name'] = layer_name imagelist.append(Image(idata, **imeta)) return imagelist def split_rgb(stack: Image, with_alpha=False) -> List[Image]: """Variant of stack_to_images that splits an RGB with predefined cmap.""" if not stack.rgb: raise ValueError( trans._('Image must be RGB to use split_rgb', deferred=True) ) images = stack_to_images(stack, -1, colormap=('red', 'green', 'blue')) return images if with_alpha else images[:3] def images_to_stack(images: List[Image], axis: int = 0, **kwargs) -> Image: """Combines a list of Image layers into one layer stacked along axis The new image layer will get the meta properties of the first image layer in the input list unless specified in kwargs Parameters ---------- images : List List of Image Layers axis : int Index to to insert the new axis **kwargs : dict Dictionary of parameters values to override parameters from the first image in images list. Returns ------- stack : napari.layers.Image Combined image stack """ if not images: raise IndexError(trans._("images list is empty", deferred=True)) data, meta, _ = images[0].as_layer_data_tuple() kwargs.setdefault("scale", np.insert(meta['scale'], axis, 1)) kwargs.setdefault("translate", np.insert(meta['translate'], axis, 0)) meta.update(kwargs) new_data = np.stack([image.data for image in images], axis=axis) return Image(new_data, **meta) def merge_rgb(images: List[Image]) -> List[Image]: """Variant of images_to_stack that makes an RGB from 3 images.""" if not (len(images) == 3 and all(isinstance(x, Image) for x in images)): raise ValueError( trans._("merge_rgb requires 3 images layers", deferred=True) ) return images_to_stack(images, axis=-1, rgb=True) napari-0.5.0a1/napari/layers/utils/string_encoding.py000066400000000000000000000144611437041365600226660ustar00rootroot00000000000000from string import Formatter from typing import Any, Literal, Protocol, Sequence, Union, runtime_checkable import numpy as np from pydantic import parse_obj_as from napari.layers.utils.style_encoding import ( StyleEncoding, _ConstantStyleEncoding, _DerivedStyleEncoding, _ManualStyleEncoding, ) from napari.utils.events.custom_types import Array from napari.utils.translations import trans """A scalar array that represents one string value.""" StringValue = Array[str, ()] """An Nx1 array where each element represents one string value.""" StringArray = Array[str, (-1,)] """The default string value, which may also be used a safe fallback string.""" DEFAULT_STRING = np.array('', dtype=' 'StringEncoding': """Validates and coerces a value to a StringEncoding. Parameters ---------- value : StringEncodingArgument The value to validate and coerce. If this is already a StringEncoding, it is returned as is. If this is a dict, then it should represent one of the built-in string encodings. If this a valid format string, then a FormatStringEncoding is returned. If this is any other string, a DirectStringEncoding is returned. If this is a sequence of strings, a ManualStringEncoding is returned. Returns ------- StringEncoding Raises ------ TypeError If the value is not a supported type. ValidationError If the value cannot be parsed into a StringEncoding. """ if isinstance(value, StringEncoding): return value if isinstance(value, dict): return parse_obj_as( Union[ ConstantStringEncoding, ManualStringEncoding, DirectStringEncoding, FormatStringEncoding, ], value, ) if isinstance(value, str): if _is_format_string(value): return FormatStringEncoding(format=value) return DirectStringEncoding(feature=value) if isinstance(value, Sequence): return ManualStringEncoding(array=value, default=DEFAULT_STRING) raise TypeError( trans._( 'value should be a StringEncoding, a dict, a string, a sequence of strings, or None', deferred=True, ) ) class ConstantStringEncoding(_ConstantStyleEncoding[StringValue, StringArray]): """Encodes color values from a single constant color. Attributes ---------- constant : StringValue The constant string value. encoding_type : Literal['ConstantStringEncoding'] The type of encoding this specifies, which is useful for distinguishing this from other encodings when passing this as a dictionary. """ constant: StringValue encoding_type: Literal['ConstantStringEncoding'] = 'ConstantStringEncoding' class ManualStringEncoding(_ManualStyleEncoding[StringValue, StringArray]): """Encodes string values manually in an array. Attributes ---------- array : StringArray The array of string values. default : StringValue The default string value that is used when requesting a value that is out of bounds in the array attribute. encoding_type : Literal['ManualStringEncoding'] The type of encoding this specifies, which is useful for distinguishing this from other encodings when passing this as a dictionary. """ array: StringArray default: StringValue = DEFAULT_STRING encoding_type: Literal['ManualStringEncoding'] = 'ManualStringEncoding' class DirectStringEncoding(_DerivedStyleEncoding[StringValue, StringArray]): """Encodes strings directly from a feature column. Attributes ---------- feature : str The name of the feature that contains the desired strings. fallback : StringValue The safe constant fallback string to use if the feature column does not contain valid string values. encoding_type : Literal['DirectStringEncoding'] The type of encoding this specifies, which is useful for distinguishing this from other encodings when passing this as a dictionary. """ feature: str fallback: StringValue = DEFAULT_STRING encoding_type: Literal['DirectStringEncoding'] = 'DirectStringEncoding' def __call__(self, features: Any) -> StringArray: return np.array(features[self.feature], dtype=str) class FormatStringEncoding(_DerivedStyleEncoding[StringValue, StringArray]): """Encodes string values by formatting feature values. Attributes ---------- format : str A format string with the syntax supported by :func:`str.format`, where all format fields should be feature names. fallback : StringValue The safe constant fallback string to use if the format string is not valid or contains fields other than feature names. encoding_type : Literal['FormatStringEncoding'] The type of encoding this specifies, which is useful for distinguishing this from other encodings when passing this as a dictionary. """ format: str fallback: StringValue = DEFAULT_STRING encoding_type: Literal['FormatStringEncoding'] = 'FormatStringEncoding' def __call__(self, features: Any) -> StringArray: feature_names = features.columns.to_list() values = [ self.format.format(**dict(zip(feature_names, row))) for row in features.itertuples(index=False, name=None) ] return np.array(values, dtype=str) def _is_format_string(string: str) -> bool: """Returns True if a string is a valid format string with at least one field, False otherwise.""" try: fields = tuple( field for _, field, _, _ in Formatter().parse(string) if field is not None ) except ValueError: return False return len(fields) > 0 napari-0.5.0a1/napari/layers/utils/style_encoding.py000066400000000000000000000220221437041365600225100ustar00rootroot00000000000000import warnings from abc import ABC, abstractmethod from typing import ( Any, Generic, List, Protocol, TypeVar, Union, runtime_checkable, ) import numpy as np from napari.utils.events import EventedModel from napari.utils.translations import trans IndicesType = Union[range, List[int], np.ndarray] """The variable type of a single style value.""" StyleValue = TypeVar('StyleValue', bound=np.ndarray) """The variable type of multiple style values in an array.""" StyleArray = TypeVar('StyleArray', bound=np.ndarray) @runtime_checkable class StyleEncoding(Protocol[StyleValue, StyleArray]): """Encodes generic style values, like colors and strings, from layer features. The public API of any StyleEncoding is just __call__, such that it can be called to generate style values from layer features. That call should be stateless, in that the values returned only depend on the given features. A StyleEncoding also has a private API that provides access to and mutation of previously generated and cached style values. This currently needs to be implemented to maintain some related behaviors in napari, but may be removed from this protocol in the future. """ def __call__(self, features: Any) -> Union[StyleValue, StyleArray]: """Apply this encoding with the given features to generate style values. Parameters ---------- features : Dataframe-like The layer features table from which to derive the output values. Returns ------- Union[StyleValue, StyleArray] Either a single style value (e.g. from a constant encoding) or an array of encoded values the same length as the given features. Raises ------ KeyError, ValueError If generating values from the given features fails. """ @property def _values(self) -> Union[StyleValue, StyleArray]: """The previously generated and cached values.""" def _apply(self, features: Any) -> None: """Applies this to the tail of the given features and updates cached values. If the cached values are longer than the given features, this will remove the extra cached values. If they are the same length, this may do nothing. Parameters ---------- features : Dataframe-like The full layer features table from which to derive the output values. """ def _append(self, array: StyleArray) -> None: """Appends raw style values to cached values. This is useful for supporting the paste operation in layers. Parameters ---------- array : StyleArray The values to append. The dimensionality of these should match that of the existing style values. """ def _delete(self, indices: IndicesType) -> None: """Deletes cached style values by index. Parameters ---------- indices The indices of the style values to remove. """ def _clear(self) -> None: """Clears all previously generated and cached values.""" def _json_encode(self) -> dict: """Convert this to a dictionary that can be passed to json.dumps. Returns ------- dict The dictionary representation of this with JSON compatible keys and values. """ class _StyleEncodingModel(EventedModel): class Config: # Forbid extra initialization parameters instead of ignoring # them by default. This is useful when parsing style encodings # from dicts, as different types of encodings may have the same # field names. # https://pydantic-docs.helpmanual.io/usage/model_config/#options extra = 'forbid' # The following classes provide generic implementations of common ways # to encode style values, like constant, manual, and derived encodings. # They inherit Python's built-in `Generic` type, so that an encoding with # a specific output type can inherit the generic type annotations from # this class along with the functionality it provides. For example, # `ConstantStringEncoding.__call__` returns an `Array[str, ()]` whereas # `ConstantColorEncoding.__call__` returns an `Array[float, (4,)]`. # For more information on `Generic`, see the official docs. # https://docs.python.org/3/library/typing.html#generics class _ConstantStyleEncoding( _StyleEncodingModel, Generic[StyleValue, StyleArray] ): """Encodes a constant style value. This encoding is generic so that it can be used to implement style encodings with different value types like Array[] Attributes ---------- constant : StyleValue The constant style value. """ constant: StyleValue def __call__(self, features: Any) -> Union[StyleValue, StyleArray]: return self.constant @property def _values(self) -> Union[StyleValue, StyleArray]: return self.constant def _apply(self, features: Any) -> None: pass def _append(self, array: StyleArray) -> None: pass def _delete(self, indices: IndicesType) -> None: pass def _clear(self) -> None: pass def _json_encode(self) -> dict: return self.dict() class _ManualStyleEncoding( _StyleEncodingModel, Generic[StyleValue, StyleArray] ): """Encodes style values manually. The style values are encoded manually in the array attribute, so that attribute can be written to make persistent updates. Attributes ---------- array : np.ndarray The array of values. default : np.ndarray The default style value that is used when ``array`` is shorter than the given features. """ array: StyleArray default: StyleValue def __call__(self, features: Any) -> Union[StyleArray, StyleValue]: n_values = self.array.shape[0] n_rows = features.shape[0] if n_rows > n_values: tail_array = np.array([self.default] * (n_rows - n_values)) return np.append(self.array, tail_array, axis=0) return np.array(self.array[:n_rows]) @property def _values(self) -> Union[StyleValue, StyleArray]: return self.array def _apply(self, features: Any) -> None: self.array = self(features) def _append(self, array: StyleArray) -> None: self.array = np.append(self.array, array, axis=0) def _delete(self, indices: IndicesType) -> None: self.array = np.delete(self.array, indices, axis=0) def _clear(self) -> None: pass def _json_encode(self) -> dict: return self.dict() class _DerivedStyleEncoding( _StyleEncodingModel, Generic[StyleValue, StyleArray], ABC ): """Encodes style values by deriving them from feature values. Attributes ---------- fallback : StyleValue The fallback style value. """ fallback: StyleValue _cached: StyleArray def __init__(self, **kwargs) -> None: super().__init__(**kwargs) self._cached = _empty_array_like(self.fallback) @abstractmethod def __call__(self, features: Any) -> Union[StyleValue, StyleArray]: pass @property def _values(self) -> Union[StyleValue, StyleArray]: return self._cached def _apply(self, features: Any) -> None: n_cached = self._cached.shape[0] n_rows = features.shape[0] if n_cached < n_rows: tail_array = self._call_safely(features.iloc[n_cached:n_rows]) self._append(tail_array) elif n_cached > n_rows: self._cached = self._cached[:n_rows] def _call_safely(self, features: Any) -> StyleArray: """Calls this without raising encoding errors, warning instead.""" try: array = self(features) except (KeyError, ValueError): warnings.warn( trans._( 'Applying the encoding failed. Using the safe fallback value instead.', deferred=True, ), category=RuntimeWarning, ) shape = (features.shape[0],) + self.fallback.shape array = np.broadcast_to(self.fallback, shape) return array def _append(self, array: StyleArray) -> None: self._cached = np.append(self._cached, array, axis=0) def _delete(self, indices: IndicesType) -> None: self._cached = np.delete(self._cached, indices, axis=0) def _clear(self) -> None: self._cached = _empty_array_like(self.fallback) def _json_encode(self) -> dict: return self.dict() def _get_style_values( encoding: StyleEncoding[StyleValue, StyleArray], indices: IndicesType, value_ndim: int = 0, ): """Returns a scalar style value or indexes non-scalar style values.""" values = encoding._values return values if values.ndim == value_ndim else values[indices] def _empty_array_like(value: StyleValue) -> StyleArray: """Returns an empty array with the same type and remaining shape of the given value.""" shape = (0,) + value.shape return np.empty_like(value, shape=shape) napari-0.5.0a1/napari/layers/utils/text_manager.py000066400000000000000000000346271437041365600221760ustar00rootroot00000000000000import warnings from copy import deepcopy from typing import Any, Dict, List, Optional, Sequence, Tuple, Union import numpy as np import pandas as pd from pydantic import PositiveInt, validator from napari.layers.base._base_constants import Blending from napari.layers.utils._text_constants import Anchor from napari.layers.utils._text_utils import get_text_anchors from napari.layers.utils.color_encoding import ( ColorArray, ColorEncoding, ConstantColorEncoding, ) from napari.layers.utils.layer_utils import _validate_features from napari.layers.utils.string_encoding import ( ConstantStringEncoding, StringArray, StringEncoding, ) from napari.layers.utils.style_encoding import _get_style_values from napari.utils.events import Event, EventedModel from napari.utils.events.custom_types import Array from napari.utils.translations import trans class TextManager(EventedModel): """Manages properties related to text displayed in conjunction with the layer. Parameters ---------- features : Any The features table of a layer. values : array-like The array of strings manually specified. .. deprecated:: 0.4.16 `values` is deprecated. Use `string` instead. text : str A a property name or a format string containing property names. This will be used to fill out string values n_text times using the data in properties. .. deprecated:: 0.4.16 `text` is deprecated. Use `string` instead. n_text : int The number of text elements to initially display, which should match the number of elements (e.g. points) in a layer. .. deprecated:: 0.4.16 `n_text` is deprecated. Its value is implied by `features` instead. properties: dict Stores properties data that will be used to generate strings from the given text. Typically comes from a layer. .. deprecated:: 0.4.16 `properties` is deprecated. Use `features` instead. Attributes ---------- string : StringEncoding Defines the string for each text element. values : np.ndarray The encoded string values. visible : bool True if the text should be displayed, false otherwise. size : float Font size of the text, which must be positive. Default value is 12. color : ColorEncoding Defines the color for each text element. blending : Blending The blending mode that determines how RGB and alpha values of the layer visual get mixed. Allowed values are 'translucent' and 'additive'. Note that 'opaque' blending is not allowed, as it colors the bounding box surrounding the text, and if given, 'translucent' will be used instead. anchor : Anchor The location of the text origin relative to the bounding box. Should be 'center', 'upper_left', 'upper_right', 'lower_left', or 'lower_right'. translation : np.ndarray Offset from the anchor point in data coordinates. rotation : float Angle of the text elements around the anchor point. Default value is 0. """ string: StringEncoding = ConstantStringEncoding(constant='') color: ColorEncoding = ConstantColorEncoding(constant='cyan') visible: bool = True size: PositiveInt = 12 blending: Blending = Blending.TRANSLUCENT anchor: Anchor = Anchor.CENTER # Use a scalar default translation to broadcast to any dimensionality. translation: Array[float] = 0 rotation: float = 0 def __init__( self, text=None, properties=None, n_text=None, features=None, **kwargs ) -> None: if n_text is not None: _warn_about_deprecated_n_text_parameter() if properties is not None: _warn_about_deprecated_properties_parameter() features = _validate_features(properties, num_data=n_text) else: features = _validate_features(features) if 'values' in kwargs: _warn_about_deprecated_values_parameter() values = kwargs.pop('values') if 'string' not in kwargs: kwargs['string'] = values if text is not None: _warn_about_deprecated_text_parameter() kwargs['string'] = text super().__init__(**kwargs) self.events.add(values=Event) self.apply(features) @property def values(self): return self.string._values def __setattr__(self, key, value): if key == 'values': self.string = value else: super().__setattr__(key, value) def refresh(self, features: Any) -> None: """Refresh all encoded values using new layer features. Parameters ---------- features : Any The features table of a layer. """ self.string._clear() self.color._clear() self.string._apply(features) self.events.values() self.color._apply(features) # Trigger the main event for vispy layers. self.events(Event(type='refresh')) def refresh_text(self, properties: Dict[str, np.ndarray]): """Refresh all of the current text elements using updated properties values Parameters ---------- properties : Dict[str, np.ndarray] The new properties from the layer """ warnings.warn( trans._( 'TextManager.refresh_text is deprecated since 0.4.16. Use TextManager.refresh instead.' ), DeprecationWarning, stacklevel=2, ) features = _validate_features(properties) self.refresh(features) def add(self, properties: dict, n_text: int): """Adds a number of a new text elements. Parameters ---------- properties : dict The properties to draw the text from n_text : int The number of text elements to add """ warnings.warn( trans._( 'TextManager.add is deprecated since 0.4.16. Use TextManager.apply instead.' ), DeprecationWarning, stacklevel=2, ) features = pd.DataFrame( { name: np.repeat(value, n_text, axis=0) for name, value in properties.items() } ) values = self.string(features) self.string._append(values) self.events.values() colors = self.color(features) self.color._append(colors) def remove(self, indices_to_remove: Union[range, set, list, np.ndarray]): """Remove the indicated text elements Parameters ---------- indices_to_remove : set, list, np.ndarray The indices of the text elements to remove. """ if isinstance(indices_to_remove, set): indices_to_remove = list(indices_to_remove) self.string._delete(indices_to_remove) self.events.values() self.color._delete(indices_to_remove) def apply(self, features: Any): """Applies any encodings to be the same length as the given features, generating new values or removing extra values only as needed. Parameters ---------- features : Any The features table of a layer. """ self.string._apply(features) self.events.values() self.color._apply(features) def _copy(self, indices: List[int]) -> dict: """Copies all encoded values at the given indices.""" return { 'string': _get_style_values(self.string, indices), 'color': _get_style_values(self.color, indices), } def _paste(self, *, string: StringArray, color: ColorArray): """Pastes encoded values to the end of the existing values.""" self.string._append(string) self.events.values() self.color._append(color) def compute_text_coords( self, view_data: np.ndarray, ndisplay: int, order: Optional[Tuple[int, ...]] = None, ) -> Tuple[np.ndarray, str, str]: """Calculate the coordinates for each text element in view Parameters ---------- view_data : np.ndarray The in view data from the layer ndisplay : int The number of dimensions being displayed in the viewer order : tuple of ints, optional The display order of the dimensions in the layer. If None, implies ``range(ndisplay)``. Returns ------- text_coords : np.ndarray The coordinates of the text elements anchor_x : str The vispy text anchor for the x axis anchor_y : str The vispy text anchor for the y axis """ anchor_coords, anchor_x, anchor_y = get_text_anchors( view_data, ndisplay, self.anchor ) # The translation should either be a scalar or be as long as # the dimensionality of the associated layer. # We do not have direct knowledge of that dimensionality, but # can infer enough information to get the translation coordinates # that need to offset the anchor coordinates. ndim_coords = min(ndisplay, anchor_coords.shape[1]) translation = self.translation if translation.size > 1: if order is None: translation = self.translation[-ndim_coords:] else: order_displayed = list(order[-ndim_coords:]) translation = self.translation[order_displayed] text_coords = anchor_coords + translation return text_coords, anchor_x, anchor_y def view_text(self, indices_view: np.ndarray) -> np.ndarray: """Get the values of the text elements in view Parameters ---------- indices_view : (N x 1) np.ndarray Indices of the text elements in view Returns ------- text : (N x 1) np.ndarray Array of text strings for the N text elements in view """ values = _get_style_values(self.string, indices_view) return ( np.broadcast_to(values, len(indices_view)) if values.ndim == 0 else values ) def _view_color(self, indices_view: np.ndarray) -> np.ndarray: """Get the colors of the text elements at the given indices.""" return _get_style_values(self.color, indices_view, value_ndim=1) @classmethod def _from_layer( cls, *, text: Union['TextManager', dict, str, Sequence[str], None], features: Any, ) -> 'TextManager': """Create a TextManager from a layer. Parameters ---------- text : Union[TextManager, dict, str, Sequence[str], None] An instance of TextManager, a dict that contains some of its state, a string that may be a format string or a feature name, or a sequence of strings specified manually. features : Any The features table of a layer. Returns ------- TextManager """ if isinstance(text, TextManager): kwargs = text.dict() elif isinstance(text, dict): kwargs = deepcopy(text) elif text is None: kwargs = {'string': ConstantStringEncoding(constant='')} else: kwargs = {'string': text} kwargs['features'] = features return cls(**kwargs) def _update_from_layer( self, *, text: Union['TextManager', dict, str, None], features: Any, ): """Updates this in-place from a layer. This will effectively overwrite all existing state, but in-place so that there is no need for any external components to reconnect to any useful events. For this reason, only fields that change in value will emit their corresponding events. Parameters ---------- See :meth:`TextManager._from_layer`. """ # Create a new instance from the input to populate all fields. new_manager = TextManager._from_layer(text=text, features=features) # Update a copy of this so that any associated errors are raised # before actually making the update. This does not need to be a # deep copy because update will only try to reassign fields and # should not mutate any existing fields in-place. # Avoid recursion because some fields are also models that may # not share field names/types (e.g. string). current_manager = self.copy() current_manager.update(new_manager, recurse=False) # If we got here, then there were no errors, so update for real. # Connected callbacks may raise errors, but those are bugs. self.update(new_manager, recurse=False) # Some of the encodings may have changed, so ensure they encode new # values if needed. self.apply(features) @validator('blending', pre=True, always=True) def _check_blending_mode(cls, blending): blending_mode = Blending(blending) # The opaque blending mode is not allowed for text. # See: https://github.com/napari/napari/pull/600#issuecomment-554142225 if blending_mode == Blending.OPAQUE: blending_mode = Blending.TRANSLUCENT warnings.warn( trans._( 'opaque blending mode is not allowed for text. setting to translucent.', deferred=True, ), category=RuntimeWarning, ) return blending_mode def _warn_about_deprecated_text_parameter(): warnings.warn( trans._( 'text is a deprecated parameter since 0.4.16. Use string instead.' ), DeprecationWarning, stacklevel=2, ) def _warn_about_deprecated_properties_parameter(): warnings.warn( trans._( 'properties is a deprecated parameter since 0.4.16. Use features instead.' ), DeprecationWarning, stacklevel=2, ) def _warn_about_deprecated_n_text_parameter(): warnings.warn( trans._( 'n_text is a deprecated parameter since 0.4.16. Use features instead.' ), DeprecationWarning, stacklevel=2, ) def _warn_about_deprecated_values_parameter(): warnings.warn( trans._( 'values is a deprecated parameter since 0.4.16. Use string instead.' ), DeprecationWarning, stacklevel=2, ) napari-0.5.0a1/napari/layers/vectors/000077500000000000000000000000001437041365600174575ustar00rootroot00000000000000napari-0.5.0a1/napari/layers/vectors/__init__.py000066400000000000000000000001111437041365600215610ustar00rootroot00000000000000from napari.layers.vectors.vectors import Vectors __all__ = ['Vectors'] napari-0.5.0a1/napari/layers/vectors/_tests/000077500000000000000000000000001437041365600207605ustar00rootroot00000000000000napari-0.5.0a1/napari/layers/vectors/_tests/test_vectors.py000066400000000000000000000507631437041365600240710ustar00rootroot00000000000000import numpy as np import pandas as pd import pytest from vispy.color import get_colormap from napari._tests.utils import check_layer_world_data_extent from napari.layers import Vectors from napari.utils.colormaps.standardize_color import transform_color # Set random seed for testing np.random.seed(0) def test_random_vectors(): """Test instantiating Vectors layer with random coordinate-like 2D data.""" shape = (10, 2, 2) np.random.seed(0) data = np.random.random(shape) data[:, 0, :] = 20 * data[:, 0, :] layer = Vectors(data) assert np.all(layer.data == data) assert layer.data.shape == shape assert layer.ndim == shape[2] assert layer._view_data.shape[2] == 2 def test_random_vectors_image(): """Test instantiating Vectors layer with random image-like 2D data.""" shape = (20, 10, 2) np.random.seed(0) data = np.random.random(shape) layer = Vectors(data) assert layer.data.shape == (20 * 10, 2, 2) assert layer.ndim == 2 assert layer._view_data.shape[2] == 2 def test_no_args_vectors(): """Test instantiating Vectors layer with no arguments""" layer = Vectors() assert layer.data.shape == (0, 2, 2) def test_no_data_vectors_with_ndim(): """Test instantiating Vectors layers with no data but specifying ndim""" layer = Vectors(ndim=2) assert layer.data.shape[-1] == 2 def test_incompatible_ndim_vectors(): """Test instantiating Vectors layer with ndim argument incompatible with data""" data = np.empty((0, 2, 2)) with pytest.raises(ValueError): Vectors(data, ndim=3) def test_empty_vectors(): """Test instantiating Vectors layer with empty coordinate-like 2D data.""" shape = (0, 2, 2) data = np.empty(shape) layer = Vectors(data) assert np.all(layer.data == data) assert layer.data.shape == shape assert layer.ndim == shape[2] assert layer._view_data.shape[2] == 2 def test_empty_vectors_with_property_choices(): """Test instantiating Vectors layer with empty coordinate-like 2D data.""" shape = (0, 2, 2) data = np.empty(shape) property_choices = {'angle': np.array([0.5], dtype=float)} layer = Vectors(data, property_choices=property_choices) assert np.all(layer.data == data) assert layer.data.shape == shape assert layer.ndim == shape[2] assert layer._view_data.shape[2] == 2 np.testing.assert_equal(layer.property_choices, property_choices) def test_empty_layer_with_edge_colormap(): """Test creating an empty layer where the edge color is a colormap""" shape = (0, 2, 2) data = np.empty(shape) default_properties = {'angle': np.array([1.5], dtype=float)} layer = Vectors( data=data, property_choices=default_properties, edge_color='angle', edge_colormap='grays', ) assert layer.edge_color_mode == 'colormap' # edge_color should remain empty when refreshing colors layer.refresh_colors(update_color_mapping=True) np.testing.assert_equal(layer.edge_color, np.empty((0, 4))) def test_empty_layer_with_edge_color_cycle(): """Test creating an empty layer where the edge color is a color cycle""" shape = (0, 2, 2) data = np.empty(shape) default_properties = {'vector_type': np.array(['A'])} layer = Vectors( data=data, property_choices=default_properties, edge_color='vector_type', ) assert layer.edge_color_mode == 'cycle' # edge_color should remain empty when refreshing colors layer.refresh_colors(update_color_mapping=True) np.testing.assert_equal(layer.edge_color, np.empty((0, 4))) def test_random_3D_vectors(): """Test instantiating Vectors layer with random coordinate-like 3D data.""" shape = (10, 2, 3) np.random.seed(0) data = np.random.random(shape) data[:, 0, :] = 20 * data[:, 0, :] layer = Vectors(data) assert np.all(layer.data == data) assert layer.data.shape == shape assert layer.ndim == shape[2] assert layer._view_data.shape[2] == 2 def test_random_3D_vectors_image(): """Test instantiating Vectors layer with random image-like 3D data.""" shape = (12, 20, 10, 3) np.random.seed(0) data = np.random.random(shape) layer = Vectors(data) assert layer.data.shape == (12 * 20 * 10, 2, 3) assert layer.ndim == 3 assert layer._view_data.shape[2] == 2 def test_no_data_3D_vectors_with_ndim(): """Test instantiating Vectors layers with no data but specifying ndim""" layer = Vectors(ndim=3) assert layer.data.shape[-1] == 3 @pytest.mark.filterwarnings("ignore:Passing `np.nan`:DeprecationWarning:numpy") def test_empty_3D_vectors(): """Test instantiating Vectors layer with empty coordinate-like 3D data.""" shape = (0, 2, 3) data = np.empty(shape) layer = Vectors(data) assert np.all(layer.data == data) assert layer.data.shape == shape assert layer.ndim == shape[2] assert layer._view_data.shape[2] == 2 def test_data_setter(): n_vectors_0 = 10 shape = (n_vectors_0, 2, 3) np.random.seed(0) data = np.random.random(shape) data[:, 0, :] = 20 * data[:, 0, :] properties = { 'prop_0': np.random.random((n_vectors_0,)), 'prop_1': np.random.random((n_vectors_0,)), } layer = Vectors(data, properties=properties) assert len(layer.data) == n_vectors_0 assert len(layer.edge_color) == n_vectors_0 assert len(layer.properties['prop_0']) == n_vectors_0 assert len(layer.properties['prop_1']) == n_vectors_0 # set the data with more vectors n_vectors_1 = 20 data_1 = np.random.random((n_vectors_1, 2, 3)) data_1[:, 0, :] = 20 * data_1[:, 0, :] layer.data = data_1 assert len(layer.data) == n_vectors_1 assert len(layer.edge_color) == n_vectors_1 assert len(layer.properties['prop_0']) == n_vectors_1 assert len(layer.properties['prop_1']) == n_vectors_1 # set the data with fewer vectors n_vectors_2 = 5 data_2 = np.random.random((n_vectors_2, 2, 3)) data_2[:, 0, :] = 20 * data_2[:, 0, :] layer.data = data_2 assert len(layer.data) == n_vectors_2 assert len(layer.edge_color) == n_vectors_2 assert len(layer.properties['prop_0']) == n_vectors_2 assert len(layer.properties['prop_1']) == n_vectors_2 def test_properties_dataframe(): """test if properties can be provided as a DataFrame""" shape = (10, 2) np.random.seed(0) shape = (10, 2, 2) data = np.random.random(shape) data[:, 0, :] = 20 * data[:, 0, :] properties = {'vector_type': np.array(['A', 'B'] * int(shape[0] / 2))} properties_df = pd.DataFrame(properties) properties_df = properties_df.astype(properties['vector_type'].dtype) layer = Vectors(data, properties=properties_df) np.testing.assert_equal(layer.properties, properties) # test adding a dataframe via the properties setter properties_2 = {'vector_type2': np.array(['A', 'B'] * int(shape[0] / 2))} properties_df2 = pd.DataFrame(properties_2) layer.properties = properties_df2 np.testing.assert_equal(layer.properties, properties_2) def test_adding_properties(): """test adding properties to a Vectors layer""" shape = (10, 2) np.random.seed(0) shape = (10, 2, 2) data = np.random.random(shape) data[:, 0, :] = 20 * data[:, 0, :] properties = {'vector_type': np.array(['A', 'B'] * int(shape[0] / 2))} layer = Vectors(data) # properties should start empty assert layer.properties == {} # add properties layer.properties = properties np.testing.assert_equal(layer.properties, properties) # removing a property that was the _edge_color_property should give a warning layer.edge_color = 'vector_type' properties_2 = { 'not_vector_type': np.array(['A', 'B'] * int(shape[0] / 2)) } with pytest.warns(RuntimeWarning): layer.properties = properties_2 # adding properties with the wrong length should raise an exception bad_properties = {'vector_type': np.array(['A', 'B'])} with pytest.raises(ValueError): layer.properties = bad_properties def test_changing_data(): """Test changing Vectors data.""" shape_a = (10, 2, 2) np.random.seed(0) data_a = np.random.random(shape_a) data_a[:, 0, :] = 20 * data_a[:, 0, :] shape_b = (16, 2, 2) data_b = np.random.random(shape_b) data_b[:, 0, :] = 20 * data_b[:, 0, :] layer = Vectors(data_b) layer.data = data_b assert np.all(layer.data == data_b) assert layer.data.shape == shape_b assert layer.ndim == shape_b[2] assert layer._view_data.shape[2] == 2 def test_name(): """Test setting layer name.""" np.random.seed(0) data = np.random.random((10, 2, 2)) data[:, 0, :] = 20 * data[:, 0, :] layer = Vectors(data) assert layer.name == 'Vectors' layer = Vectors(data, name='random') assert layer.name == 'random' layer.name = 'vcts' assert layer.name == 'vcts' def test_visiblity(): """Test setting layer visibility.""" np.random.seed(0) data = np.random.random((10, 2, 2)) data[:, 0, :] = 20 * data[:, 0, :] layer = Vectors(data) assert layer.visible is True layer.visible = False assert layer.visible is False layer = Vectors(data, visible=False) assert layer.visible is False layer.visible = True assert layer.visible is True def test_opacity(): """Test setting layer opacity.""" np.random.seed(0) data = np.random.random((10, 2, 2)) data[:, 0, :] = 20 * data[:, 0, :] layer = Vectors(data) assert layer.opacity == 0.7 layer.opacity = 0.5 assert layer.opacity == 0.5 layer = Vectors(data, opacity=0.6) assert layer.opacity == 0.6 layer.opacity = 0.3 assert layer.opacity == 0.3 def test_blending(): """Test setting layer blending.""" np.random.seed(0) data = np.random.random((10, 2, 2)) data[:, 0, :] = 20 * data[:, 0, :] layer = Vectors(data) assert layer.blending == 'translucent' layer.blending = 'additive' assert layer.blending == 'additive' layer = Vectors(data, blending='additive') assert layer.blending == 'additive' layer.blending = 'opaque' assert layer.blending == 'opaque' def test_edge_width(): """Test setting edge width.""" np.random.seed(0) data = np.random.random((10, 2, 2)) data[:, 0, :] = 20 * data[:, 0, :] layer = Vectors(data) assert layer.edge_width == 1 layer.edge_width = 2 assert layer.edge_width == 2 layer = Vectors(data, edge_width=3) assert layer.edge_width == 3 def test_invalid_edge_color(): """Test providing an invalid edge color raises an exception""" np.random.seed(0) shape = (10, 2, 2) data = np.random.random(shape) data[:, 0, :] = 20 * data[:, 0, :] layer = Vectors(data) with pytest.raises(ValueError): layer.edge_color = 5 def test_edge_color_direct(): """Test setting edge color.""" np.random.seed(0) data = np.random.random((10, 2, 2)) data[:, 0, :] = 20 * data[:, 0, :] layer = Vectors(data) np.testing.assert_allclose( layer.edge_color, np.repeat([[1, 0, 0, 1]], data.shape[0], axis=0) ) # set edge color as an RGB array layer.edge_color = [0, 0, 1] np.testing.assert_allclose( layer.edge_color, np.repeat([[0, 0, 1, 1]], data.shape[0], axis=0) ) # set edge color as an RGBA array layer.edge_color = [0, 1, 0, 0.5] np.testing.assert_allclose( layer.edge_color, np.repeat([[0, 1, 0, 0.5]], data.shape[0], axis=0) ) # set all edge colors directly edge_colors = np.random.random((data.shape[0], 4)) layer.edge_color = edge_colors np.testing.assert_allclose(layer.edge_color, edge_colors) def test_edge_color_cycle(): """Test creating Vectors where edge color is set by a color cycle""" np.random.seed(0) shape = (10, 2, 2) data = np.random.random(shape) data[:, 0, :] = 20 * data[:, 0, :] properties = {'vector_type': np.array(['A', 'B'] * int(shape[0] / 2))} color_cycle = ['red', 'blue'] layer = Vectors( data, properties=properties, edge_color='vector_type', edge_color_cycle=color_cycle, ) np.testing.assert_equal(layer.properties, properties) edge_color_array = transform_color(color_cycle * int(shape[0] / 2)) assert np.all(layer.edge_color == edge_color_array) def test_edge_color_colormap(): """Test creating Vectors where edge color is set by a colormap""" shape = (10, 2) shape = (10, 2, 2) data = np.random.random(shape) data[:, 0, :] = 20 * data[:, 0, :] properties = {'angle': np.array([0, 1.5] * int(shape[0] / 2))} layer = Vectors( data, properties=properties, edge_color='angle', edge_colormap='gray', ) np.testing.assert_equal(layer.properties, properties) assert layer.edge_color_mode == 'colormap' edge_color_array = transform_color(['black', 'white'] * int(shape[0] / 2)) assert np.all(layer.edge_color == edge_color_array) # change the color cycle - edge_color should not change layer.edge_color_cycle = ['red', 'blue'] assert np.all(layer.edge_color == edge_color_array) # adjust the clims layer.edge_contrast_limits = (0, 3) layer.refresh_colors(update_color_mapping=False) np.testing.assert_allclose(layer.edge_color[-1], [0.5, 0.5, 0.5, 1]) # change the colormap new_colormap = 'viridis' layer.edge_colormap = new_colormap assert layer.edge_colormap.name == new_colormap # test adding a colormap with a vispy Colormap object layer.edge_colormap = get_colormap('gray') assert 'unnamed colormap' in layer.edge_colormap.name def test_edge_color_map_non_numeric_property(): """Test setting edge_color as a color map of a non-numeric property raises an error """ np.random.seed(0) shape = (10, 2, 2) data = np.random.random(shape) data[:, 0, :] = 20 * data[:, 0, :] properties = {'vector_type': np.array(['A', 'B'] * int(shape[0] / 2))} color_cycle = ['red', 'blue'] initial_color = [0, 1, 0, 1] layer = Vectors( data, properties=properties, edge_color=initial_color, edge_color_cycle=color_cycle, edge_colormap='gray', ) # layer should start out in direct edge color mode with all green vectors assert layer.edge_color_mode == 'direct' np.testing.assert_allclose( layer.edge_color, np.repeat([initial_color], shape[0], axis=0) ) # switching to colormap mode should raise an error because the 'vector_type' is non-numeric layer.edge_color = 'vector_type' with pytest.raises(TypeError): layer.edge_color_mode = 'colormap' def test_switching_edge_color_mode(): """Test transitioning between all color modes""" np.random.seed(0) shape = (10, 2, 2) data = np.random.random(shape) data[:, 0, :] = 20 * data[:, 0, :] properties = { 'magnitude': np.arange(shape[0]), 'vector_type': np.array(['A', 'B'] * int(shape[0] / 2)), } color_cycle = ['red', 'blue'] initial_color = [0, 1, 0, 1] layer = Vectors( data, properties=properties, edge_color=initial_color, edge_color_cycle=color_cycle, edge_colormap='gray', ) # layer should start out in direct edge color mode with all green vectors assert layer.edge_color_mode == 'direct' np.testing.assert_allclose( layer.edge_color, np.repeat([initial_color], shape[0], axis=0) ) # there should not be an edge_color_property assert layer._edge.color_properties is None # transitioning to colormap should raise a warning # because there isn't an edge color property yet and # the first property in Vectors.properties is being automatically selected with pytest.warns(RuntimeWarning): layer.edge_color_mode = 'colormap' assert layer._edge.color_properties.name == next(iter(properties)) np.testing.assert_allclose(layer.edge_color[-1], [1, 1, 1, 1]) # switch to color cycle layer.edge_color_mode = 'cycle' layer.edge_color = 'vector_type' edge_color_array = transform_color(color_cycle * int(shape[0] / 2)) np.testing.assert_allclose(layer.edge_color, edge_color_array) # switch back to direct, edge_colors shouldn't change edge_colors = layer.edge_color layer.edge_color_mode = 'direct' np.testing.assert_allclose(layer.edge_color, edge_colors) def test_properties_color_mode_without_properties(): """Test that switching to a colormode requiring properties without properties defined raises an exceptions """ np.random.seed(0) shape = (10, 2, 2) data = np.random.random(shape) data[:, 0, :] = 20 * data[:, 0, :] layer = Vectors(data) assert layer.properties == {} with pytest.raises(ValueError): layer.edge_color_mode = 'colormap' with pytest.raises(ValueError): layer.edge_color_mode = 'cycle' def test_length(): """Test setting length.""" np.random.seed(0) data = np.random.random((10, 2, 2)) data[:, 0, :] = 20 * data[:, 0, :] layer = Vectors(data) assert layer.length == 1 layer.length = 2 assert layer.length == 2 layer = Vectors(data, length=3) assert layer.length == 3 def test_thumbnail(): """Test the image thumbnail for square data.""" np.random.seed(0) data = np.random.random((10, 2, 2)) data[:, 0, :] = 18 * data[:, 0, :] + 1 data[0, :, :] = [0, 0] data[-1, 0, :] = [20, 20] data[-1, 1, :] = [0, 0] layer = Vectors(data) layer._update_thumbnail() assert layer.thumbnail.shape == layer._thumbnail_shape def test_big_thumbail(): """Test the image thumbnail with n_vectors > _max_vectors_thumbnail""" np.random.seed(0) n_vectors = int(1.5 * Vectors._max_vectors_thumbnail) data = np.random.random((n_vectors, 2, 2)) data[:, 0, :] = 18 * data[:, 0, :] + 1 data[0, :, :] = [0, 0] data[-1, 0, :] = [20, 20] data[-1, 1, :] = [0, 0] layer = Vectors(data) layer._update_thumbnail() assert layer.thumbnail.shape == layer._thumbnail_shape def test_value(): """Test getting the value of the data at the current coordinates.""" np.random.seed(0) data = np.random.random((10, 2, 2)) data[:, 0, :] = 20 * data[:, 0, :] layer = Vectors(data) value = layer.get_value((0,) * 2) assert value is None @pytest.mark.parametrize( 'position,view_direction,dims_displayed,world', [ ((0, 0, 0), [1, 0, 0], [0, 1, 2], False), ((0, 0, 0), [1, 0, 0], [0, 1, 2], True), ((0, 0, 0, 0), [0, 1, 0, 0], [1, 2, 3], True), ], ) def test_value_3d(position, view_direction, dims_displayed, world): """Currently get_value should return None in 3D""" np.random.seed(0) data = np.random.random((10, 2, 3)) data[:, 0, :] = 20 * data[:, 0, :] layer = Vectors(data) layer._slice_dims([0, 0, 0], ndisplay=3) value = layer.get_value( position, view_direction=view_direction, dims_displayed=dims_displayed, world=world, ) assert value is None def test_message(): """Test converting value and coords to message.""" np.random.seed(0) data = np.random.random((10, 2, 2)) data[:, 0, :] = 20 * data[:, 0, :] layer = Vectors(data) msg = layer.get_status((0,) * 2) assert type(msg) == dict def test_world_data_extent(): """Test extent after applying transforms.""" # data input format is start position, then length. data = [[(7, -5, -3), (1, -1, 2)], [(0, 0, 0), (4, 30, 12)]] min_val = (0, -6, -3) max_val = (8, 30, 12) layer = Vectors(np.array(data)) extent = np.array((min_val, max_val)) check_layer_world_data_extent(layer, extent, (3, 1, 1), (10, 20, 5), False) def test_out_of_slice_display(): """Test setting out_of_slice_display flag for 2D and 4D data.""" shape = (10, 2, 2) np.random.seed(0) data = 20 * np.random.random(shape) layer = Vectors(data) assert layer.out_of_slice_display is False layer.out_of_slice_display = True assert layer.out_of_slice_display is True layer = Vectors(data, out_of_slice_display=True) assert layer.out_of_slice_display is True shape = (10, 2, 4) data = 20 * np.random.random(shape) layer = Vectors(data) assert layer.out_of_slice_display is False layer.out_of_slice_display = True assert layer.out_of_slice_display is True layer = Vectors(data, out_of_slice_display=True) assert layer.out_of_slice_display is True def test_empty_data_from_tuple(): """Test that empty data raises an error.""" layer = Vectors(name="vector", ndim=3) layer2 = Vectors.create(*layer.as_layer_data_tuple()) assert layer2.data.size == 0 napari-0.5.0a1/napari/layers/vectors/_vector_utils.py000066400000000000000000000067241437041365600227230ustar00rootroot00000000000000from typing import Optional, Tuple import numpy as np from napari.utils.translations import trans def convert_image_to_coordinates(vectors): """To convert an image-like array with elements (y-proj, x-proj) into a position list of coordinates Every pixel position (n, m) results in two output coordinates of (N,2) Parameters ---------- vectors : (N1, N2, ..., ND, D) array "image-like" data where there is a length D vector of the projections at each pixel. Returns ------- coords : (N, 2, D) array A list of N vectors with start point and projections of the vector in D dimensions. """ # create coordinate spacing for image spacing = [list(range(r)) for r in vectors.shape[:-1]] grid = np.meshgrid(*spacing) # create empty vector of necessary shape nvect = np.prod(vectors.shape[:-1]) coords = np.empty((nvect, 2, vectors.ndim - 1), dtype=np.float32) # assign coordinates to all pixels for i, g in enumerate(grid): coords[:, 0, i] = g.flatten() coords[:, 1, :] = np.reshape(vectors, (-1, vectors.ndim - 1)) return coords def fix_data_vectors( vectors: Optional[np.ndarray], ndim: Optional[int] ) -> Tuple[np.ndarray, int]: """ Ensure that vectors array is 3d and have second dimension of size 2 and third dimension of size ndim (default 2 for empty arrays) Parameters ---------- vectors : (N, 2, D) or (N1, N2, ..., ND, D) array A (N, 2, D) array is interpreted as "coordinate-like" data and a list of N vectors with start point and projections of the vector in D dimensions. A (N1, N2, ..., ND, D) array is interpreted as "image-like" data where there is a length D vector of the projections at each pixel. ndim : int or None number of expected dimensions Returns ------- vectors : (N, 2, D) array Vectors array ndim : int number of dimensions Raises ------ ValueError if ndim does not match with third dimensions of vectors """ if vectors is None: vectors = [] vectors = np.asarray(vectors) if vectors.ndim == 3 and vectors.shape[1] == 2: # an (N, 2, D) array that is coordinate-like, we're good to go pass elif vectors.size == 0: if ndim is None: ndim = 2 vectors = np.empty((0, 2, ndim)) elif vectors.shape[-1] == vectors.ndim - 1: # an (N1, N2, ..., ND, D) array that is image-like vectors = convert_image_to_coordinates(vectors) else: # np.atleast_3d does not reshape (2, 3) to (1, 2, 3) as one would expect # when passing a single vector if vectors.ndim == 2: vectors = vectors[np.newaxis] if vectors.ndim != 3 or vectors.shape[1] != 2: raise ValueError( trans._( "could not reshape Vector data from {vectors_shape} to (N, 2, {dimensions})", deferred=True, vectors_shape=vectors.shape, dimensions=ndim or 'D', ) ) data_ndim = vectors.shape[2] if ndim is not None and ndim != data_ndim: raise ValueError( trans._( "Vectors dimensions ({data_ndim}) must be equal to ndim ({ndim})", deferred=True, data_ndim=data_ndim, ndim=ndim, ) ) ndim = data_ndim return vectors, ndim napari-0.5.0a1/napari/layers/vectors/_vectors_key_bindings.py000066400000000000000000000016111437041365600244010ustar00rootroot00000000000000from napari.layers.base._base_constants import Mode from napari.layers.utils.layer_utils import ( register_layer_action, register_layer_attr_action, ) from napari.layers.vectors.vectors import Vectors from napari.utils.translations import trans def register_vectors_action(description: str, repeatable: bool = False): return register_layer_action(Vectors, description, repeatable) def register_vectors_mode_action(description): return register_layer_attr_action(Vectors, description, 'mode') @register_vectors_mode_action(trans._('Transform')) def activate_vectors_transform_mode(layer): layer.mode = Mode.TRANSFORM @register_vectors_mode_action(trans._('Pan/zoom')) def activate_vectors_pan_zoom_mode(layer): layer.mode = Mode.PAN_ZOOM vectors_fun_to_mode = [ (activate_vectors_pan_zoom_mode, Mode.PAN_ZOOM), (activate_vectors_transform_mode, Mode.TRANSFORM), ] napari-0.5.0a1/napari/layers/vectors/vectors.py000066400000000000000000000673701437041365600215330ustar00rootroot00000000000000import warnings from copy import copy from typing import Dict, List, Tuple, Union import numpy as np import pandas as pd from napari.layers.base import Layer from napari.layers.utils._color_manager_constants import ColorMode from napari.layers.utils.color_manager import ColorManager from napari.layers.utils.color_transformations import ColorType from napari.layers.utils.layer_utils import _FeatureTable from napari.layers.vectors._vector_utils import fix_data_vectors from napari.utils.colormaps import Colormap, ValidColormapArg from napari.utils.events import Event from napari.utils.events.custom_types import Array from napari.utils.translations import trans class Vectors(Layer): """ Vectors layer renders lines onto the canvas. Parameters ---------- data : (N, 2, D) or (N1, N2, ..., ND, D) array An (N, 2, D) array is interpreted as "coordinate-like" data and a list of N vectors with start point and projections of the vector in D dimensions. An (N1, N2, ..., ND, D) array is interpreted as "image-like" data where there is a length D vector of the projections at each pixel. ndim : int Number of dimensions for vectors. When data is not None, ndim must be D. An empty vectors layer can be instantiated with arbitrary ndim. features : dict[str, array-like] or DataFrame Features table where each row corresponds to a vector and each column is a feature. properties : dict {str: array (N,)}, DataFrame Properties for each vector. Each property should be an array of length N, where N is the number of vectors. property_choices : dict {str: array (N,)} possible values for each property. edge_width : float Width for all vectors in pixels. length : float Multiplicative factor on projections for length of all vectors. edge_color : str Color of all of the vectors. edge_color_cycle : np.ndarray, list Cycle of colors (provided as string name, RGB, or RGBA) to map to edge_color if a categorical attribute is used color the vectors. edge_colormap : str, napari.utils.Colormap Colormap to set vector color if a continuous attribute is used to set edge_color. edge_contrast_limits : None, (float, float) clims for mapping the property to a color map. These are the min and max value of the specified property that are mapped to 0 and 1, respectively. The default value is None. If set the none, the clims will be set to (property.min(), property.max()) out_of_slice_display : bool If True, renders vectors not just in central plane but also slightly out of slice according to specified point marker size. name : str Name of the layer. metadata : dict Layer metadata. scale : tuple of float Scale factors for the layer. translate : tuple of float Translation values for the layer. rotate : float, 3-tuple of float, or n-D array. If a float convert into a 2D rotation matrix using that value as an angle. If 3-tuple convert into a 3D rotation matrix, using a yaw, pitch, roll convention. Otherwise assume an nD rotation. Angles are assumed to be in degrees. They can be converted from radians with np.degrees if needed. shear : 1-D array or n-D array Either a vector of upper triangular values, or an nD shear matrix with ones along the main diagonal. affine : n-D array or napari.utils.transforms.Affine (N+1, N+1) affine transformation matrix in homogeneous coordinates. The first (N, N) entries correspond to a linear transform and the final column is a length N translation vector and a 1 or a napari `Affine` transform object. Applied as an extra transform on top of the provided scale, rotate, and shear values. opacity : float Opacity of the layer visual, between 0.0 and 1.0. blending : str One of a list of preset blending modes that determines how RGB and alpha values of the layer visual get mixed. Allowed values are {'opaque', 'translucent', and 'additive'}. visible : bool Whether the layer visual is currently being displayed. cache : bool Whether slices of out-of-core datasets should be cached upon retrieval. Currently, this only applies to dask arrays. Attributes ---------- data : (N, 2, D) array The start point and projections of N vectors in D dimensions. features : Dataframe-like Features table where each row corresponds to a vector and each column is a feature. feature_defaults : DataFrame-like Stores the default value of each feature in a table with one row. properties : dict {str: array (N,)}, DataFrame Properties for each vector. Each property should be an array of length N, where N is the number of vectors. edge_width : float Width for all vectors in pixels. length : float Multiplicative factor on projections for length of all vectors. edge_color : str Color of all of the vectors. edge_color_cycle : np.ndarray, list Cycle of colors (provided as string name, RGB, or RGBA) to map to edge_color if a categorical attribute is used color the vectors. edge_colormap : str, napari.utils.Colormap Colormap to set vector color if a continuous attribute is used to set edge_color. edge_contrast_limits : None, (float, float) clims for mapping the property to a color map. These are the min and max value of the specified property that are mapped to 0 and 1, respectively. The default value is None. If set the none, the clims will be set to (property.min(), property.max()) out_of_slice_display : bool If True, renders vectors not just in central plane but also slightly out of slice according to specified point marker size. Notes ----- _view_data : (M, 2, 2) array The start point and projections of N vectors in 2D for vectors whose start point is in the currently viewed slice. _view_face_color : (M, 4) np.ndarray colors for the M in view vectors _view_indices : (1, M) array indices for the M in view vectors _view_alphas : (M,) or float relative opacity for the M in view vectors _property_choices : dict {str: array (N,)} Possible values for the properties in Vectors.properties. _max_vectors_thumbnail : int The maximum number of vectors that will ever be used to render the thumbnail. If more vectors are present then they are randomly subsampled. """ # The max number of vectors that will ever be used to render the thumbnail # If more vectors are present then they are randomly subsampled _max_vectors_thumbnail = 1024 def __init__( self, data=None, *, ndim=None, features=None, properties=None, property_choices=None, edge_width=1, edge_color='red', edge_color_cycle=None, edge_colormap='viridis', edge_contrast_limits=None, out_of_slice_display=False, length=1, name=None, metadata=None, scale=None, translate=None, rotate=None, shear=None, affine=None, opacity=0.7, blending='translucent', visible=True, cache=True, experimental_clipping_planes=None, ) -> None: if ndim is None and scale is not None: ndim = len(scale) data, ndim = fix_data_vectors(data, ndim) super().__init__( data, ndim, name=name, metadata=metadata, scale=scale, translate=translate, rotate=rotate, shear=shear, affine=affine, opacity=opacity, blending=blending, visible=visible, cache=cache, experimental_clipping_planes=experimental_clipping_planes, ) # events for non-napari calculations self.events.add( length=Event, edge_width=Event, edge_color=Event, edge_color_mode=Event, properties=Event, out_of_slice_display=Event, features=Event, feature_defaults=Event, ) # Save the vector style params self._edge_width = edge_width self._out_of_slice_display = out_of_slice_display self._length = float(length) self._data = data self._displayed_stored = None self._feature_table = _FeatureTable.from_layer( features=features, properties=properties, property_choices=property_choices, num_data=len(self.data), ) self._edge = ColorManager._from_layer_kwargs( n_colors=len(self.data), colors=edge_color, continuous_colormap=edge_colormap, contrast_limits=edge_contrast_limits, categorical_colormap=edge_color_cycle, properties=self.properties if self._data.size > 0 else self.property_choices, ) # Data containing vectors in the currently viewed slice self._view_data = np.empty((0, 2, 2)) self._displayed_stored = [] self._view_indices = [] self._view_alphas = [] # now that everything is set up, make the layer visible (if set to visible) self.refresh() self.visible = visible @property def data(self) -> np.ndarray: """(N, 2, D) array: start point and projections of vectors.""" return self._data @data.setter def data(self, vectors: np.ndarray): previous_n_vectors = len(self.data) self._data, _ = fix_data_vectors(vectors, self.ndim) n_vectors = len(self.data) # Adjust the props/color arrays when the number of vectors has changed with self.events.blocker_all(): with self._edge.events.blocker_all(): self._feature_table.resize(n_vectors) if n_vectors < previous_n_vectors: # If there are now fewer points, remove the size and colors of the # extra ones if len(self._edge.colors) > n_vectors: self._edge._remove( np.arange(n_vectors, len(self._edge.colors)) ) elif n_vectors > previous_n_vectors: # If there are now more points, add the size and colors of the # new ones adding = n_vectors - previous_n_vectors self._edge._add(n_colors=adding) self._update_dims() self.events.data(value=self.data) self._reset_editable() @property def features(self): """Dataframe-like features table. It is an implementation detail that this is a `pandas.DataFrame`. In the future, we will target the currently-in-development Data API dataframe protocol [1]. This will enable us to use alternate libraries such as xarray or cuDF for additional features without breaking existing usage of this. If you need to specifically rely on the pandas API, please coerce this to a `pandas.DataFrame` using `features_to_pandas_dataframe`. References ---------- .. [1]: https://data-apis.org/dataframe-protocol/latest/API.html """ return self._feature_table.values @features.setter def features( self, features: Union[Dict[str, np.ndarray], pd.DataFrame], ) -> None: self._feature_table.set_values(features, num_data=len(self.data)) if self._edge.color_properties is not None: if self._edge.color_properties.name not in self.features: self._edge.color_mode = ColorMode.DIRECT self._edge.color_properties = None warnings.warn( trans._( 'property used for edge_color dropped', deferred=True, ), RuntimeWarning, ) else: edge_color_name = self._edge.color_properties.name property_values = self.features[edge_color_name].to_numpy() self._edge.color_properties = { 'name': edge_color_name, 'values': property_values, 'current_value': self.feature_defaults[edge_color_name][0], } self.events.properties() self.events.features() @property def properties(self) -> Dict[str, np.ndarray]: """dict {str: array (N,)}, DataFrame: Annotations for each point""" return self._feature_table.properties() @properties.setter def properties(self, properties: Dict[str, Array]): self.features = properties @property def feature_defaults(self): """Dataframe-like with one row of feature default values. See `features` for more details on the type of this property. """ return self._feature_table.defaults @property def property_choices(self) -> Dict[str, np.ndarray]: return self._feature_table.choices() def _get_state(self): """Get dictionary of layer state. Returns ------- state : dict Dictionary of layer state. """ state = self._get_base_state() state.update( { 'length': self.length, 'edge_width': self.edge_width, 'edge_color': self.edge_color if self.data.size else [self._edge.current_color], 'edge_color_cycle': self.edge_color_cycle, 'edge_colormap': self.edge_colormap.name, 'edge_contrast_limits': self.edge_contrast_limits, 'data': self.data, 'properties': self.properties, 'property_choices': self.property_choices, 'ndim': self.ndim, 'features': self.features, 'out_of_slice_display': self.out_of_slice_display, } ) return state def _get_ndim(self) -> int: """Determine number of dimensions of the layer.""" return self.data.shape[2] @property def _extent_data(self) -> np.ndarray: """Extent of layer in data coordinates. Returns ------- extent_data : array, shape (2, D) """ if len(self.data) == 0: extrema = np.full((2, self.ndim), np.nan) else: # Convert from projections to endpoints using the current length data = copy(self.data) data[:, 1, :] = data[:, 0, :] + self.length * data[:, 1, :] maxs = np.max(data, axis=(0, 1)) mins = np.min(data, axis=(0, 1)) extrema = np.vstack([mins, maxs]) return extrema @property def out_of_slice_display(self) -> bool: """bool: renders vectors slightly out of slice.""" return self._out_of_slice_display @out_of_slice_display.setter def out_of_slice_display(self, out_of_slice_display: bool) -> None: self._out_of_slice_display = out_of_slice_display self.events.out_of_slice_display() self.refresh() @property def edge_width(self) -> Union[int, float]: """float: Width for all vectors in pixels.""" return self._edge_width @edge_width.setter def edge_width(self, edge_width: Union[int, float]): self._edge_width = edge_width self.events.edge_width() self.refresh() @property def length(self) -> Union[int, float]: """float: Multiplicative factor for length of all vectors.""" return self._length @length.setter def length(self, length: Union[int, float]): self._length = float(length) self.events.length() self.refresh() @property def edge_color(self) -> np.ndarray: """(1 x 4) np.ndarray: Array of RGBA edge colors (applied to all vectors)""" return self._edge.colors @edge_color.setter def edge_color(self, edge_color: ColorType): self._edge._set_color( color=edge_color, n_colors=len(self.data), properties=self.properties, current_properties=self._feature_table.currents(), ) self.events.edge_color() def refresh_colors(self, update_color_mapping: bool = False): """Calculate and update edge colors if using a cycle or color map Parameters ---------- update_color_mapping : bool If set to True, the function will recalculate the color cycle map or colormap (whichever is being used). If set to False, the function will use the current color cycle map or color map. For example, if you are adding/modifying vectors and want them to be colored with the same mapping as the other vectors (i.e., the new vectors shouldn't affect the color cycle map or colormap), set update_color_mapping=False. Default value is False. """ self._edge._refresh_colors(self.properties, update_color_mapping) @property def edge_color_mode(self) -> ColorMode: """str: Edge color setting mode DIRECT (default mode) allows each vector to be set arbitrarily CYCLE allows the color to be set via a color cycle over an attribute COLORMAP allows color to be set via a color map over an attribute """ return self._edge.color_mode @edge_color_mode.setter def edge_color_mode(self, edge_color_mode: Union[str, ColorMode]): edge_color_mode = ColorMode(edge_color_mode) if edge_color_mode == ColorMode.DIRECT: self._edge_color_mode = edge_color_mode elif edge_color_mode in (ColorMode.CYCLE, ColorMode.COLORMAP): if self._edge.color_properties is not None: color_property = self._edge.color_properties.name else: color_property = '' if color_property == '': if self.properties: color_property = next(iter(self.properties)) self._edge.color_properties = { 'name': color_property, 'values': self.features[color_property].to_numpy(), 'current_value': self.feature_defaults[color_property][ 0 ], } warnings.warn( trans._( 'edge_color property was not set, setting to: {color_property}', deferred=True, color_property=color_property, ), RuntimeWarning, ) else: raise ValueError( trans._( 'There must be a valid Points.properties to use {edge_color_mode}', deferred=True, edge_color_mode=edge_color_mode, ) ) # ColorMode.COLORMAP can only be applied to numeric properties if (edge_color_mode == ColorMode.COLORMAP) and not issubclass( self.properties[color_property].dtype.type, np.number, ): raise TypeError( trans._( 'selected property must be numeric to use ColorMode.COLORMAP', deferred=True, ) ) self._edge.color_mode = edge_color_mode self.events.edge_color_mode() @property def edge_color_cycle(self) -> np.ndarray: """list, np.ndarray : Color cycle for edge_color. Can be a list of colors defined by name, RGB or RGBA """ return self._edge.categorical_colormap.fallback_color.values @edge_color_cycle.setter def edge_color_cycle(self, edge_color_cycle: Union[list, np.ndarray]): self._edge.categorical_colormap = edge_color_cycle @property def edge_colormap(self) -> Tuple[str, Colormap]: """Return the colormap to be applied to a property to get the edge color. Returns ------- colormap : napari.utils.Colormap The Colormap object. """ return self._edge.continuous_colormap @edge_colormap.setter def edge_colormap(self, colormap: ValidColormapArg): self._edge.continuous_colormap = colormap @property def edge_contrast_limits(self) -> Tuple[float, float]: """None, (float, float): contrast limits for mapping the edge_color colormap property to 0 and 1 """ return self._edge.contrast_limits @edge_contrast_limits.setter def edge_contrast_limits( self, contrast_limits: Union[None, Tuple[float, float]] ): self._edge.contrast_limits = contrast_limits @property def _view_face_color(self) -> np.ndarray: """(Mx4) np.ndarray : colors for the M in view vectors""" face_color = self.edge_color[self._view_indices] face_color[:, -1] *= self._view_alphas face_color = np.repeat(face_color, 2, axis=0) if self._slice_input.ndisplay == 3 and self.ndim > 2: face_color = np.vstack([face_color, face_color]) return face_color def _slice_data( self, dims_indices ) -> Tuple[List[int], Union[float, np.ndarray]]: """Determines the slice of vectors given the indices. Parameters ---------- dims_indices : sequence of int, float or slice objects Indices of the slicing plane Returns ------- slice_indices : list Indices of vectors in the currently viewed slice. alpha : float, (N, ) array The computed, relative opacity of vectors in the current slice. If `out_of_slice_display` is mode is off, this is always 1. Otherwise, vectors originating in the current slice are assigned a value of 1, while vectors passing through the current slice are assigned progressively lower values, based on how far from the current slice they originate. """ if len(self.data) > 0: dims_not_displayed = self._slice_input.not_displayed # We want a numpy array so we can use fancy indexing with the non-displayed # indices, but as dims_indices can (and often/always does) contain slice # objects, the array has dtype=object which is then very slow for the # arithmetic below. # promote slicing plane to array so we can index into it, project as type float not_disp_indices = np.array(dims_indices)[ dims_not_displayed ].astype(float) # get the anchor points (starting positions) of the vector layers in not displayed dims data = self.data[:, 0, dims_not_displayed] # calculate distances from anchor points to the slicing plane distances = abs(data - not_disp_indices) # if we need to include vectors that are out of this slice if self.out_of_slice_display is True: # get the scaled projected vectors projected_lengths = abs( self.data[:, 1, dims_not_displayed] * self.length ) # find where the distance to plane is less than the scaled vector matches = np.all(distances <= projected_lengths, axis=1) alpha_match = projected_lengths[matches] alpha_match[alpha_match == 0] = 1 alpha_per_dim = ( alpha_match - distances[matches] ) / alpha_match alpha_per_dim[alpha_match == 0] = 1 alpha = np.prod(alpha_per_dim, axis=1).astype(float) else: matches = np.all(distances <= 0.5, axis=1) alpha = 1.0 slice_indices = np.where(matches)[0].astype(int) return slice_indices, alpha else: return [], np.empty(0) def _set_view_slice(self): """Sets the view given the indices to slice with.""" indices, alphas = self._slice_data(self._slice_indices) disp = self._slice_input.displayed if len(self.data) == 0: self._view_data = np.empty((0, 2, 2)) self._view_indices = [] elif self.ndim > 2: indices, alphas = self._slice_data(self._slice_indices) self._view_indices = indices self._view_alphas = alphas self._view_data = self.data[np.ix_(indices, [0, 1], disp)] else: self._view_data = self.data[:, :, disp] self._view_indices = np.arange(self.data.shape[0]) self._view_alphas = 1.0 def _update_thumbnail(self): """Update thumbnail with current vectors and colors.""" # Set the default thumbnail to black, opacity 1 colormapped = np.zeros(self._thumbnail_shape) colormapped[..., 3] = 1 if len(self.data) == 0: self.thumbnail = colormapped else: # calculate min vals for the vertices and pad with 0.5 # the offset is needed to ensure that the top left corner of the # vectors corresponds to the top left corner of the thumbnail de = self._extent_data offset = ( np.array([de[0, d] for d in self._slice_input.displayed]) + 0.5 )[-2:] # calculate range of values for the vertices and pad with 1 # padding ensures the entire vector can be represented in the thumbnail # without getting clipped shape = np.ceil( [de[1, d] - de[0, d] + 1 for d in self._slice_input.displayed] ).astype(int)[-2:] zoom_factor = np.divide(self._thumbnail_shape[:2], shape).min() if self._view_data.shape[0] > self._max_vectors_thumbnail: thumbnail_indices = np.random.randint( 0, self._view_data.shape[0], self._max_vectors_thumbnail ) vectors = copy(self._view_data[thumbnail_indices, :, -2:]) thumbnail_color_indices = self._view_indices[thumbnail_indices] else: vectors = copy(self._view_data[:, :, -2:]) thumbnail_color_indices = self._view_indices vectors[:, 1, :] = ( vectors[:, 0, :] + vectors[:, 1, :] * self.length ) downsampled = (vectors - offset) * zoom_factor downsampled = np.clip( downsampled, 0, np.subtract(self._thumbnail_shape[:2], 1) ) edge_colors = self._edge.colors[thumbnail_color_indices] for v, ec in zip(downsampled, edge_colors): start = v[0] stop = v[1] step = int(np.ceil(np.max(abs(stop - start)))) x_vals = np.linspace(start[0], stop[0], step) y_vals = np.linspace(start[1], stop[1], step) for x, y in zip(x_vals, y_vals): colormapped[int(x), int(y), :] = ec colormapped[..., 3] *= self.opacity self.thumbnail = colormapped def _get_value(self, position): """Value of the data at a position in data coordinates. Parameters ---------- position : tuple Position in data coordinates. Returns ------- value : None Value of the data at the coord. """ return None napari-0.5.0a1/napari/plugins/000077500000000000000000000000001437041365600161545ustar00rootroot00000000000000napari-0.5.0a1/napari/plugins/__init__.py000066400000000000000000000047211437041365600202710ustar00rootroot00000000000000from functools import lru_cache from npe2 import PackageMetadata, PluginManifest from npe2 import PluginManager as _PluginManager from napari.plugins import _npe2 from napari.plugins._plugin_manager import NapariPluginManager from napari.settings import get_settings __all__ = ("plugin_manager", "menu_item_template") from napari.utils.theme import _install_npe2_themes #: Template to use for namespacing a plugin item in the menu bar # widget_name (plugin_name) menu_item_template = '{1} ({0})' """Template to use for namespacing a plugin item in the menu bar""" #: The main plugin manager instance for the `napari` plugin namespace. plugin_manager = NapariPluginManager() """Main Plugin manager instance""" @lru_cache # only call once def _initialize_plugins(): _npe2pm = _PluginManager.instance() settings = get_settings() if settings.schema_version >= '0.4.0': for p in settings.plugins.disabled_plugins: _npe2pm.disable(p) # just in case anything has already been registered before we initialized _npe2.on_plugins_registered(set(_npe2pm.iter_manifests())) # connect enablement/registration events to listeners _npe2pm.events.enablement_changed.connect( _npe2.on_plugin_enablement_change ) _npe2pm.events.plugins_registered.connect(_npe2.on_plugins_registered) _npe2pm.discover(include_npe1=settings.plugins.use_npe2_adaptor) # this is a workaround for the fact that briefcase does not seem to include # napari's entry_points.txt in the bundled app, so the builtin plugins # don't get detected. So we just register it manually. This could # potentially be removed when we move to a different bundle strategy if 'napari' not in _npe2pm._manifests: mf = PluginManifest.from_distribution('napari') mf.package_metadata = PackageMetadata.for_package('napari') _npe2pm.register(mf) # Disable plugins listed as disabled in settings, or detected in npe2 _from_npe2 = {m.name for m in _npe2pm.iter_manifests()} if 'napari' in _from_npe2: _from_npe2.update({'napari', 'builtins'}) plugin_manager._skip_packages = _from_npe2 plugin_manager._blocked.update(settings.plugins.disabled_plugins) if settings.plugins.use_npe2_adaptor: # prevent npe1 plugin_manager discovery # (this doesn't prevent manual registration) plugin_manager.discover = lambda *a, **k: None else: plugin_manager._initialize() _install_npe2_themes() napari-0.5.0a1/napari/plugins/_npe2.py000066400000000000000000000323511437041365600175350ustar00rootroot00000000000000from __future__ import annotations from typing import ( TYPE_CHECKING, DefaultDict, Dict, Iterator, List, Optional, Sequence, Set, Tuple, Union, cast, ) from app_model.types import SubmenuItem from npe2 import io_utils from npe2 import plugin_manager as pm from npe2.manifest import contributions from napari.utils.translations import trans if TYPE_CHECKING: from app_model import Action from npe2.manifest import PluginManifest from npe2.manifest.contributions import WriterContribution from npe2.plugin_manager import PluginName from npe2.types import LayerData, SampleDataCreator, WidgetCreator from qtpy.QtWidgets import QMenu from napari.layers import Layer from napari.types import SampleDict class _FakeHookimpl: def __init__(self, name) -> None: self.plugin_name = name def read( paths: Sequence[str], plugin: Optional[str] = None, *, stack: bool ) -> Optional[Tuple[List[LayerData], _FakeHookimpl]]: """Try to return data for `path`, from reader plugins using a manifest.""" assert stack is not None # the goal here would be to make read_get_reader of npe2 aware of "stack", # and not have this conditional here. # this would also allow the npe2-npe1 shim to do this transform as well if stack: npe1_path = paths else: assert len(paths) == 1 npe1_path = paths[0] try: layer_data, reader = io_utils.read_get_reader( npe1_path, plugin_name=plugin ) return layer_data, _FakeHookimpl(reader.plugin_name) except ValueError as e: if 'No readers returned data' not in str(e): raise e from e return None def write_layers( path: str, layers: List[Layer], plugin_name: Optional[str] = None, writer: Optional[WriterContribution] = None, ) -> Tuple[List[str], str]: """ Write layers to a file using an NPE2 plugin. Parameters ---------- path : str The path (file, directory, url) to write. layers : list of Layers The layers to write. plugin_name : str, optional Name of the plugin to write data with. If None then all plugins corresponding to appropriate hook specification will be looped through to find the first one that can write the data. writer : WriterContribution, optional Writer contribution to use to write given layers, autodetect if None. Returns ------- (written paths, writer name) as Tuple[List[str],str] written paths: List[str] Empty list when no plugin was found, otherwise a list of file paths, if any, that were written. writer name: str Name of the plugin selected to write the data. """ layer_data = [layer.as_layer_data_tuple() for layer in layers] if writer is None: try: paths, writer = io_utils.write_get_writer( path=path, layer_data=layer_data, plugin_name=plugin_name ) return (paths, writer.plugin_name) except ValueError: return ([], '') n = sum(ltc.max() for ltc in writer.layer_type_constraints()) args = (path, *layer_data[0][:2]) if n <= 1 else (path, layer_data) res = writer.exec(args=args) if isinstance( res, str ): # pragma: no cover # it shouldn't be... bad plugin. return ([res], writer.plugin_name) return (res or [], writer.plugin_name) def get_widget_contribution( plugin_name: str, widget_name: Optional[str] = None ) -> Optional[Tuple[WidgetCreator, str]]: widgets_seen = set() for contrib in pm.iter_widgets(): if contrib.plugin_name == plugin_name: if not widget_name or contrib.display_name == widget_name: return contrib.get_callable(), contrib.display_name widgets_seen.add(contrib.display_name) if widget_name and widgets_seen: msg = trans._( 'Plugin {plugin_name!r} does not provide a widget named {widget_name!r}. It does provide: {seen}', plugin_name=plugin_name, widget_name=widget_name, seen=widgets_seen, deferred=True, ) raise KeyError(msg) return None def populate_qmenu(menu: QMenu, menu_key: str): """Populate `menu` from a `menu_key` offering in the manifest.""" # TODO: declare somewhere what menu_keys are valid. def _wrap(cmd_): def _wrapped(*args): cmd_.exec(args=args) return _wrapped for item in pm.iter_menu(menu_key): if isinstance(item, contributions.Submenu): subm_contrib = pm.get_submenu(item.submenu) subm = menu.addMenu(subm_contrib.label) populate_qmenu(subm, subm_contrib.id) else: cmd = pm.get_command(item.command) action = menu.addAction(cmd.title) action.triggered.connect(_wrap(cmd)) def file_extensions_string_for_layers( layers: Sequence[Layer], ) -> Tuple[Optional[str], List[WriterContribution]]: """Create extensions string using npe2. When npe2 can be imported, returns an extension string and the list of corresponding writers. Otherwise returns (None,[]). The extension string is a ";;" delimeted string of entries. Each entry has a brief description of the file type and a list of extensions. For example: "Images (*.png *.jpg *.tif);;All Files (*.*)" The writers, when provided, are the `npe2.manifest.io.WriterContribution` objects. There is one writer per entry in the extension string. """ layer_types = [layer._type_string for layer in layers] writers = list(pm.iter_compatible_writers(layer_types)) def _items(): """Lookup the command name and its supported extensions.""" for writer in writers: name = pm.get_manifest(writer.command).display_name title = ( f"{name} {writer.display_name}" if writer.display_name else name ) yield title, writer.filename_extensions # extension strings are in the format: # " (* * *);;+" def _fmt_exts(es): return " ".join(f"*{e}" for e in es if e) if es else "*.*" return ( ";;".join(f"{name} ({_fmt_exts(exts)})" for name, exts in _items()), writers, ) def get_readers(path: Optional[str] = None) -> Dict[str, str]: """Get valid reader plugin_name:display_name mapping given path. Iterate through compatible readers for the given path and return dictionary of plugin_name to display_name for each reader. If path is not given, return all readers. Parameters ---------- path : str path for which to find compatible readers Returns ------- Dict[str, str] Dictionary of plugin_name to display name """ if path: return { reader.plugin_name: pm.get_manifest(reader.command).display_name for reader in pm.iter_compatible_readers([path]) } return { mf.name: mf.display_name for mf in pm.iter_manifests() if mf.contributions.readers } def iter_manifests( disabled: Optional[bool] = None, ) -> Iterator[PluginManifest]: yield from pm.iter_manifests(disabled=disabled) def widget_iterator() -> Iterator[Tuple[str, Tuple[str, Sequence[str]]]]: # eg ('dock', ('my_plugin', ('My widget', MyWidget))) wdgs: DefaultDict[str, List[str]] = DefaultDict(list) for wdg_contrib in pm.iter_widgets(): wdgs[wdg_contrib.plugin_name].append(wdg_contrib.display_name) return (('dock', x) for x in wdgs.items()) def sample_iterator() -> Iterator[Tuple[str, Dict[str, SampleDict]]]: return ( ( # use display_name for user facing display plugin_name, { c.key: {'data': c.open, 'display_name': c.display_name} for c in contribs }, ) for plugin_name, contribs in pm.iter_sample_data() ) def get_sample_data( plugin: str, sample: str ) -> Tuple[Optional[SampleDataCreator], List[Tuple[str, str]]]: """Get sample data opener from npe2. Parameters ---------- plugin : str name of a plugin providing a sample sample : str name of the sample Returns ------- tuple - first item is a data "opener": a callable that returns an iterable of layer data, or None, if none found. - second item is a list of available samples (plugin_name, sample_name) if no data opener is found. """ avail: List[Tuple[str, str]] = [] for plugin_name, contribs in pm.iter_sample_data(): for contrib in contribs: if plugin_name == plugin and contrib.key == sample: return contrib.open, [] avail.append((plugin_name, contrib.key)) return None, avail def index_npe1_adapters(): """Tell npe2 to import and index any discovered npe1 plugins.""" pm.index_npe1_adapters() def on_plugin_enablement_change(enabled: Set[str], disabled: Set[str]): """Callback when any npe2 plugins are enabled or disabled. 'Disabled' means the plugin remains installed, but it cannot be activated, and its contributions will not be indexed """ from napari import Viewer from napari.settings import get_settings plugin_settings = get_settings().plugins to_disable = set(plugin_settings.disabled_plugins) to_disable.difference_update(enabled) to_disable.update(disabled) plugin_settings.disabled_plugins = to_disable for plugin_name in enabled: # technically, you can enable (i.e. "undisable") a plugin that isn't # currently registered/available. So we check to make sure this is # actually a registered plugin. if plugin_name in pm.instance(): _register_manifest_actions(pm.get_manifest(plugin_name)) # TODO: after app-model, these QMenus will be evented and self-updating # and we can remove this... but `_register_manifest_actions` will need to # add the actions file and plugins menus (since we don't require plugins to # list them explicitly) for v in Viewer._instances: v.window.plugins_menu._build() v.window.file_menu._rebuild_samples_menu() def on_plugins_registered(manifests: Set[PluginManifest]): """Callback when any npe2 plugins are registered. 'Registered' means that a manifest has been provided or discovered. """ for mf in manifests: if not pm.is_disabled(mf.name): _register_manifest_actions(mf) def _register_manifest_actions(manifest: PluginManifest) -> None: """Gather and register actions from a manifest. This is called when a plugin is registered or enabled and it adds the plugin's menus and submenus to the app model registry. """ from napari._app_model import get_app app = get_app() actions, submenus = _npe2_manifest_to_actions(manifest) context = pm.get_context(cast('PluginName', manifest.name)) if actions: context.register_disposable(app.register_actions(actions)) if submenus: context.register_disposable(app.menus.append_menu_items(submenus)) def _npe2_manifest_to_actions( mf: PluginManifest, ) -> Tuple[List[Action], List[Tuple[str, SubmenuItem]]]: """Gather actions and submenus from a npe2 manifest, export app_model types.""" from app_model.types import Action, MenuRule from napari._app_model.constants._menus import is_menu_contributable cmds: DefaultDict[str, List[MenuRule]] = DefaultDict(list) submenus: List[Tuple[str, SubmenuItem]] = [] for menu_id, items in mf.contributions.menus.items(): if is_menu_contributable(menu_id): for item in items: if isinstance(item, contributions.MenuCommand): rule = MenuRule(id=menu_id, **_when_group_order(item)) cmds[item.command].append(rule) else: subitem = _npe2_submenu_to_app_model(item) submenus.append((menu_id, subitem)) actions: List[Action] = [ Action( id=cmd.id, title=cmd.title, category=cmd.category, tooltip=cmd.short_title or cmd.title, icon=cmd.icon, enablement=cmd.enablement, callback=cmd.python_name or '', menus=cmds.get(cmd.id), keybindings=[], ) for cmd in mf.contributions.commands or () ] return actions, submenus def _when_group_order( menu_item: contributions.MenuItem, ) -> dict[str, Union[str, float, None]]: """Extract when/group/order from an npe2 Submenu or MenuCommand.""" group, _, _order = (menu_item.group or '').partition("@") try: order: Optional[float] = float(_order) except ValueError: order = None return {'when': menu_item.when, 'group': group or None, 'order': order} def _npe2_submenu_to_app_model(subm: contributions.Submenu) -> SubmenuItem: """Convert a npe2 submenu contribution to an app_model SubmenuItem.""" contrib = pm.get_submenu(subm.submenu) return SubmenuItem( submenu=contrib.id, title=contrib.label, icon=contrib.icon, **_when_group_order(subm), # enablement= ?? npe2 doesn't have this, but app_model does ) napari-0.5.0a1/napari/plugins/_plugin_manager.py000066400000000000000000000667701437041365600216750ustar00rootroot00000000000000import contextlib import sys import warnings from functools import partial from pathlib import Path from types import FunctionType from typing import ( Any, Callable, Dict, Iterable, Iterator, List, Optional, Set, Tuple, Union, ) from warnings import warn from napari_plugin_engine import HookImplementation from napari_plugin_engine import PluginManager as PluginManager from napari_plugin_engine.hooks import HookCaller from pydantic import ValidationError from typing_extensions import TypedDict from napari.plugins import hook_specifications from napari.settings import get_settings from napari.types import AugmentedWidget, LayerData, SampleDict, WidgetCallable from napari.utils._appdirs import user_site_packages from napari.utils.events import EmitterGroup, EventedSet from napari.utils.misc import camel_to_spaces, running_as_bundled_app from napari.utils.theme import Theme, register_theme, unregister_theme from napari.utils.translations import trans class PluginHookOption(TypedDict): """Custom type specifying plugin and enabled state.""" plugin: str enabled: bool CallOrderDict = Dict[str, List[PluginHookOption]] class NapariPluginManager(PluginManager): """PluginManager subclass for napari-specific functionality. Notes ----- The events emitted by the plugin include: * registered (value: str) Emitted after plugin named `value` has been registered. * unregistered (value: str) Emitted after plugin named `value` has been unregistered. * enabled (value: str) Emitted after plugin named `value` has been removed from the block list. * disabled (value: str) Emitted after plugin named `value` has been added to the block list. """ ENTRY_POINT = 'napari.plugin' def __init__(self) -> None: super().__init__('napari', discover_entry_point=self.ENTRY_POINT) self.events = EmitterGroup( source=self, registered=None, unregistered=None, enabled=None, disabled=None, ) self._blocked: EventedSet[str] = EventedSet() self._blocked.events.changed.connect(self._on_blocked_change) # set of package names to skip when discovering, used for skipping # npe2 stuff self._skip_packages: Set[str] = set() with self.discovery_blocked(): self.add_hookspecs(hook_specifications) # dicts to store maps from extension -> plugin_name self._extension2reader: Dict[str, str] = {} self._extension2writer: Dict[str, str] = {} self._sample_data: Dict[str, Dict[str, SampleDict]] = {} self._dock_widgets: Dict[ str, Dict[str, Tuple[WidgetCallable, Dict[str, Any]]] ] = {} self._function_widgets: Dict[str, Dict[str, Callable[..., Any]]] = {} self._theme_data: Dict[str, Dict[str, Theme]] = {} if sys.platform.startswith('linux') and running_as_bundled_app(): sys.path.append(user_site_packages()) def _initialize(self): with self.discovery_blocked(): from napari.settings import get_settings # dicts to store maps from extension -> plugin_name plugin_settings = get_settings().plugins self._extension2reader.update(plugin_settings.extension2reader) self._extension2writer.update(plugin_settings.extension2writer) def register( self, namespace: Any, name: Optional[str] = None ) -> Optional[str]: name = super().register(namespace, name=name) if name: self.events.registered(value=name) return name def iter_available( self, path: Optional[str] = None, entry_point: Optional[str] = None, prefix: Optional[str] = None, ) -> Iterator[Tuple[str, str, Optional[str]]]: # overriding to skip npe2 plugins for item in super().iter_available(path, entry_point, prefix): if item[-1] not in self._skip_packages: yield item def unregister( self, name_or_object: Any, ) -> Optional[Any]: if isinstance(name_or_object, str): _name = name_or_object else: _name = self.get_name(name_or_object) plugin = super().unregister(name_or_object) # unregister any theme that was associated with the # unregistered plugin self.unregister_theme_colors(_name) # remove widgets, sample data, theme data for _dict in ( self._dock_widgets, self._sample_data, self._theme_data, self._function_widgets, ): _dict.pop(_name, None) # type: ignore self.events.unregistered(value=_name) return plugin def _on_blocked_change(self, event): # things that are "added to the blocked list" become disabled for item in event.added: self.events.disabled(value=item) # things that are "removed from the blocked list" become enabled for item in event.removed: self.events.enabled(value=item) if event.removed: # if an event was removed from the "disabled" list... # let's reregister. # TODO: might be able to be more direct here. self.discover() get_settings().plugins.disabled_plugins = set(self._blocked) def call_order(self, first_result_only=True) -> CallOrderDict: """Returns the call order from the plugin manager. Returns ------- call_order : CallOrderDict mapping of hook_specification name, to a list of dicts with keys: {'plugin', 'hook_impl', 'enabled'}. Plugins earlier in the dict are called sooner. """ order: CallOrderDict = {} for spec_name, caller in self.hooks.items(): # no need to save call order unless we only use first result if first_result_only and not caller.is_firstresult: continue impls = caller.get_hookimpls() # no need to save call order if there is only a single item if len(impls) > 1: order[spec_name] = [ { 'plugin': f'{impl.plugin_name}--{impl.function.__name__}', 'enabled': impl.enabled, } for impl in reversed(impls) ] return order def set_call_order(self, new_order: CallOrderDict): """Sets the plugin manager call order to match settings plugin values. Note: Run this after load_settings_plugin_defaults, which sets the default values in settings. Parameters ---------- new_order : CallOrderDict mapping of hook_specification name, to a list of dicts with keys: {'plugin', 'enabled'}. Plugins earlier in the dict are called sooner. """ for spec_name, hook_caller in self.hooks.items(): if spec_name in new_order: order = [] for p in new_order.get(spec_name, []): try: plugin = p['plugin'] hook_impl_name = None if '--' in plugin: plugin, hook_impl_name = tuple(plugin.split('--')) enabled = p['enabled'] # the plugin may not be there if its been disabled. hook_caller._set_plugin_enabled(plugin, enabled) hook_impls = hook_caller.get_hookimpls() # get the HookImplementation objects matching this entry hook_impl = list( filter( # plugin name has to match lambda impl: impl.plugin_name == plugin and ( # if we have a hook_impl_name it must match not hook_impl_name or impl.function.__name__ == hook_impl_name ), hook_impls, ) ) order.extend(hook_impl) except KeyError: continue if order: hook_caller.bring_to_front(order) # SAMPLE DATA --------------------------- def register_sample_data( self, data: Dict[str, Union[str, Callable[..., Iterable[LayerData]]]], hookimpl: HookImplementation, ): """Register sample data dict returned by `napari_provide_sample_data`. Each key in `data` is a `sample_name` (the string that will appear in the `Open Sample` menu), and the value is either a string, or a callable that returns an iterable of ``LayerData`` tuples, where each tuple is a 1-, 2-, or 3-tuple of ``(data,)``, ``(data, meta)``, or ``(data, meta, layer_type)``. Parameters ---------- data : Dict[str, Union[str, Callable[..., Iterable[LayerData]]]] A mapping of {sample_name->data} hookimpl : HookImplementation The hook implementation that returned the dict """ plugin_name = hookimpl.plugin_name hook_name = 'napari_provide_sample_data' if not isinstance(data, dict): warn_message = trans._( 'Plugin {plugin_name!r} provided a non-dict object to {hook_name!r}: data ignored.', deferred=True, plugin_name=plugin_name, hook_name=hook_name, ) warn(message=warn_message) return _data: Dict[str, SampleDict] = {} for name, _datum in list(data.items()): if isinstance(_datum, dict): datum: SampleDict = _datum if 'data' not in _datum or 'display_name' not in _datum: warn_message = trans._( 'In {hook_name!r}, plugin {plugin_name!r} provided an invalid dict object for key {name!r} that does not have required keys: "data" and "display_name". Ignoring', deferred=True, hook_name=hook_name, plugin_name=plugin_name, name=name, ) warn(message=warn_message) continue else: datum = {'data': _datum, 'display_name': name} if not ( callable(datum['data']) or isinstance(datum['data'], (str, Path)) ): warn_message = trans._( 'Plugin {plugin_name!r} provided invalid data for key {name!r} in the dict returned by {hook_name!r}. (Must be str, callable, or dict), got ({dtype}).', deferred=True, plugin_name=plugin_name, name=name, hook_name=hook_name, dtype=type(datum["data"]), ) warn(message=warn_message) continue _data[name] = datum if plugin_name not in self._sample_data: self._sample_data[plugin_name] = {} self._sample_data[plugin_name].update(_data) def available_samples(self) -> Tuple[Tuple[str, str], ...]: """Return a tuple of sample data keys provided by plugins. Returns ------- sample_keys : Tuple[Tuple[str, str], ...] A sequence of 2-tuples ``(plugin_name, sample_name)`` showing available sample data provided by plugins. To load sample data into the viewer, use :meth:`napari.Viewer.open_sample`. Examples -------- .. code-block:: python from napari.plugins import available_samples sample_keys = available_samples() if sample_keys: # load first available sample viewer.open_sample(*sample_keys[0]) """ return tuple( (p, s) for p in self._sample_data for s in self._sample_data[p] ) # THEME DATA ------------------------------------ def register_theme_colors( self, data: Dict[str, Dict[str, Union[str, Tuple, List]]], hookimpl: HookImplementation, ): """Register theme data dict returned by `napari_experimental_provide_theme`. The `theme` data should be provided as an iterable containing dictionary of values, where the ``folder`` value will be used as theme name. """ plugin_name = hookimpl.plugin_name hook_name = '`napari_experimental_provide_theme`' if not isinstance(data, Dict): warn_message = trans._( 'Plugin {plugin_name!r} provided a non-dict object to {hook_name!r}: data ignored', deferred=True, plugin_name=plugin_name, hook_name=hook_name, ) warn(message=warn_message) return _data = {} for theme_id, theme_colors in data.items(): try: theme = Theme.parse_obj(theme_colors) register_theme(theme_id, theme, plugin_name) _data[theme_id] = theme except (KeyError, ValidationError) as err: warn_msg = trans._( "In {hook_name!r}, plugin {plugin_name!r} provided an invalid dict object for creating themes. {err!r}", deferred=True, hook_name=hook_name, plugin_name=plugin_name, err=err, ) warn(message=warn_msg) continue if plugin_name not in self._theme_data: self._theme_data[plugin_name] = {} self._theme_data[plugin_name].update(_data) def unregister_theme_colors(self, plugin_name: str): """Unregister theme data from napari.""" if plugin_name not in self._theme_data: return # unregister all themes that were provided by the plugins for theme_id in self._theme_data[plugin_name]: unregister_theme(theme_id) # since its possible that disabled/removed plugin was providing the # current theme, we check for this explicitly and if this the case, # theme is automatically changed to default `dark` theme settings = get_settings() current_theme = settings.appearance.theme if current_theme in self._theme_data[plugin_name]: settings.appearance.theme = "dark" # type: ignore warnings.warn( message=trans._( "The current theme {current_theme!r} was provided by the plugin {plugin_name!r} which was disabled or removed. Switched theme to the default.", deferred=True, plugin_name=plugin_name, current_theme=current_theme, ) ) def discover_themes(self): """Trigger discovery of theme plugins. As a "historic" hook, this should only need to be called once. (historic here means that even plugins that are discovered after this is called will be added.) """ if self._theme_data: return self.hook.napari_experimental_provide_theme.call_historic( result_callback=partial(self.register_theme_colors), with_impl=True ) # FUNCTION & DOCK WIDGETS ----------------------- def iter_widgets(self) -> Iterator[Tuple[str, Tuple[str, Dict[str, Any]]]]: from itertools import chain, repeat dock_widgets = zip(repeat("dock"), self._dock_widgets.items()) func_widgets = zip(repeat("func"), self._function_widgets.items()) yield from chain(dock_widgets, func_widgets) def register_dock_widget( self, args: Union[AugmentedWidget, List[AugmentedWidget]], hookimpl: HookImplementation, ): plugin_name = hookimpl.plugin_name hook_name = '`napari_experimental_provide_dock_widget`' for arg in args if isinstance(args, list) else [args]: if isinstance(arg, tuple): if not arg: warn_message = trans._( 'Plugin {plugin_name!r} provided an invalid tuple to {hook_name}. Skipping', deferred=True, plugin_name=plugin_name, hook_name=hook_name, ) warn(message=warn_message) continue _cls = arg[0] kwargs = arg[1] if len(arg) > 1 else {} else: _cls, kwargs = (arg, {}) if not callable(_cls): warn_message = trans._( 'Plugin {plugin_name!r} provided a non-callable object (widget) to {hook_name}: {_cls!r}. Widget ignored.', deferred=True, plugin_name=plugin_name, hook_name=hook_name, _cls=_cls, ) warn(message=warn_message) continue if not isinstance(kwargs, dict): warn_message = trans._( 'Plugin {plugin_name!r} provided invalid kwargs to {hook_name} for class {clsname}. Widget ignored.', deferred=True, plugin_name=plugin_name, hook_name=hook_name, clsname=_cls.__name__, ) warn(message=warn_message) continue # Get widget name name = str(kwargs.get('name', '')) or camel_to_spaces( _cls.__name__ ) if plugin_name not in self._dock_widgets: # tried defaultdict(dict) but got odd KeyErrors... self._dock_widgets[plugin_name] = {} elif name in self._dock_widgets[plugin_name]: warn_message = trans._( 'Plugin {plugin_name!r} has already registered a dock widget {name!r} which has now been overwritten', deferred=True, plugin_name=plugin_name, name=name, ) warn(message=warn_message) self._dock_widgets[plugin_name][name] = (_cls, kwargs) def register_function_widget( self, args: Union[Callable, List[Callable]], hookimpl: HookImplementation, ): plugin_name = hookimpl.plugin_name hook_name = '`napari_experimental_provide_function`' for func in args if isinstance(args, list) else [args]: if not isinstance(func, FunctionType): warn_message = trans._( 'Plugin {plugin_name!r} provided a non-callable type to {hook_name}: {functype!r}. Function widget ignored.', deferred=True, functype=type(func), plugin_name=plugin_name, hook_name=hook_name, ) if isinstance(func, tuple): warn_message += trans._( " To provide multiple function widgets please use a LIST of callables", deferred=True, ) warn(message=warn_message) continue # Get function name name = func.__name__.replace('_', ' ') if plugin_name not in self._function_widgets: # tried defaultdict(dict) but got odd KeyErrors... self._function_widgets[plugin_name] = {} elif name in self._function_widgets[plugin_name]: warn_message = trans._( 'Plugin {plugin_name!r} has already registered a function widget {name!r} which has now been overwritten', deferred=True, plugin_name=plugin_name, name=name, ) warn(message=warn_message) self._function_widgets[plugin_name][name] = func def discover_sample_data(self): if self._sample_data: return self.hook.napari_provide_sample_data.call_historic( result_callback=partial(self.register_sample_data), with_impl=True ) def discover_widgets(self): """Trigger discovery of dock_widgets plugins. As a "historic" hook, this should only need to be called once. (historic here means that even plugins that are discovered after this is called will be added.) """ if self._dock_widgets: return self.hook.napari_experimental_provide_dock_widget.call_historic( partial(self.register_dock_widget), with_impl=True ) self.hook.napari_experimental_provide_function.call_historic( partial(self.register_function_widget), with_impl=True ) def get_widget( self, plugin_name: str, widget_name: Optional[str] = None ) -> Tuple[WidgetCallable, Dict[str, Any]]: """Get widget `widget_name` provided by plugin `plugin_name`. Note: it's important that :func:`discover_dock_widgets` has been called first, otherwise plugins may not be found yet. (Typically, that is done in qt_main_window) Parameters ---------- plugin_name : str Name of a plugin providing a widget widget_name : str, optional Name of a widget provided by `plugin_name`. If `None`, and the specified plugin provides only a single widget, that widget will be returned, otherwise a ValueError will be raised, by default None Returns ------- plugin_widget : Tuple[Callable, dict] Tuple of (widget_class, options). Raises ------ KeyError If plugin `plugin_name` does not provide any widgets KeyError If plugin does not provide a widget named `widget_name`. ValueError If `widget_name` is not provided, but `plugin_name` provides more than one widget """ plg_wdgs = self._dock_widgets.get(plugin_name) if not plg_wdgs: msg = trans._( 'Plugin {plugin_name!r} does not provide any dock widgets', plugin_name=plugin_name, deferred=True, ) raise KeyError(msg) if not widget_name: if len(plg_wdgs) > 1: msg = trans._( 'Plugin {plugin_name!r} provides more than 1 dock_widget. Must also provide "widget_name" from {avail}', avail=set(plg_wdgs), plugin_name=plugin_name, deferred=True, ) raise ValueError(msg) widget_name = list(plg_wdgs)[0] else: if widget_name not in plg_wdgs: msg = trans._( 'Plugin {plugin_name!r} does not provide a widget named {widget_name!r}', plugin_name=plugin_name, widget_name=widget_name, deferred=True, ) raise KeyError(msg) return plg_wdgs[widget_name] def get_reader_for_extension(self, extension: str) -> Optional[str]: """Return reader plugin assigned to `extension`, or None.""" return self._get_plugin_for_extension(extension, type_='reader') def assign_reader_to_extensions( self, reader: str, extensions: Union[str, Iterable[str]] ) -> None: """Assign a specific reader plugin to `extensions`. Parameters ---------- reader : str Name of a plugin offering a reader hook. extensions : Union[str, Iterable[str]] Name(s) of extensions to always write with `reader` """ from napari.settings import get_settings self._assign_plugin_to_extensions(reader, extensions, type_='reader') extension2readers = get_settings().plugins.extension2reader get_settings().plugins.extension2reader = { **extension2readers, **self._extension2reader, } def get_writer_for_extension(self, extension: str) -> Optional[str]: """Return writer plugin assigned to `extension`, or None.""" return self._get_plugin_for_extension(extension, type_='writer') def assign_writer_to_extensions( self, writer: str, extensions: Union[str, Iterable[str]] ) -> None: """Assign a specific writer plugin to `extensions`. Parameters ---------- writer : str Name of a plugin offering a writer hook. extensions : Union[str, Iterable[str]] Name(s) of extensions to always write with `writer` """ from napari.settings import get_settings self._assign_plugin_to_extensions(writer, extensions, type_='writer') get_settings().plugins.extension2writer = self._extension2writer def _get_plugin_for_extension( self, extension: str, type_: str ) -> Optional[str]: """helper method for public get__for_extension functions.""" ext_map = getattr(self, f'_extension2{type_}', None) if ext_map is None: raise ValueError( trans._( "invalid plugin type: {type_!r}", deferred=True, type_=type_, ) ) if not extension.startswith("."): extension = f".{extension}" plugin = ext_map.get(extension) # make sure it's still an active plugin if plugin and (plugin not in self.plugins): del self.ext_map[plugin] return None return plugin def _assign_plugin_to_extensions( self, plugin: str, extensions: Union[str, Iterable[str]], type_: Optional[str] = None, ) -> None: """helper method for public assign__to_extensions functions.""" caller: HookCaller = getattr(self.hook, f'napari_get_{type_}', None) if caller is None: raise ValueError( trans._( "invalid plugin type: {type_!r}", deferred=True, type_=type_, ) ) plugins = caller.get_hookimpls() if plugin not in {p.plugin_name for p in plugins}: msg = trans._( "{plugin!r} is not a valid {type_} plugin name", plugin=plugin, type_=type_, deferred=True, ) raise ValueError(msg) ext_map = getattr(self, f'_extension2{type_}') if isinstance(extensions, str): extensions = [extensions] for ext in extensions: if not ext.startswith("."): ext = f".{ext}" ext_map[ext] = plugin func = None # give warning that plugin *may* not be able to read that extension with contextlib.suppress(Exception): func = caller._call_plugin(plugin, path=f'_testing_{ext}') if func is None: msg = trans._( 'plugin {plugin!r} did not return a {type_} function when provided a path ending in {ext!r}. This *may* indicate a typo?', deferred=True, plugin=plugin, type_=type_, ext=ext, ) warn(msg) napari-0.5.0a1/napari/plugins/_tests/000077500000000000000000000000001437041365600174555ustar00rootroot00000000000000napari-0.5.0a1/napari/plugins/_tests/__init__.py000066400000000000000000000000001437041365600215540ustar00rootroot00000000000000napari-0.5.0a1/napari/plugins/_tests/_sample_manifest.yaml000066400000000000000000000024501437041365600236500ustar00rootroot00000000000000name: my-plugin display_name: My Plugin contributions: commands: - id: my-plugin.hello_world title: Hello World - id: my-plugin.some_reader title: Some Reader - id: my-plugin.my_writer title: Image Writer - id: my-plugin.generate_random_data title: Generate uniform random data - id: my-plugin.some_widget title: Create my widget readers: - command: my-plugin.some_reader filename_patterns: ["*.fzy", "*.fzzy"] accepts_directories: true writers: - command: my-plugin.my_writer filename_extensions: ["*.tif", "*.tiff"] layer_types: ["image"] widgets: - command: my-plugin.some_widget display_name: My Widget menus: napari/layers/context: - submenu: mysubmenu - command: my-plugin.hello_world my-plugin/submenu: - command: my-plugin.hello_world submenus: - id: mysubmenu label: My SubMenu themes: - label: "SampleTheme" id: "sample_theme" type: "dark" colors: background: "#272822" foreground: "#75715e" sample_data: - display_name: Some Random Data (512 x 512) key: random_data command: my-plugin.generate_random_data - display_name: Random internet image key: internet_image uri: https://picsum.photos/1024napari-0.5.0a1/napari/plugins/_tests/test_exceptions.py000066400000000000000000000024331437041365600232510ustar00rootroot00000000000000import sys import pytest from napari_plugin_engine import PluginError from napari.plugins import exceptions # monkeypatch fixture is from pytest @pytest.mark.parametrize('as_html', (True, False), ids=['as_html', 'as_text']) @pytest.mark.parametrize('cgitb', (True, False), ids=['cgitb', 'ipython']) def test_format_exceptions(cgitb, as_html, monkeypatch): if cgitb: monkeypatch.setitem(sys.modules, 'IPython.core.ultratb', None) monkeypatch.setattr( exceptions, 'standard_metadata', lambda x: {'package': 'test-package', 'version': '0.1.0'}, ) # we make sure to actually raise the exceptions, # otherwise they will miss the __traceback__ attributes. try: try: raise ValueError('cause') except ValueError as e: raise PluginError( 'some error', plugin_name='test_plugin', plugin="mock", cause=e, ) from e except PluginError: pass formatted = exceptions.format_exceptions('test_plugin', as_html=as_html) assert "some error" in formatted assert "version: 0.1.0" in formatted assert "plugin package: test-package" in formatted assert exceptions.format_exceptions('nonexistent', as_html=as_html) == '' napari-0.5.0a1/napari/plugins/_tests/test_hook_specifications.py000066400000000000000000000061641437041365600251200ustar00rootroot00000000000000import inspect import pytest from numpydoc.docscrape import FunctionDoc from napari.plugins import hook_specifications # 1. we first create a hook specification decorator: # ``napari_hook_specification = napari_plugin_engine.HookSpecificationMarker("napari")`` # 2. when it decorates a function, that function object gets a new attribute # called "napari_spec" # 3. that attribute is what makes specifications discoverable when you run # ``plugin_manager.add_hookspecs(module)`` # (The ``add_hookspecs`` method basically just looks through the module for # any functions that have a "napari_spec" attribute. # # here, we are using that attribute to discover all of our internal hook # specifications (in module ``napari.plugins.hook_specifications``) so as to # make sure that they conform to our own internal rules about documentation and # type annotations, etc... HOOK_SPECIFICATIONS = [ (name, func) for name, func in vars(hook_specifications).items() if hasattr(func, 'napari_spec') ] @pytest.mark.parametrize("name, func", HOOK_SPECIFICATIONS) def test_hook_specification_naming(name, func): """All hook specifications should begin with napari_.""" assert name.startswith('napari_'), ( "hook specification '%s' does not start with 'napari_'" % name ) @pytest.mark.parametrize("name, func", HOOK_SPECIFICATIONS) def test_docstring_on_hook_specification(name, func): """All hook specifications should have documentation.""" assert func.__doc__, "no docstring for '%s'" % name @pytest.mark.parametrize("name, func", HOOK_SPECIFICATIONS) def test_annotation_on_hook_specification(name, func): """All hook specifications should have type annotations for all parameters. (Use typing.Any to bail out). If the hook specification accepts no parameters, then it should declare a return type annotation. (until we identify a case where a hook specification needs to both take no parameters and return nothing) """ sig = inspect.signature(func) if sig.parameters: for param in sig.parameters.values(): for forbidden in ('_plugin', '_skip_impls', '_return_result_obj'): assert ( param.name != forbidden ), f'Must not name hook_specification argument "{forbidden}".' assert param.annotation is not param.empty, ( f"in hook specification '{name}', parameter '{param}' " "has no type annotation" ) else: assert sig.return_annotation is not sig.empty, ( f"hook specifications with no parameters ({name})," " must declare a return type annotation" ) @pytest.mark.parametrize("name, func", HOOK_SPECIFICATIONS) def test_docs_match_signature(name, func): sig = inspect.signature(func) docs = FunctionDoc(func) sig_params = set(sig.parameters) doc_params = {p.name for p in docs.get('Parameters')} assert sig_params == doc_params, ( f"Signature parameters for hook specification '{name}' do " "not match the parameters listed in the docstring:\n" f"{sig_params} != {doc_params}" ) napari-0.5.0a1/napari/plugins/_tests/test_hub.py000066400000000000000000000073071437041365600216530ustar00rootroot00000000000000from unittest import mock from urllib import error from napari.plugins import hub # Mock data # ---------------------------------------------------------------------------- HUB_REPLY = b'''{"authors": [{"email": "sofroniewn@gmail.com", "name": "Nicholas Sofroniew"}], "development_status": ["Development Status :: 4 - Beta"], "license": "BSD-3-Clause", "name": "napari-svg", "project_site": "https://github.com/napari/napari-svg", "summary": "A plugin", "version": "0.1.6", "visibility": "public"}''' ANACONDA_REPLY_DIFFERENT_PYPI = b'{"versions": ["0.1.5"]}' ANACONDA_REPLY_SAME_PYPI = b'{"versions": ["0.1.5", "0.1.6"]}' ANACONDA_REPLY_EMPTY = b'{"versions": []}' # Mocks # ---------------------------------------------------------------------------- class FakeResponse: def __init__(self, *, data: bytes, _error=None) -> None: self.data = data self._error = _error def read(self): if self._error: raise self._error return self.data def close(self): pass def __enter__(self): return self def __exit__(self, *exc): return def mocked_urlopen_valid_different(*args, **kwargs): if "https://api.anaconda.org" in args[0]: return FakeResponse(data=ANACONDA_REPLY_DIFFERENT_PYPI) return FakeResponse(data=HUB_REPLY) def mocked_urlopen_valid_same(*args, **kwargs): if "https://api.anaconda.org" in args[0]: return FakeResponse(data=ANACONDA_REPLY_SAME_PYPI) return FakeResponse(data=HUB_REPLY) def mocked_urlopen_valid_empty(*args, **kwargs): if "https://api.anaconda.org" in args[0]: return FakeResponse(data=ANACONDA_REPLY_EMPTY) return FakeResponse(data=HUB_REPLY) def mocked_urlopen_valid_not_in_forge(*args, **kwargs): if "https://api.anaconda.org" in args[0]: return FakeResponse( data=ANACONDA_REPLY_EMPTY, _error=error.HTTPError('', 1, '', '', None), ) return FakeResponse(data=HUB_REPLY) # Tests # ---------------------------------------------------------------------------- @mock.patch('urllib.request.urlopen', new=mocked_urlopen_valid_different) def test_hub_plugin_info_different_pypi(): hub.hub_plugin_info.cache_clear() info, is_available_in_conda_forge = hub.hub_plugin_info( 'napari-SVG', conda_forge=True ) assert is_available_in_conda_forge assert info.name == 'napari-svg' assert info.version == '0.1.5' @mock.patch('urllib.request.urlopen', new=mocked_urlopen_valid_same) def test_hub_plugin_info_same_as_pypi(): hub.hub_plugin_info.cache_clear() info, is_available_in_conda_forge = hub.hub_plugin_info( 'napari-SVG', conda_forge=True ) assert is_available_in_conda_forge assert info.version == '0.1.6' @mock.patch('urllib.request.urlopen', new=mocked_urlopen_valid_empty) def test_hub_plugin_info_empty(): hub.hub_plugin_info.cache_clear() info, is_available_in_conda_forge = hub.hub_plugin_info( 'napari-SVG', conda_forge=True ) assert not is_available_in_conda_forge assert info.version == '0.1.6' @mock.patch('urllib.request.urlopen', new=mocked_urlopen_valid_empty) def test_hub_plugin_info_forge_false(): hub.hub_plugin_info.cache_clear() info, is_available_in_conda_forge = hub.hub_plugin_info( 'napari-SVG', conda_forge=False ) assert is_available_in_conda_forge assert info.version == '0.1.6' @mock.patch('urllib.request.urlopen', new=mocked_urlopen_valid_not_in_forge) def test_hub_plugin_info_not_in_forge(): hub.hub_plugin_info.cache_clear() info, is_available_in_conda_forge = hub.hub_plugin_info( 'napari-SVG', conda_forge=True ) assert not is_available_in_conda_forge assert info.version == '0.1.6' napari-0.5.0a1/napari/plugins/_tests/test_npe2.py000066400000000000000000000144731437041365600217430ustar00rootroot00000000000000from pathlib import Path from types import MethodType from typing import TYPE_CHECKING from unittest.mock import MagicMock import numpy as np import pytest from npe2 import PluginManifest if TYPE_CHECKING: from npe2._pytest_plugin import TestPluginManager from napari.layers import Image, Points from napari.plugins import _npe2 PLUGIN_NAME = 'my-plugin' # this matches the sample_manifest PLUGIN_DISPLAY_NAME = 'My Plugin' # this matches the sample_manifest MANIFEST_PATH = Path(__file__).parent / '_sample_manifest.yaml' @pytest.fixture def mock_pm(npe2pm: 'TestPluginManager'): from napari.plugins import _initialize_plugins _initialize_plugins.cache_clear() mock_reg = MagicMock() npe2pm._command_registry = mock_reg with npe2pm.tmp_plugin(manifest=MANIFEST_PATH): yield npe2pm def test_read(mock_pm: 'TestPluginManager'): _, hookimpl = _npe2.read(["some.fzzy"], stack=False) mock_pm.commands.get.assert_called_once_with(f'{PLUGIN_NAME}.some_reader') assert hookimpl.plugin_name == PLUGIN_NAME mock_pm.commands.get.reset_mock() _, hookimpl = _npe2.read(["some.fzzy"], stack=True) mock_pm.commands.get.assert_called_once_with(f'{PLUGIN_NAME}.some_reader') mock_pm.commands.get.reset_mock() assert _npe2.read(["some.randomext"], stack=True) is None mock_pm.commands.get.assert_not_called() def test_write(mock_pm: 'TestPluginManager'): # saving an image without a writer goes straight to npe2.write # it will use our plugin writer image = Image(np.random.rand(20, 20), name='ex_img') _npe2.write_layers('some_file.tif', [image]) mock_pm.commands.get.assert_called_once_with(f'{PLUGIN_NAME}.my_writer') # points won't trigger our sample writer mock_pm.commands.get.reset_mock() points = Points(np.random.rand(20, 2), name='ex_points') _npe2.write_layers('some_file.tif', [points]) mock_pm.commands.get.assert_not_called() # calling _npe2.write_layers with a specific writer contribution should # directly execute the writer.exec with arguments appropriate for the # writer spec (single or multi-writer) mock_pm.commands.get.reset_mock() writer = mock_pm.get_manifest(PLUGIN_NAME).contributions.writers[0] writer = MagicMock(wraps=writer) writer.exec.return_value = [''] assert _npe2.write_layers('some_file.tif', [points], writer=writer)[0] == [ '' ] mock_pm.commands.get.assert_not_called() writer.exec.assert_called_once() assert writer.exec.call_args_list[0].kwargs['args'][0] == 'some_file.tif' def test_get_widget_contribution(mock_pm: 'TestPluginManager'): # calling with plugin alone (_, display_name) = _npe2.get_widget_contribution(PLUGIN_NAME) mock_pm.commands.get.assert_called_once_with('my-plugin.some_widget') assert display_name == 'My Widget' # calling with plugin but wrong widget name provides a useful error msg with pytest.raises(KeyError) as e: _npe2.get_widget_contribution(PLUGIN_NAME, 'Not a widget') assert ( f"Plugin {PLUGIN_NAME!r} does not provide a widget named 'Not a widget'" in str(e.value) ) # calling with a non-existent plugin just returns None mock_pm.commands.get.reset_mock() assert not _npe2.get_widget_contribution('not-a-thing') mock_pm.commands.get.assert_not_called() def test_populate_qmenu(mock_pm: 'TestPluginManager'): menu = MagicMock() _npe2.populate_qmenu(menu, '/napari/layer_context') assert menu.addMenu.called_once_with('My SubMenu') assert menu.addAction.called_once_with('Hello World') def test_file_extensions_string_for_layers(mock_pm: 'TestPluginManager'): layers = [Image(np.random.rand(20, 20), name='ex_img')] label, writers = _npe2.file_extensions_string_for_layers(layers) assert label == 'My Plugin (*.tif *.tiff)' writer = mock_pm.get_manifest(PLUGIN_NAME).contributions.writers[0] assert writers == [writer] def test_get_readers(mock_pm): assert _npe2.get_readers("some.fzzy") == {PLUGIN_NAME: 'My Plugin'} def test_iter_manifest(mock_pm): for i in _npe2.iter_manifests(): assert isinstance(i, PluginManifest) def test_get_sample_data(mock_pm): samples = mock_pm.get_manifest(PLUGIN_NAME).contributions.sample_data opener, _ = _npe2.get_sample_data(PLUGIN_NAME, 'random_data') assert isinstance(opener, MethodType) and opener.__self__ is samples[0] opener, _ = _npe2.get_sample_data(PLUGIN_NAME, 'internet_image') assert isinstance(opener, MethodType) and opener.__self__ is samples[1] opener, avail = _npe2.get_sample_data('not-a-plugin', 'nor-a-sample') assert opener is None assert avail == [ (PLUGIN_NAME, 'random_data'), (PLUGIN_NAME, 'internet_image'), ] def test_sample_iterator(mock_pm): samples = list(_npe2.sample_iterator()) assert samples for plugin, contribs in samples: assert isinstance(plugin, str) assert isinstance(contribs, dict) # check that the manifest display_name is used assert plugin == PLUGIN_NAME assert contribs for i in contribs.values(): assert 'data' in i assert 'display_name' in i def test_widget_iterator(mock_pm): wdgs = list(_npe2.widget_iterator()) assert wdgs == [('dock', (PLUGIN_NAME, ['My Widget']))] def test_plugin_actions(mock_pm: 'TestPluginManager'): from napari._app_model import get_app from napari.plugins import _initialize_plugins app = get_app() menus_items1 = list(app.menus.get_menu('napari/layers/context')) assert 'my-plugin.hello_world' not in app.commands _initialize_plugins() # connect registration callbacks and populate registries # the _sample_manifest should have added two items to menus menus_items2 = list(app.menus.get_menu('napari/layers/context')) assert 'my-plugin.hello_world' in app.commands assert len(menus_items2) == len(menus_items1) + 2 # then disable and re-enable the plugin mock_pm.disable(PLUGIN_NAME) menus_items3 = list(app.menus.get_menu('napari/layers/context')) assert len(menus_items3) == len(menus_items1) assert 'my-plugin.hello_world' not in app.commands mock_pm.enable(PLUGIN_NAME) menus_items4 = list(app.menus.get_menu('napari/layers/context')) assert len(menus_items4) == len(menus_items2) assert 'my-plugin.hello_world' in app.commands napari-0.5.0a1/napari/plugins/_tests/test_plugin_widgets.py000066400000000000000000000031471437041365600241170ustar00rootroot00000000000000import pytest from napari_plugin_engine import napari_hook_implementation def func(x, y): pass def func2(x, y): pass fwidget_args = { 'single_func': func, 'list_func': [func, func2], 'bad_func_tuple': (func, {'call_button': True}), 'bad_full_func_tuple': (func, {'auto_call': True}, {'area': 'right'}), 'bad_tuple_list': [(func, {'auto_call': True}), (func2, {})], 'bad_func': 1, 'bad_tuple1': (func, 1), 'bad_tuple2': (func, {}, 1), 'bad_tuple3': (func, 1, {}), 'bad_double_tuple': ((func, {}), (func2, {})), 'bad_magic_kwargs': (func, {"non_magicgui_kwarg": True}), 'bad_good_magic_kwargs': (func, {'call_button': True, "x": {'max': 200}}), } # napari_plugin_manager fixture from napari.conftest # request, recwarn fixtures are from pytest @pytest.mark.parametrize('arg', fwidget_args.values(), ids=fwidget_args.keys()) def test_function_widget_registration( arg, napari_plugin_manager, request, recwarn ): """Test that function widgets get validated and registerd correctly.""" class Plugin: @napari_hook_implementation def napari_experimental_provide_function(): return arg napari_plugin_manager.discover_widgets() napari_plugin_manager.register(Plugin, name='Plugin') f_widgets = napari_plugin_manager._function_widgets if 'bad_' in request.node.name: assert not f_widgets assert len(recwarn) == 1 else: assert f_widgets['Plugin']['func'] == func assert len(recwarn) == 0 if 'list_func' in request.node.name: assert f_widgets['Plugin']['func2'] == func2 napari-0.5.0a1/napari/plugins/_tests/test_plugins_manager.py000066400000000000000000000064471437041365600242540ustar00rootroot00000000000000import subprocess import sys from typing import TYPE_CHECKING import pytest from napari_plugin_engine import napari_hook_implementation if TYPE_CHECKING: from napari.plugins._plugin_manager import NapariPluginManager def test_plugin_discovery_is_delayed(): """Test that plugins are not getting discovered at napari import time.""" cmd = [ sys.executable, '-c', 'import sys; from napari.plugins import plugin_manager; ' 'sys.exit(len(plugin_manager.plugins) > 2)', # we have 2 'builtins' ] # will fail if plugin discovery happened at import proc = subprocess.run(cmd, capture_output=True) assert not proc.returncode, 'Plugins were discovered at import time!' def test_plugin_events(napari_plugin_manager): """Test event emission by plugin manager.""" tnpm: NapariPluginManager = napari_plugin_manager register_events = [] unregister_events = [] enable_events = [] disable_events = [] tnpm.events.registered.connect(lambda e: register_events.append(e)) tnpm.events.unregistered.connect(lambda e: unregister_events.append(e)) tnpm.events.enabled.connect(lambda e: enable_events.append(e)) tnpm.events.disabled.connect(lambda e: disable_events.append(e)) class Plugin: pass tnpm.register(Plugin, name='Plugin') assert 'Plugin' in tnpm.plugins assert len(register_events) == 1 assert register_events[0].value == 'Plugin' assert not enable_events assert not disable_events tnpm.unregister(Plugin) assert len(unregister_events) == 1 assert unregister_events[0].value == 'Plugin' tnpm.set_blocked('Plugin') assert len(disable_events) == 1 assert disable_events[0].value == 'Plugin' assert not enable_events assert 'Plugin' not in tnpm.plugins # blocked from registering assert tnpm.is_blocked('Plugin') tnpm.register(Plugin, name='Plugin') assert 'Plugin' not in tnpm.plugins assert len(register_events) == 1 tnpm.set_blocked('Plugin', False) assert not tnpm.is_blocked('Plugin') assert len(enable_events) == 1 assert enable_events[0].value == 'Plugin' # note: it doesn't immediately re-register it assert 'Plugin' not in tnpm.plugins # but we can now re-register it tnpm.register(Plugin, name='Plugin') assert len(register_events) == 2 def test_plugin_extension_assignment(napari_plugin_manager): class Plugin: @napari_hook_implementation def napari_get_reader(path): if path.endswith('.png'): return lambda x: None @napari_hook_implementation def napari_get_writer(path, *args): if path.endswith('.png'): return lambda x: None tnpm: NapariPluginManager = napari_plugin_manager tnpm.register(Plugin, name='test_plugin') assert tnpm.get_reader_for_extension('.png') is None tnpm.assign_reader_to_extensions('test_plugin', '.png') assert '.png' in tnpm._extension2reader assert tnpm.get_reader_for_extension('.png') == 'test_plugin' with pytest.warns(UserWarning): # reader may not recognize extension tnpm.assign_reader_to_extensions('test_plugin', '.pndfdg') with pytest.raises(ValueError): # invalid plugin name tnpm.assign_reader_to_extensions('test_pldfdfugin', '.png') napari-0.5.0a1/napari/plugins/_tests/test_provide_theme.py000066400000000000000000000072011437041365600237200ustar00rootroot00000000000000"""Test `napari_experimental_provide_theme` hook specification.""" from typing import TYPE_CHECKING import pytest from napari_plugin_engine import napari_hook_implementation from napari.settings import get_settings from napari.utils.theme import Theme, available_themes, get_theme from napari.viewer import ViewerModel if TYPE_CHECKING: from napari.plugins._plugin_manager import NapariPluginManager def test_provide_theme_hook(napari_plugin_manager: "NapariPluginManager"): dark = get_theme("dark", True) dark["name"] = "dark-test" class TestPlugin: @napari_hook_implementation def napari_experimental_provide_theme(): return {"dark-test": dark} viewer = ViewerModel() napari_plugin_manager.discover_themes() napari_plugin_manager.register(TestPlugin) # make sure theme data is present in the plugin reg = napari_plugin_manager._theme_data["TestPlugin"] assert isinstance(reg, dict) assert len(reg) == 1 assert isinstance(reg["dark-test"], Theme) # make sure theme was registered assert "dark-test" in available_themes() viewer.theme = "dark-test" def test_provide_theme_hook_bad(napari_plugin_manager: "NapariPluginManager"): napari_plugin_manager.discover_themes() dark = get_theme("dark", True) dark.pop("foreground") dark["name"] = "dark-bad" class TestPluginBad: @napari_hook_implementation def napari_experimental_provide_theme(): return {"dark-bad": dark} with pytest.warns( UserWarning, match=", plugin 'TestPluginBad' provided an invalid dict object", ): napari_plugin_manager.register(TestPluginBad) # make sure theme data is present in the plugin but the theme is not there reg = napari_plugin_manager._theme_data["TestPluginBad"] assert isinstance(reg, dict) assert len(reg) == 0 assert "dark-bad" not in available_themes() def test_provide_theme_hook_not_dict( napari_plugin_manager: "NapariPluginManager", ): napari_plugin_manager.discover_themes() class TestPluginBad: @napari_hook_implementation def napari_experimental_provide_theme(): return ["bad-theme", []] with pytest.warns( UserWarning, match="Plugin 'TestPluginBad' provided a non-dict object", ): napari_plugin_manager.register(TestPluginBad) # make sure theme data is present in the plugin but the theme is not there assert "TestPluginBad" not in napari_plugin_manager._theme_data def test_provide_theme_hook_unregister( napari_plugin_manager: "NapariPluginManager", ): dark = get_theme("dark", True) dark["name"] = "dark-test" class TestPlugin: @napari_hook_implementation def napari_experimental_provide_theme(): return {"dark-test": dark} napari_plugin_manager.discover_themes() napari_plugin_manager.register(TestPlugin) # make sure theme was registered assert "TestPlugin" in napari_plugin_manager._theme_data reg = napari_plugin_manager._theme_data["TestPlugin"] assert isinstance(reg, dict) assert len(reg) == 1 assert "dark-test" in available_themes() get_settings().appearance.theme = "dark-test" with pytest.warns(UserWarning, match="The current theme "): napari_plugin_manager.unregister("TestPlugin") # make sure that plugin-specific data was removed assert "TestPlugin" not in napari_plugin_manager._theme_data # since the plugin was unregistered, the current theme cannot # be the theme registered by the plugin assert get_settings().appearance.theme != "dark-test" assert "dark-test" not in available_themes() napari-0.5.0a1/napari/plugins/_tests/test_sample_data.py000066400000000000000000000026561437041365600233510ustar00rootroot00000000000000from pathlib import Path import numpy as np import pytest from npe2 import DynamicPlugin from npe2.manifest.contributions import SampleDataURI import napari from napari.layers._source import Source from napari.viewer import ViewerModel def test_sample_hook(builtins, tmp_plugin: DynamicPlugin): viewer = ViewerModel() NAME = tmp_plugin.name KEY = 'random data' with pytest.raises(KeyError, match=f"Plugin {NAME!r} does not provide"): viewer.open_sample(NAME, KEY) @tmp_plugin.contribute.sample_data(key=KEY) def _generate_random_data(shape=(512, 512)): data = np.random.rand(*shape) return [(data, {'name': KEY})] LOGO = str(Path(napari.__file__).parent / 'resources' / 'logo.png') tmp_plugin.manifest.contributions.sample_data.append( SampleDataURI(uri=LOGO, key='napari logo', display_name='Napari logo') ) assert len(viewer.layers) == 0 viewer.open_sample(NAME, KEY) assert viewer.layers[-1].source == Source( path=None, reader_plugin=None, sample=(NAME, KEY) ) assert len(viewer.layers) == 1 viewer.open_sample(NAME, 'napari logo') assert viewer.layers[-1].source == Source( path=LOGO, reader_plugin='napari', sample=(NAME, 'napari logo') ) # test calling with kwargs viewer.open_sample(NAME, KEY, shape=(256, 256)) assert len(viewer.layers) == 3 assert viewer.layers[-1].source == Source(sample=(NAME, KEY)) napari-0.5.0a1/napari/plugins/_tests/test_save_layers.py000066400000000000000000000061431437041365600234070ustar00rootroot00000000000000import os import pytest from npe2 import DynamicPlugin from napari.plugins.io import save_layers # the layer_data_and_types fixture is defined in napari/conftest.py def test_save_layer_single_named_plugin( builtins, tmpdir, layer_data_and_types ): """Test saving a single layer with a named plugin.""" layers, _, _, filenames = layer_data_and_types for layer, fn in zip(layers, filenames): path = os.path.join(tmpdir, fn) # Check file does not exist assert not os.path.isfile(path) # Write data save_layers(path, [layer], plugin=builtins.name) # Check file now exists assert os.path.isfile(path) # the layer_data_and_types fixture is defined in napari/conftest.py def test_save_layer_no_results(): """Test no layers is not an error, and warns on no results.""" with pytest.warns(UserWarning): result = save_layers('no_layers', []) assert result == [] # the layer_data_and_types fixture is defined in napari/conftest.py def test_save_layer_single_no_named_plugin( builtins, tmpdir, layer_data_and_types ): """Test saving a single layer without naming plugin.""" # make writer builtin plugins get called first layers, _, _, filenames = layer_data_and_types for layer, fn in zip(layers, filenames): path = os.path.join(tmpdir, fn) # Check file does not exist assert not os.path.isfile(path) # Write data save_layers(path, [layer]) # Check file now exists assert os.path.isfile(path) # the layer_data_and_types fixture is defined in napari/conftest.py def test_save_layer_multiple_named_plugin( builtins: DynamicPlugin, tmpdir, layer_data_and_types ): """Test saving multiple layers with a named plugin.""" layers, _, _, filenames = layer_data_and_types path = os.path.join(tmpdir, 'layers_folder') # Check file does not exist assert not os.path.isdir(path) # Write data save_layers(path, layers, plugin=builtins.name) # Check folder now exists assert os.path.isdir(path) # Check individual files now exist for f in filenames: assert os.path.isfile(os.path.join(path, f)) # Check no additional files exist assert set(os.listdir(path)) == set(filenames) assert set(os.listdir(tmpdir)) == {'layers_folder'} # the layer_data_and_types fixture is defined in napari/conftest.py def test_save_layer_multiple_no_named_plugin( builtins: DynamicPlugin, tmpdir, layer_data_and_types ): """Test saving multiple layers without naming a plugin.""" layers, _, _, filenames = layer_data_and_types path = os.path.join(tmpdir, 'layers_folder') # Check file does not exist assert not os.path.isdir(path) # Write data save_layers(path, layers, plugin=builtins.name) # Check folder now exists assert os.path.isdir(path) # Check individual files now exist for f in filenames: assert os.path.isfile(os.path.join(path, f)) # Check no additional files exist assert set(os.listdir(path)) == set(filenames) assert set(os.listdir(tmpdir)) == {'layers_folder'} napari-0.5.0a1/napari/plugins/_tests/test_utils.py000066400000000000000000000170621437041365600222340ustar00rootroot00000000000000from npe2 import DynamicPlugin from napari.plugins.utils import ( MatchFlag, get_all_readers, get_filename_patterns_for_reader, get_potential_readers, get_preferred_reader, score_specificity, ) from napari.settings import get_settings def test_get_preferred_reader_no_readers(): get_settings().plugins.extension2reader = {} reader = get_preferred_reader('my_file.tif') assert reader is None def test_get_preferred_reader_for_extension(): get_settings().plugins.extension2reader = {'*.tif': 'fake-plugin'} reader = get_preferred_reader('my_file.tif') assert reader == 'fake-plugin' def test_get_preferred_reader_complex_pattern(): get_settings().plugins.extension2reader = { '*/my-specific-folder/*.tif': 'fake-plugin' } reader = get_preferred_reader('/asdf/my-specific-folder/my_file.tif') assert reader == 'fake-plugin' reader = get_preferred_reader('/asdf/foo/my-specific-folder/my_file.tif') assert reader == 'fake-plugin' def test_get_preferred_reader_match_less_ambiguous(): get_settings().plugins.extension2reader = { # generic star so least specificity '*.tif': 'generic-tif-plugin', # specific file so most specificity '*/foo.tif': 'very-specific-plugin', # set so less specificity '*/file_[0-9][0-9].tif': 'set-plugin', } reader = get_preferred_reader('/asdf/a.tif') assert reader == 'generic-tif-plugin' reader = get_preferred_reader('/asdf/foo.tif') assert reader == 'very-specific-plugin' reader = get_preferred_reader('/asdf/file_01.tif') assert reader == 'set-plugin' def test_get_preferred_reader_more_nested(): get_settings().plugins.extension2reader = { # less nested so less specificity '*.tif': 'generic-tif-plugin', # more nested so higher specificity '*/my-specific-folder/*.tif': 'fake-plugin', # even more nested so even higher specificity '*/my-specific-folder/nested/*.tif': 'very-specific-plugin', } reader = get_preferred_reader('/asdf/nested/1/2/3/my_file.tif') assert reader == 'generic-tif-plugin' reader = get_preferred_reader('/asdf/my-specific-folder/my_file.tif') assert reader == 'fake-plugin' reader = get_preferred_reader( '/asdf/my-specific-folder/nested/my_file.tif' ) assert reader == 'very-specific-plugin' def test_get_preferred_reader_abs_path(): get_settings().plugins.extension2reader = { # abs path so highest specificity '/asdf/*.tif': 'most-specific-plugin', # less nested so less specificity '*.tif': 'generic-tif-plugin', # more nested so higher specificity '*/my-specific-folder/*.tif': 'fake-plugin', # even more nested so even higher specificity '*/my-specific-folder/nested/*.tif': 'very-specific-plugin', } reader = get_preferred_reader( '/asdf/my-specific-folder/nested/my_file.tif' ) assert reader == 'most-specific-plugin' def test_score_specificity_simple(): assert score_specificity('') == (True, 0, [MatchFlag.NONE]) assert score_specificity('a') == (True, 0, [MatchFlag.NONE]) assert score_specificity('ab*c') == (True, 0, [MatchFlag.STAR]) assert score_specificity('a?c') == (True, 0, [MatchFlag.ANY]) assert score_specificity('a[a-zA-Z]c') == (True, 0, [MatchFlag.SET]) assert score_specificity('*[a-zA-Z]*a?c') == ( True, 0, [MatchFlag.STAR | MatchFlag.ANY | MatchFlag.SET], ) def test_score_specificity_complex(): assert score_specificity('*/my-specific-folder/[nested]/*?.tif') == ( True, -3, [ MatchFlag.STAR, MatchFlag.NONE, MatchFlag.SET, MatchFlag.STAR | MatchFlag.ANY, ], ) assert score_specificity('/my-specific-folder/[nested]/*?.tif') == ( False, -2, [ MatchFlag.NONE, MatchFlag.SET, MatchFlag.STAR | MatchFlag.ANY, ], ) def test_score_specificity_collapse_star(): assert score_specificity('*/*/?*.tif') == ( True, -1, [MatchFlag.STAR, MatchFlag.STAR | MatchFlag.ANY], ) assert score_specificity('*/*/*a?c.tif') == ( True, 0, [MatchFlag.STAR | MatchFlag.ANY], ) assert score_specificity('*/*/*.tif') == (True, 0, [MatchFlag.STAR]) assert score_specificity('*/abc*/*.tif') == ( True, -1, [MatchFlag.STAR, MatchFlag.STAR], ) assert score_specificity('/abc*/*.tif') == (False, 0, [MatchFlag.STAR]) def test_score_specificity_range(): _, _, score = score_specificity('[abc') assert score == [MatchFlag.NONE] _, _, score = score_specificity('[abc]') assert score == [MatchFlag.SET] _, _, score = score_specificity('[abc[') assert score == [MatchFlag.NONE] _, _, score = score_specificity('][abc') assert score == [MatchFlag.NONE] _, _, score = score_specificity('[[abc]]') assert score == [MatchFlag.SET] def test_get_preferred_reader_no_extension(): assert get_preferred_reader('my_file') is None def test_get_potential_readers_gives_napari( builtins, tmp_plugin: DynamicPlugin ): @tmp_plugin.contribute.reader(filename_patterns=['*.tif']) def read_tif(path): ... readers = get_potential_readers('my_file.tif') assert 'napari' in readers assert 'builtins' not in readers def test_get_potential_readers_finds_readers(tmp_plugin: DynamicPlugin): tmp2 = tmp_plugin.spawn(register=True) @tmp_plugin.contribute.reader(filename_patterns=['*.tif']) def read_tif(path): ... @tmp2.contribute.reader(filename_patterns=['*.*']) def read_all(path): ... readers = get_potential_readers('my_file.tif') assert len(readers) == 2 def test_get_potential_readers_extension_case(tmp_plugin: DynamicPlugin): @tmp_plugin.contribute.reader(filename_patterns=['*.tif']) def read_tif(path): ... readers = get_potential_readers('my_file.TIF') assert len(readers) == 1 def test_get_potential_readers_none_available(): assert not get_potential_readers('my_file.fake') def test_get_potential_readers_plugin_name_disp_name( tmp_plugin: DynamicPlugin, ): @tmp_plugin.contribute.reader(filename_patterns=['*.fake']) def read_tif(path): ... readers = get_potential_readers('my_file.fake') assert readers[tmp_plugin.name] == tmp_plugin.display_name def test_get_all_readers_gives_napari(builtins): npe2_readers, npe1_readers = get_all_readers() assert len(npe1_readers) == 0 assert len(npe2_readers) == 1 assert 'napari' in npe2_readers def test_get_all_readers(tmp_plugin: DynamicPlugin): tmp2 = tmp_plugin.spawn(register=True) @tmp_plugin.contribute.reader(filename_patterns=['*.fake']) def read_tif(path): ... @tmp2.contribute.reader(filename_patterns=['.fake2']) def read_all(path): ... npe2_readers, npe1_readers = get_all_readers() assert len(npe2_readers) == 2 assert len(npe1_readers) == 0 def test_get_filename_patterns_fake_plugin(): assert len(get_filename_patterns_for_reader('gibberish')) == 0 def test_get_filename_patterns(tmp_plugin: DynamicPlugin): @tmp_plugin.contribute.reader(filename_patterns=['*.tif']) def read_tif(path): ... @tmp_plugin.contribute.reader(filename_patterns=['*.csv']) def read_csv(pth): ... patterns = get_filename_patterns_for_reader(tmp_plugin.name) assert len(patterns) == 2 assert '*.tif' in patterns assert '*.csv' in patterns napari-0.5.0a1/napari/plugins/exceptions.py000066400000000000000000000036731437041365600207200ustar00rootroot00000000000000from napari_plugin_engine import PluginError, standard_metadata from napari.utils.translations import trans def format_exceptions( plugin_name: str, as_html: bool = False, color="Neutral" ): """Return formatted tracebacks for all exceptions raised by plugin. Parameters ---------- plugin_name : str The name of a plugin for which to retrieve tracebacks. as_html : bool Whether to return the exception string as formatted html, defaults to False. Returns ------- str A formatted string with traceback information for every exception raised by ``plugin_name`` during this session. """ _plugin_errors = PluginError.get(plugin_name=plugin_name) if not _plugin_errors: return '' from napari import __version__ from napari.utils._tracebacks import get_tb_formatter format_exc_info = get_tb_formatter() _linewidth = 80 _pad = (_linewidth - len(plugin_name) - 18) // 2 msg = [ trans._( "{pad} Errors for plugin '{plugin_name}' {pad}", deferred=True, pad='=' * _pad, plugin_name=plugin_name, ), '', f'{"napari version": >16}: {__version__}', ] err0 = _plugin_errors[0] if err0.plugin: package_meta = standard_metadata(err0.plugin) if package_meta: msg.extend( [ f'{"plugin package": >16}: {package_meta["package"]}', f'{"version": >16}: {package_meta["version"]}', f'{"module": >16}: {err0.plugin}', ] ) msg.append('') for n, err in enumerate(_plugin_errors): _pad = _linewidth - len(str(err)) - 10 msg += ['', f'ERROR #{n + 1}: {str(err)} {"-" * _pad}', ''] msg.append(format_exc_info(err.info(), as_html, color)) msg.append('=' * _linewidth) return ("
" if as_html else "\n").join(msg) napari-0.5.0a1/napari/plugins/hook_specifications.py000066400000000000000000000514611437041365600225600ustar00rootroot00000000000000""" All napari hook specifications for pluggable functionality are defined here. A *hook specification* is a function signature (with documentation) that declares an API that plugin developers must adhere to when providing hook implementations. *Hook implementations* provided by plugins (and internally by napari) will then be invoked in various places throughout the code base. When implementing a hook specification, pay particular attention to the number and types of the arguments in the specification signature, as well as the expected return type. To allow for hook specifications to evolve over the lifetime of napari, hook implementations may accept *fewer* arguments than defined in the specification. (This allows for extending existing hook arguments without breaking existing implementations). However, implementations must not require *more* arguments than defined in the spec. For more general background on the plugin hook calling mechanism, see the `napari-plugin-manager documentation `_. .. NOTE:: Hook specifications are a feature borrowed from `pluggy `_. In the `pluggy documentation `_, hook specification marker instances are named ``hookspec`` by convention, and hook implementation marker instances are named ``hookimpl``. The convention in napari is to name them more explicitly: ``napari_hook_specification`` and ``napari_hook_implementation``, respectively. """ # These hook specifications also serve as the API reference for plugin # developers, so comprehensive documentation with complete type annotations is # imperative! from __future__ import annotations from types import FunctionType from typing import Any, Dict, List, Optional, Tuple, Union from napari_plugin_engine import napari_hook_specification from napari.types import ( AugmentedWidget, ReaderFunction, SampleData, SampleDict, WriterFunction, ) # -------------------------------------------------------------------------- # # IO Hooks # # -------------------------------------------------------------------------- # @napari_hook_specification(historic=True) def napari_provide_sample_data() -> Dict[str, Union[SampleData, SampleDict]]: """Provide sample data. Plugins may implement this hook to provide sample data for use in napari. Sample data is accessible in the `File > Open Sample` menu, or programmatically, with :meth:`napari.Viewer.open_sample`. Plugins implementing this hook specification must return a ``dict``, where each key is a `sample_key` (the string that will appear in the `Open Sample` menu), and the value is either a string, or a callable that returns an iterable of ``LayerData`` tuples, where each tuple is a 1-, 2-, or 3-tuple of ``(data,)``, ``(data, meta)``, or ``(data, meta, layer_type)`` (thus, an individual sample-loader may provide multiple layers). If the value is a string, it will be opened with :meth:`napari.Viewer.open`. Examples -------- Here's a minimal example of a plugin that provides three samples: 1. random data from numpy 2. a random image pulled from the internet 3. random data from numpy, provided as a dict with the keys: * 'display_name': a string that will show in the menu (by default, the `sample_key` will be shown) * 'data': a string or callable, as in 1/2. .. code-block:: python import numpy as np from napari_plugin_engine import napari_hook_implementation def _generate_random_data(shape=(512, 512)): data = np.random.rand(*shape) return [(data, {'name': 'random data'})] @napari_hook_implementation def napari_provide_sample_data(): return { 'random data': _generate_random_data, 'random image': 'https://picsum.photos/1024', 'sample_key': { 'display_name': 'Some Random Data (512 x 512)' 'data': _generate_random_data, } } Returns ------- Dict[ str, Union[str, Callable[..., Iterable[LayerData]]] ] A mapping of `sample_key` to `data_loader` """ @napari_hook_specification(firstresult=True) def napari_get_reader(path: Union[str, List[str]]) -> Optional[ReaderFunction]: """Return a function capable of loading ``path`` into napari, or ``None``. This is the primary "**reader plugin**" function. It accepts a path or list of paths, and returns a list of data to be added to the ``Viewer``. The function may return ``[(None, )]`` to indicate that the file was read successfully, but did not contain any data. The main place this hook is used is in :func:`Viewer.open() `, via the :func:`~napari.plugins.io.read_data_with_plugins` function. It will also be called on ``File -> Open...`` or when a user drops a file or folder onto the viewer. This function must execute **quickly**, and should return ``None`` if the filepath is of an unrecognized format for this reader plugin. If ``path`` is determined to be recognized format, this function should return a *new* function that accepts the same filepath (or list of paths), and returns a list of ``LayerData`` tuples, where each tuple is a 1-, 2-, or 3-tuple of ``(data,)``, ``(data, meta)``, or ``(data, meta, layer_type)``. ``napari`` will then use each tuple in the returned list to generate a new layer in the viewer using the :func:`Viewer._add_layer_from_data() ` method. The first, (optional) second, and (optional) third items in each tuple in the returned layer_data list, therefore correspond to the ``data``, ``meta``, and ``layer_type`` arguments of the :func:`Viewer._add_layer_from_data() ` method, respectively. .. important:: ``path`` may be either a ``str`` or a ``list`` of ``str``. If a ``list``, then each path in the list can be assumed to be one part of a larger multi-dimensional stack (for instance: a list of 2D image files that should be stacked along a third axis). Implementations should do their own checking for ``list`` or ``str``, and handle each case as desired. Parameters ---------- path : str or list of str Path to file, directory, or resource (like a URL), or a list of paths. Returns ------- Callable or None A function that accepts the path, and returns a list of ``layer_data``, where ``layer_data`` is one of ``(data,)``, ``(data, meta)``, or ``(data, meta, layer_type)``. If unable to read the path, must return ``None`` (not ``False``!). """ @napari_hook_specification(firstresult=True) def napari_get_writer( path: str, layer_types: List[str] ) -> Optional[WriterFunction]: """Return function capable of writing napari layer data to ``path``. This function will be called whenever the user attempts to save multiple layers (e.g. via ``File -> Save Layers``, or :func:`~napari.plugins.io.save_layers`). This function must execute **quickly**, and should return ``None`` if ``path`` has an unrecognized extension for the reader plugin or the list of layer types are incompatible with what the plugin can write. If ``path`` is a recognized format, this function should return a *function* that accepts the same ``path``, and a list of tuples containing the data for each layer being saved in the form of ``(Layer.data, Layer._get_state(), Layer._type_string)``. The writer function should return a list of strings (the actual filepath(s) that were written). .. important:: It is up to plugins to inspect and obey any extension in ``path`` (and return ``None`` if it is an unsupported extension). An example function signature for a ``WriterFunction`` that might be returned by this hook specification is as follows: .. code-block:: python def writer_function( path: str, layer_data: List[Tuple[Any, Dict, str]] ) -> List[str]: ... Parameters ---------- path : str Path to file, directory, or resource (like a URL). Any extensions in the path should be examined and obeyed. (i.e. if the plugin is incapable of returning a requested extension, it should return ``None``). layer_types : list of str List of layer types (e.g. "image", "labels") that will be provided to the writer function. Returns ------- Callable or None A function that accepts the path, a list of layer_data (where layer_data is ``(data, meta, layer_type)``). If unable to write to the path or write the layer_data, must return ``None`` (not ``False``). """ @napari_hook_specification(firstresult=True) def napari_write_image(path: str, data: Any, meta: dict) -> Optional[str]: """Write image data and metadata into a path. It is the responsibility of the implementation to check any extension on ``path`` and return ``None`` if it is an unsupported extension. If ``path`` has no extension, implementations may append their preferred extension. Parameters ---------- path : str Path to file, directory, or resource (like a URL). data : array or list of array Image data. Can be N dimensional. If meta['rgb'] is ``True`` then the data should be interpreted as RGB or RGBA. If meta['multiscale'] is True, then the data should be interpreted as a multiscale image. meta : dict Image metadata. Returns ------- path : str or None If data is successfully written, return the ``path`` that was written. Otherwise, if nothing was done, return ``None``. """ @napari_hook_specification(firstresult=True) def napari_write_labels(path: str, data: Any, meta: dict) -> Optional[str]: """Write labels data and metadata into a path. It is the responsibility of the implementation to check any extension on ``path`` and return ``None`` if it is an unsupported extension. If ``path`` has no extension, implementations may append their preferred extension. Parameters ---------- path : str Path to file, directory, or resource (like a URL). data : array or list of array Integer valued label data. Can be N dimensional. Every pixel contains an integer ID corresponding to the region it belongs to. The label 0 is rendered as transparent. If a list and arrays are decreasing in shape then the data is from a multiscale image. meta : dict Labels metadata. Returns ------- path : str or None If data is successfully written, return the ``path`` that was written. Otherwise, if nothing was done, return ``None``. """ @napari_hook_specification(firstresult=True) def napari_write_points(path: str, data: Any, meta: dict) -> Optional[str]: """Write points data and metadata into a path. It is the responsibility of the implementation to check any extension on ``path`` and return ``None`` if it is an unsupported extension. If ``path`` has no extension, implementations may append their preferred extension. Parameters ---------- path : str Path to file, directory, or resource (like a URL). data : array (N, D) Coordinates for N points in D dimensions. meta : dict Points metadata. Returns ------- path : str or None If data is successfully written, return the ``path`` that was written. Otherwise, if nothing was done, return ``None``. """ @napari_hook_specification(firstresult=True) def napari_write_shapes(path: str, data: Any, meta: dict) -> Optional[str]: """Write shapes data and metadata into a path. It is the responsibility of the implementation to check any extension on ``path`` and return ``None`` if it is an unsupported extension. If ``path`` has no extension, implementations may append their preferred extension. Parameters ---------- path : str Path to file, directory, or resource (like a URL). data : list List of shape data, where each element is an (N, D) array of the N vertices of a shape in D dimensions. meta : dict Shapes metadata. Returns ------- path : str or None If data is successfully written, return the ``path`` that was written. Otherwise, if nothing was done, return ``None``. """ @napari_hook_specification(firstresult=True) def napari_write_surface(path: str, data: Any, meta: dict) -> Optional[str]: """Write surface data and metadata into a path. It is the responsibility of the implementation to check any extension on ``path`` and return ``None`` if it is an unsupported extension. If ``path`` has no extension, implementations may append their preferred extension. Parameters ---------- path : str Path to file, directory, or resource (like a URL). data : 3-tuple of array The first element of the tuple is an (N, D) array of vertices of mesh triangles. The second is an (M, 3) array of int of indices of the mesh triangles. The third element is the (K0, ..., KL, N) array of values used to color vertices where the additional L dimensions are used to color the same mesh with different values. meta : dict Surface metadata. Returns ------- path : str or None If data is successfully written, return the ``path`` that was written. Otherwise, if nothing was done, return ``None``. """ @napari_hook_specification(firstresult=True) def napari_write_vectors(path: str, data: Any, meta: dict) -> Optional[str]: """Write vectors data and metadata into a path. It is the responsibility of the implementation to check any extension on ``path`` and return ``None`` if it is an unsupported extension. If ``path`` has no extension, implementations may append their preferred extension. Parameters ---------- path : str Path to file, directory, or resource (like a URL). data : (N, 2, D) array The start point and projections of N vectors in D dimensions. meta : dict Vectors metadata. Returns ------- path : str or None If data is successfully written, return the ``path`` that was written. Otherwise, if nothing was done, return ``None``. """ # -------------------------------------------------------------------------- # # GUI Hooks # # -------------------------------------------------------------------------- # @napari_hook_specification(historic=True) def napari_experimental_provide_function() -> Union[ FunctionType, List[FunctionType] ]: """Provide function(s) that can be passed to magicgui. This hook specification is marked as experimental as the API or how the returned value is handled may change here more frequently then the rest of the codebase. Returns ------- function(s) : FunctionType or list of FunctionType Implementations should provide either a single function, or a list of functions. Note that this does not preclude specifying multiple separate implementations in the same module or class. The functions should have Python type annotations so that `magicgui `_ can generate a widget from them. Examples -------- >>> from napari.types import ImageData, LayerDataTuple >>> >>> def my_function(image : ImageData) -> LayerDataTuple: >>> # process the image >>> result = -image >>> # return it + some layer properties >>> return result, {'colormap':'turbo'} >>> >>> @napari_hook_implementation >>> def napari_experimental_provide_function(): >>> return my_function """ @napari_hook_specification(historic=True) def napari_experimental_provide_dock_widget() -> Union[ AugmentedWidget, List[AugmentedWidget] ]: """Provide functions that return widgets to be docked in the viewer. This hook specification is marked as experimental as the API or how the returned value is handled may change here more frequently then the rest of the codebase. Returns ------- result : callable or tuple or list of callables or list of tuples A "callable" in this context is a class or function that, when called, returns an instance of either a :class:`~qtpy.QtWidgets.QWidget` or a :class:`~magicgui.widgets.FunctionGui`. Implementations of this hook specification must return a callable, or a tuple of ``(callable, dict)``, where the dict contains keyword arguments for :meth:`napari.qt.Window.add_dock_widget`. (note, however, that ``shortcut=`` keyword is not yet supported). Implementations may also return a list, in which each item must be a callable or ``(callable, dict)`` tuple. Note that this does not preclude specifying multiple separate implementations in the same module or class. Examples -------- An example with a QtWidget: >>> from qtpy.QtWidgets import QWidget >>> from napari_plugin_engine import napari_hook_implementation >>> >>> class MyWidget(QWidget): ... def __init__(self, napari_viewer): ... self.viewer = napari_viewer ... super().__init__() ... ... # initialize layout ... layout = QGridLayout() ... ... # add a button ... btn = QPushButton('Click me!', self) ... def trigger(): ... print("napari has", len(napari_viewer.layers), "layers") ... btn.clicked.connect(trigger) ... layout.addWidget(btn) ... ... # activate layout ... self.setLayout(layout) >>> >>> @napari_hook_implementation >>> def napari_experimental_provide_dock_widget(): ... return MyWidget An example using magicgui: >>> from magicgui import magic_factory >>> from napari_plugin_engine import napari_hook_implementation >>> >>> @magic_factory(auto_call=True, threshold={'max': 2 ** 16}) >>> def threshold( ... data: 'napari.types.ImageData', threshold: int ... ) -> 'napari.types.LabelsData': ... return (data > threshold).astype(int) >>> >>> @napari_hook_implementation >>> def napari_experimental_provide_dock_widget(): ... return threshold """ @napari_hook_specification(historic=True) def napari_experimental_provide_theme() -> Dict[ str, Dict[str, Union[str, Tuple, List]] ]: """Provide GUI with a set of colors used through napari. This hook allows you to provide additional color schemes so you can accomplish your desired styling. Themes are provided as `dict` with several required fields and correctly formatted color values. Colors can be specified using color names (e.g. ``white``), hex color (e.g. ``#ff5733``), rgb color in 0-255 range (e.g. ``rgb(255, 0, 127)`` or as 3- or 4-element tuples or lists (e.g. ``(255, 0, 127)``. The `Theme` model will automatically handle the conversion. See :class:`~napari.utils.theme.Theme` for more detail of what are the required keys. Returns ------- themes : Dict[str, Dict[str, Union[str, Tuple, List]] Sequence of dictionaries containing new color schemes to be used by napari. You can replace existing themes by using the same names. Examples -------- >>> def get_new_theme() -> Dict[str, Dict[str, Union[str, Tuple, List]]: ... # specify theme(s) that should be added to napari ... themes = { ... "super_dark": { ... "name": "super_dark", ... "background": "rgb(12, 12, 12)", ... "foreground": "rgb(65, 72, 81)", ... "primary": "rgb(90, 98, 108)", ... "secondary": "rgb(134, 142, 147)", ... "highlight": "rgb(106, 115, 128)", ... "text": "rgb(240, 241, 242)", ... "icon": "rgb(209, 210, 212)", ... "warning": "rgb(153, 18, 31)", ... "current": "rgb(0, 122, 204)", ... "syntax_style": "native", ... "console": "rgb(0, 0, 0)", ... "canvas": "black", ... } ... } ... return themes >>> >>> @napari_hook_implementation >>> def napari_experimental_provide_theme(): ... return get_new_theme() """ napari-0.5.0a1/napari/plugins/hub.py000066400000000000000000000074201437041365600173070ustar00rootroot00000000000000""" These convenience functions will be useful for searching napari hub for retriving plugin information and related metadata. """ import json from concurrent.futures import ThreadPoolExecutor, as_completed from functools import lru_cache from typing import Generator, Optional, Tuple from urllib import error, request from npe2 import PackageMetadata from napari.plugins.utils import normalized_name NAPARI_HUB_PLUGINS = 'https://api.napari-hub.org/plugins' ANACONDA_ORG = 'https://api.anaconda.org/package/{channel}/{package_name}' @lru_cache(maxsize=1024) def hub_plugin_info( name: str, min_dev_status=3, conda_forge=True, ) -> Tuple[Optional[PackageMetadata], bool]: """Get package metadata from the napari hub. Parameters ---------- name : str name of the package min_dev_status : int, optional Development status. Default is 3. conda_forge : bool, optional Check if package is available in conda-forge. Default is True. Returns ------- Tuple of optional PackageMetadata and bool Project PackageMetadata and availability on conda forge. """ try: with request.urlopen(NAPARI_HUB_PLUGINS + "/" + name) as resp: info = json.loads(resp.read().decode()) except error.HTTPError: return None, False # If the napari hub returns an info dict missing the required keys, # simply return None, False like the above except if ( not { 'name', 'version', 'authors', 'summary', 'license', 'project_site', } <= info.keys() ): return None, False version = info["version"] norm_name = normalized_name(info["name"]) is_available_in_conda_forge = True if conda_forge: is_available_in_conda_forge = False anaconda_api = ANACONDA_ORG.format( channel="conda-forge", package_name=norm_name ) try: with request.urlopen(anaconda_api) as resp_api: anaconda_info = json.loads(resp_api.read().decode()) versions = anaconda_info.get("versions", []) if versions: if version not in versions: version = versions[-1] is_available_in_conda_forge = True except error.HTTPError: pass classifiers = info.get("development_status", []) for _ in range(1, min_dev_status): if any(f'Development Status :: {1}' in x for x in classifiers): return None, False authors = ", ".join([author["name"] for author in info["authors"]]) data = PackageMetadata( metadata_version="1.0", name=norm_name, version=version, summary=info["summary"], home_page=info["project_site"], author=authors, license=info["license"] or "UNKNOWN", ) return data, is_available_in_conda_forge def iter_hub_plugin_info( skip=None, conda_forge=True ) -> Generator[Tuple[Optional[PackageMetadata], bool], None, None]: """Return a generator that yields ProjectInfo of available napari plugins.""" if skip is None: skip = {} with request.urlopen(NAPARI_HUB_PLUGINS) as resp: plugins = json.loads(resp.read().decode()) already_yielded = [] with ThreadPoolExecutor(max_workers=8) as executor: futures = [ executor.submit(hub_plugin_info, name, conda_forge=conda_forge) for name in sorted(plugins) if name not in skip ] for future in as_completed(futures): info, is_available_in_conda_forge = future.result() if info and info not in already_yielded: already_yielded.append(info) yield info, is_available_in_conda_forge napari-0.5.0a1/napari/plugins/io.py000066400000000000000000000423761437041365600171510ustar00rootroot00000000000000from __future__ import annotations import os import pathlib import warnings from logging import getLogger from typing import TYPE_CHECKING, Any, List, Optional, Sequence, Tuple from napari_plugin_engine import HookImplementation, PluginCallError from napari.layers import Layer from napari.plugins import _npe2, plugin_manager from napari.types import LayerData from napari.utils.misc import abspath_or_url from napari.utils.translations import trans logger = getLogger(__name__) if TYPE_CHECKING: from npe2.manifest.contributions import WriterContribution def read_data_with_plugins( paths: Sequence[str], plugin: Optional[str] = None, stack: bool = False, ) -> Tuple[Optional[List[LayerData]], Optional[HookImplementation]]: """Iterate reader hooks and return first non-None LayerData or None. This function returns as soon as the path has been read successfully, while catching any plugin exceptions, storing them for later retrieval, providing useful error messages, and re-looping until either a read operation was successful, or no valid readers were found. Exceptions will be caught and stored as PluginErrors (in plugins.exceptions.PLUGIN_ERRORS) Parameters ---------- paths : str, or list of string The of path (file, directory, url) to open plugin : str, optional Name of a plugin to use. If provided, will force ``path`` to be read with the specified ``plugin``. If the requested plugin cannot read ``path``, a PluginCallError will be raised. stack : bool See `Viewer.open` Returns ------- LayerData : list of tuples, or None LayerData that can be passed to :func:`Viewer._add_layer_from_data() `. ``LayerData`` is a list tuples, where each tuple is one of ``(data,)``, ``(data, meta)``, or ``(data, meta, layer_type)`` . If no reader plugins were found (or they all failed), returns ``None`` Raises ------ PluginCallError If ``plugin`` is specified but raises an Exception while reading. """ if plugin == 'builtins': warnings.warn( trans._( 'The "builtins" plugin name is deprecated and will not work in a future version. Please use "napari" instead.', deferred=True, ), ) plugin = 'napari' assert isinstance(paths, list) if not stack: assert len(paths) == 1 hookimpl: Optional[HookImplementation] res = _npe2.read(paths, plugin, stack=stack) if res is not None: _ld, hookimpl = res return [] if _is_null_layer_sentinel(_ld) else _ld, hookimpl # type: ignore [return-value] hook_caller = plugin_manager.hook.napari_get_reader paths = [abspath_or_url(p, must_exist=True) for p in paths] if not plugin and not stack: extension = os.path.splitext(paths[0])[-1] plugin = plugin_manager.get_reader_for_extension(extension) # npe1 compact whether we are reading as stack or not is carried in the type # of paths npe1_path = paths if stack else paths[0] hookimpl = None if plugin: if plugin == 'napari': # napari is npe2 only message = trans._( 'No plugin found capable of reading {repr_path!r}.', deferred=True, repr_path=npe1_path, ) raise ValueError(message) if plugin not in plugin_manager.plugins: names = {i.plugin_name for i in hook_caller.get_hookimpls()} raise ValueError( trans._( "There is no registered plugin named '{plugin}'.\nNames of plugins offering readers are: {names}", deferred=True, plugin=plugin, names=names, ) ) reader = hook_caller._call_plugin(plugin, path=npe1_path) if not callable(reader): raise ValueError( trans._( 'Plugin {plugin!r} does not support file(s) {paths}', deferred=True, plugin=plugin, paths=paths, ) ) hookimpl = hook_caller.get_plugin_implementation(plugin) layer_data = reader(npe1_path) # if the reader returns a "null layer" sentinel indicating an empty # file, return an empty list, otherwise return the result or None if _is_null_layer_sentinel(layer_data): return [], hookimpl return layer_data or None, hookimpl layer_data = None result = hook_caller.call_with_result_obj(path=npe1_path) if reader := result.result: # will raise exceptions if any occurred try: layer_data = reader(npe1_path) # try to read data hookimpl = result.implementation except Exception as exc: # noqa BLE001 raise PluginCallError(result.implementation, cause=exc) from exc if not layer_data: # if layer_data is empty, it means no plugin could read path # we just want to provide some useful feedback, which includes # whether or not paths were passed to plugins as a list. if stack: message = trans._( 'No plugin found capable of reading [{repr_path!r}, ...] as stack.', deferred=True, repr_path=paths[0], ) else: message = trans._( 'No plugin found capable of reading {repr_path!r}.', deferred=True, repr_path=paths, ) # TODO: change to a warning notification in a later PR raise ValueError(message) # if the reader returns a "null layer" sentinel indicating an empty file, # return an empty list, otherwise return the result or None _data = [] if _is_null_layer_sentinel(layer_data) else layer_data or None return _data, hookimpl def save_layers( path: str, layers: List[Layer], *, plugin: Optional[str] = None, _writer: Optional[WriterContribution] = None, ) -> List[str]: """Write list of layers or individual layer to a path using writer plugins. If ``plugin`` is not provided and only one layer is passed, then we directly call ``plugin_manager.hook.napari_write_()`` which will loop through implementations and stop when the first one returns a non-None result. The order in which implementations are called can be changed with the hook ``bring_to_front`` method, for instance: ``plugin_manager.hook.napari_write_points.bring_to_front`` If ``plugin`` is not provided and multiple layers are passed, then we call ``plugin_manager.hook.napari_get_writer()`` which loops through plugins to find the first one that knows how to handle the combination of layers and is able to write the file. If no plugins offer ``napari_get_writer`` for that combination of layers then the builtin ``napari_get_writer`` implementation will create a folder and call ``napari_write_`` for each layer using the ``layer.name`` variable to modify the path such that the layers are written to unique files in the folder. If ``plugin`` is provided and a single layer is passed, then we call the ``napari_write_`` for that plugin, and if it fails we error. If a ``plugin`` is provided and multiple layers are passed, then we call we call ``napari_get_writer`` for that plugin, and if it doesn’t return a WriterFunction we error, otherwise we call it and if that fails if it we error. Parameters ---------- path : str A filepath, directory, or URL to open. layers : List[layers.Layer] Non-empty List of layers to be saved. If only a single layer is passed then we use the hook specification corresponding to its layer type, ``napari_write_``. If multiple layers are passed then we use the ``napari_get_writer`` hook specification. Warns when the list of layers is empty. plugin : str, optional Name of the plugin to use for saving. If None then all plugins corresponding to appropriate hook specification will be looped through to find the first one that can save the data. Returns ------- list of str File paths of any files that were written. """ writer_name = '' if len(layers) > 1: written, writer_name = _write_multiple_layers_with_plugins( path, layers, plugin_name=plugin, _writer=_writer ) elif len(layers) == 1: _written, writer_name = _write_single_layer_with_plugins( path, layers[0], plugin_name=plugin, _writer=_writer ) written = [_written] if _written else [] else: warnings.warn(trans._("No layers to write.")) return [] # If written is empty, something went wrong. # Generate a warning to tell the user what it was. if not written: if writer_name: warnings.warn( trans._( "Plugin \'{name}\' was selected but did not return any written paths.", deferred=True, name=writer_name, ) ) else: warnings.warn( trans._( 'No data written! A plugin could not be found to write these {length} layers to {path}.', deferred=True, length=len(layers), path=path, ) ) return written def _is_null_layer_sentinel(layer_data: Any) -> bool: """Checks if the layer data returned from a reader function indicates an empty file. The sentinel value used for this is ``[(None,)]``. Parameters ---------- layer_data : LayerData The layer data returned from a reader function to check Returns ------- bool True, if the layer_data indicates an empty file, False otherwise """ return ( isinstance(layer_data, list) and len(layer_data) == 1 and isinstance(layer_data[0], tuple) and len(layer_data[0]) == 1 and layer_data[0][0] is None ) def _write_multiple_layers_with_plugins( path: str, layers: List[Layer], *, plugin_name: Optional[str] = None, _writer: Optional[WriterContribution] = None, ) -> Tuple[List[str], str]: """Write data from multiple layers data with a plugin. If a ``plugin_name`` is not provided we loop through plugins to find the first one that knows how to handle the combination of layers and is able to write the file. If no plugins offer ``napari_get_writer`` for that combination of layers then the default ``napari_get_writer`` will create a folder and call ``napari_write_`` for each layer using the ``layer.name`` variable to modify the path such that the layers are written to unique files in the folder. If a ``plugin_name`` is provided, then call ``napari_get_writer`` for that plugin. If it doesn’t return a ``WriterFunction`` we error, otherwise we call it and if that fails if it we error. Exceptions will be caught and stored as PluginErrors (in plugins.exceptions.PLUGIN_ERRORS) Parameters ---------- path : str The path (file, directory, url) to write. layers : List of napari.layers.Layer List of napari layers to write. plugin_name : str, optional If provided, force the plugin manager to use the ``napari_get_writer`` from the requested ``plugin_name``. If none is available, or if it is incapable of handling the layers, this function will fail. Returns ------- (written paths, writer name) as Tuple[List[str],str] written paths: List[str] Empty list when no plugin was found, otherwise a list of file paths, if any, that were written. writer name: str Name of the plugin selected to write the data. """ # Try to use NPE2 first written_paths, writer_name = _npe2.write_layers( path, layers, plugin_name, _writer ) if written_paths or writer_name: return (written_paths, writer_name) logger.debug("Falling back to original plugin engine.") layer_data = [layer.as_layer_data_tuple() for layer in layers] layer_types = [ld[2] for ld in layer_data] if not plugin_name and isinstance(path, (str, pathlib.Path)): extension = os.path.splitext(path)[-1] plugin_name = plugin_manager.get_writer_for_extension(extension) hook_caller = plugin_manager.hook.napari_get_writer path = abspath_or_url(path) logger.debug("Writing to %s. Hook caller: %s", path, hook_caller) if plugin_name: # if plugin has been specified we just directly call napari_get_writer # with that plugin_name. if plugin_name not in plugin_manager.plugins: names = {i.plugin_name for i in hook_caller.get_hookimpls()} raise ValueError( trans._( "There is no registered plugin named '{plugin_name}'.\nNames of plugins offering writers are: {names}", deferred=True, plugin_name=plugin_name, names=names, ) ) implementation = hook_caller.get_plugin_implementation(plugin_name) writer_function = hook_caller( _plugin=plugin_name, path=path, layer_types=layer_types ) else: result = hook_caller.call_with_result_obj( path=path, layer_types=layer_types, _return_impl=True ) writer_function = result.result implementation = result.implementation if not callable(writer_function): if plugin_name: msg = trans._( 'Requested plugin "{plugin_name}" is not capable of writing this combination of layer types: {layer_types}', deferred=True, plugin_name=plugin_name, layer_types=layer_types, ) else: msg = trans._( 'Unable to find plugin capable of writing this combination of layer types: {layer_types}', deferred=True, layer_types=layer_types, ) raise ValueError(msg) try: return ( writer_function(abspath_or_url(path), layer_data), implementation.plugin_name, ) except Exception as exc: # noqa: BLE001 raise PluginCallError(implementation, cause=exc) from exc def _write_single_layer_with_plugins( path: str, layer: Layer, *, plugin_name: Optional[str] = None, _writer: Optional[WriterContribution] = None, ) -> Tuple[Optional[str], str]: """Write single layer data with a plugin. If ``plugin_name`` is not provided then we just directly call ``plugin_manager.hook.napari_write_()`` which will loop through implementations and stop when the first one returns a non-None result. The order in which implementations are called can be changed with the implementation sorter/disabler. If ``plugin_name`` is provided, then we call the ``napari_write_`` for that plugin, and if it fails we error. Exceptions will be caught and stored as PluginErrors (in plugins.exceptions.PLUGIN_ERRORS) Parameters ---------- path : str The path (file, directory, url) to write. layer : napari.layers.Layer Layer to be written out. plugin_name : str, optional Name of the plugin to write data with. If None then all plugins corresponding to appropriate hook specification will be looped through to find the first one that can write the data. Returns ------- (written path, writer name) as Tuple[List[str],str] written path: Optional[str] If data is successfully written, return the ``path`` that was written. Otherwise, if nothing was done, return ``None``. writer name: str Name of the plugin selected to write the data. """ # Try to use NPE2 first written_paths, writer_name = _npe2.write_layers( path, [layer], plugin_name, _writer ) if writer_name: return (written_paths[0], writer_name) logger.debug("Falling back to original plugin engine.") hook_caller = getattr( plugin_manager.hook, f'napari_write_{layer._type_string}' ) if not plugin_name and isinstance(path, (str, pathlib.Path)): extension = os.path.splitext(path)[-1] plugin_name = plugin_manager.get_writer_for_extension(extension) logger.debug("Writing to %s. Hook caller: %s", path, hook_caller) if plugin_name and (plugin_name not in plugin_manager.plugins): names = {i.plugin_name for i in hook_caller.get_hookimpls()} raise ValueError( trans._( "There is no registered plugin named '{plugin_name}'.\nPlugins capable of writing layer._type_string layers are: {names}", deferred=True, plugin_name=plugin_name, names=names, ) ) # Call the hook_caller written_path = hook_caller( _plugin=plugin_name, path=abspath_or_url(path), data=layer.data, meta=layer._get_state(), ) # type: Optional[str] return (written_path, plugin_name or '') napari-0.5.0a1/napari/plugins/pypi.py000066400000000000000000000055531437041365600175170ustar00rootroot00000000000000""" These convenience functions will be useful for searching pypi for packages that match the plugin naming convention, and retrieving related metadata. """ import json from concurrent.futures import ThreadPoolExecutor from functools import lru_cache from typing import Dict, Iterator, List, Optional, Tuple, TypedDict, cast from urllib.request import Request, urlopen from npe2 import PackageMetadata from napari.plugins.utils import normalized_name PyPIname = str @lru_cache def _user_agent() -> str: """Return a user agent string for use in http requests.""" import platform from napari import __version__ from napari.utils import misc if misc.running_as_bundled_app(): env = 'briefcase' elif misc.running_as_constructor_app(): env = 'constructor' elif misc.in_jupyter(): env = 'jupyter' elif misc.in_ipython(): env = 'ipython' else: env = 'python' parts = [ ('napari', __version__), ('runtime', env), (platform.python_implementation(), platform.python_version()), (platform.system(), platform.release()), ] return ' '.join(f'{k}/{v}' for k, v in parts) class SummaryDict(TypedDict): """Objects returned at https://npe2api.vercel.app/api/summary .""" name: PyPIname version: str display_name: str summary: str author: str license: str home_page: str @lru_cache def pypi_plugin_summaries() -> List[SummaryDict]: """Return PackageMetadata object for all known napari plugins.""" url = "https://npe2api.vercel.app/api/summary" with urlopen(Request(url, headers={'User-Agent': _user_agent()})) as resp: return json.load(resp) @lru_cache def conda_map() -> Dict[PyPIname, Optional[str]]: """Return map of PyPI package name to conda_channel/package_name ().""" url = "https://npe2api.vercel.app/api/conda" with urlopen(Request(url, headers={'User-Agent': _user_agent()})) as resp: return json.load(resp) def iter_napari_plugin_info() -> Iterator[Tuple[PackageMetadata, bool]]: """Iterator of tuples of ProjectInfo, Conda availability for all napari plugins.""" with ThreadPoolExecutor() as executor: data = executor.submit(pypi_plugin_summaries) _conda = executor.submit(conda_map) conda = _conda.result() for info in data.result(): _info = cast(Dict[str, str], dict(info)) # TODO: use this better. # this would require changing the api that qt_plugin_dialog expects to # receive (and it doesn't currently receive this from the hub API) _info.pop("display_name", None) # TODO: I'd prefer we didn't normalize the name here, but it's needed for # parity with the hub api. change this later. name = _info.pop("name") meta = PackageMetadata(name=normalized_name(name), **_info) yield meta, (name in conda) napari-0.5.0a1/napari/plugins/utils.py000066400000000000000000000132761437041365600176770ustar00rootroot00000000000000import os import os.path as osp import re from enum import IntFlag from fnmatch import fnmatch from functools import lru_cache from pathlib import Path from typing import Dict, Iterable, List, Optional, Set, Tuple, Union from npe2 import PluginManifest from napari.plugins import _npe2, plugin_manager from napari.settings import get_settings class MatchFlag(IntFlag): NONE = 0 SET = 1 ANY = 2 STAR = 4 @lru_cache def score_specificity(pattern: str) -> Tuple[bool, int, List[MatchFlag]]: """Score an fnmatch pattern, with higher specificities having lower scores. Absolute paths have highest specificity, followed by paths with the most nesting, then by path segments with the least ambiguity. Parameters ---------- pattern : str Pattern to score. Returns ------- relpath : boolean Whether the path is relative or absolute. nestedness : negative int Level of nestedness of the path, lower is deeper. score : List[MatchFlag] Path segments scored by ambiguity, higher score is higher ambiguity. """ pattern = osp.normpath(pattern) segments = pattern.split(osp.sep) score: List[MatchFlag] = [] ends_with_star = False def add(match_flag): score[-1] |= match_flag # built-in fnmatch does not allow you to escape meta-characters # so we don't need to handle them :) for segment in segments: # collapse foo/*/*/*.bar or foo*/*.bar but not foo*bar/*.baz if segment and not (ends_with_star and segment.startswith('*')): score.append(MatchFlag.NONE) if '*' in segment: add(MatchFlag.STAR) if '?' in segment: add(MatchFlag.ANY) if '[' in segment and ']' in segment[segment.index('[') :]: add(MatchFlag.SET) ends_with_star = segment.endswith('*') return not osp.isabs(pattern), 1 - len(score), score def _get_preferred_readers(path: str) -> Iterable[Tuple[str, str]]: """Given filepath, find matching readers from preferences. Parameters ---------- path : str Path of the file. Returns ------- filtered_preferences : Iterable[Tuple[str, str]] Filtered patterns and their corresponding readers. """ if osp.isdir(path): if not path.endswith(os.sep): path = path + os.sep reader_settings = get_settings().plugins.extension2reader return filter(lambda kv: fnmatch(path, kv[0]), reader_settings.items()) def get_preferred_reader(path: str) -> Optional[str]: """Given filepath, find the best matching reader from the preferences. Parameters ---------- path : str Path of the file. Returns ------- reader : str or None Best matching reader, if found. """ readers = sorted( _get_preferred_readers(path), key=lambda kv: score_specificity(kv[0]) ) if readers: preferred = readers[0] _, reader = preferred return reader return None def get_potential_readers(filename: str) -> Dict[str, str]: """Given filename, returns all readers that may read the file. Original plugin engine readers are checked based on returning a function from `napari_get_reader`. Npe2 readers are iterated based on file extension and accepting directories. Returns ------- Dict[str, str] dictionary of registered name to display_name """ readers = {} hook_caller = plugin_manager.hook.napari_get_reader # lower case file extension ext = str(Path(filename).suffix).lower() filename = str(Path(filename).with_suffix(ext)) for impl in hook_caller.get_hookimpls(): reader = hook_caller._call_plugin(impl.plugin_name, path=filename) if callable(reader): readers[impl.plugin_name] = impl.plugin_name readers.update(_npe2.get_readers(filename)) return readers def get_all_readers() -> Tuple[Dict[str, str], Dict[str, str]]: """ Return a dict of all npe2 readers and one of all npe1 readers Can be removed once npe2 shim is activated. """ npe2_readers = _npe2.get_readers() npe1_readers = {} for spec, hook_caller in plugin_manager.hooks.items(): if spec == 'napari_get_reader': potential_readers = hook_caller.get_hookimpls() for get_reader in potential_readers: npe1_readers[get_reader.plugin_name] = get_reader.plugin_name return npe2_readers, npe1_readers def normalized_name(name: str) -> str: """ Normalize a plugin name by replacing underscores and dots by dashes and lower casing it. """ return re.sub(r"[-_.]+", "-", name).lower() def get_filename_patterns_for_reader(plugin_name: str): """Return recognized filename patterns, if any, for a given plugin. Where a plugin provides multiple readers it will return a set of all recognized filename patterns. Parameters ---------- plugin_name : str name of plugin to find filename patterns for Returns ------- set set of filename patterns accepted by all plugin's reader contributions """ all_fn_patterns: Set[str] = set() current_plugin: Union[PluginManifest, None] = None for manifest in _npe2.iter_manifests(): if manifest.name == plugin_name: current_plugin = manifest if current_plugin: readers = current_plugin.contributions.readers or [] for reader in readers: all_fn_patterns = all_fn_patterns.union( set(reader.filename_patterns) ) # npe1 plugins else: _, npe1_readers = get_all_readers() if plugin_name in npe1_readers: all_fn_patterns = {'*'} return all_fn_patterns napari-0.5.0a1/napari/qt/000077500000000000000000000000001437041365600151175ustar00rootroot00000000000000napari-0.5.0a1/napari/qt/__init__.py000066400000000000000000000011351437041365600172300ustar00rootroot00000000000000from napari._qt.qt_event_loop import get_app, run from napari._qt.qt_main_window import Window from napari._qt.qt_resources import get_current_stylesheet, get_stylesheet from napari._qt.qt_viewer import QtViewer from napari._qt.widgets.qt_tooltip import QtToolTipLabel from napari._qt.widgets.qt_viewer_buttons import QtViewerButtons from napari.qt.threading import create_worker, thread_worker __all__ = ( 'create_worker', 'QtToolTipLabel', 'QtViewer', 'QtViewerButtons', 'thread_worker', 'Window', 'get_app', 'get_stylesheet', 'get_current_stylesheet', 'run', ) napari-0.5.0a1/napari/qt/progress.py000066400000000000000000000005471437041365600173430ustar00rootroot00000000000000import warnings from napari.utils.translations import trans warnings.warn( trans._( 'progress has moved from qt since 0.4.11. Use `from napari.utils import progress` instead', deferred=True, ), category=FutureWarning, stacklevel=3, ) from napari.utils import progrange, progress # noqa __all__ = ('progress', 'progrange') napari-0.5.0a1/napari/qt/threading.py000066400000000000000000000007171437041365600174430ustar00rootroot00000000000000from superqt.utils._qthreading import ( GeneratorWorkerSignals, WorkerBase, WorkerBaseSignals, ) from napari._qt.qthreading import ( FunctionWorker, GeneratorWorker, create_worker, thread_worker, ) # all of these might be used by an end-user when subclassing __all__ = ( 'create_worker', 'FunctionWorker', 'GeneratorWorker', 'GeneratorWorkerSignals', 'thread_worker', 'WorkerBase', 'WorkerBaseSignals', ) napari-0.5.0a1/napari/resources/000077500000000000000000000000001437041365600165055ustar00rootroot00000000000000napari-0.5.0a1/napari/resources/__init__.py000066400000000000000000000002641437041365600206200ustar00rootroot00000000000000from napari.resources._icons import ( ICON_PATH, ICONS, get_colorized_svg, get_icon_path, ) __all__ = ['get_colorized_svg', 'get_icon_path', 'ICON_PATH', 'ICONS'] napari-0.5.0a1/napari/resources/_icons.py000066400000000000000000000135761437041365600203450ustar00rootroot00000000000000import re from functools import lru_cache from itertools import product from pathlib import Path from typing import Dict, Iterable, Iterator, Optional, Tuple, Union from napari.utils._appdirs import user_cache_dir from napari.utils.translations import trans ICON_PATH = (Path(__file__).parent / 'icons').resolve() ICONS = {x.stem: str(x) for x in ICON_PATH.iterdir() if x.suffix == '.svg'} PLUGIN_FILE_NAME = "plugin.txt" def get_icon_path(name: str) -> str: """Return path to an SVG in the theme icons.""" if name not in ICONS: raise ValueError( trans._( "unrecognized icon name: {name!r}. Known names: {icons}", deferred=True, name=name, icons=set(ICONS), ) ) return ICONS[name] svg_elem = re.compile(r'(]*>)') svg_style = """""" @lru_cache def get_raw_svg(path: str) -> str: """Get and cache SVG XML.""" return Path(path).read_text() @lru_cache def get_colorized_svg( path_or_xml: Union[str, Path], color: str = None, opacity=1 ) -> str: """Return a colorized version of the SVG XML at ``path``. Raises ------ ValueError If the path exists but does not contain valid SVG data. """ path_or_xml = str(path_or_xml) xml = path_or_xml if '' in path_or_xml else get_raw_svg(path_or_xml) if not color: return xml if not svg_elem.search(xml): raise ValueError( trans._( "Could not detect svg tag in {path_or_xml!r}", deferred=True, path_or_xml=path_or_xml, ) ) # use regex to find the svg tag and insert css right after # (the '\\1' syntax includes the matched tag in the output) return svg_elem.sub(f'\\1{svg_style.format(color, opacity)}', xml) def generate_colorized_svgs( svg_paths: Iterable[Union[str, Path]], colors: Iterable[Union[str, Tuple[str, str]]], opacities: Iterable[float] = (1.0,), theme_override: Optional[Dict[str, str]] = None, ) -> Iterator[Tuple[str, str]]: """Helper function to generate colorized SVGs. This is a generator that yields tuples of ``(alias, icon_xml)`` for every combination (Cartesian product) of `svg_path`, `color`, and `opacity` provided. It can be used as input to :func:`_temporary_qrc_file`. Parameters ---------- svg_paths : Iterable[Union[str, Path]] An iterable of paths to svg files colors : Iterable[Union[str, Tuple[str, str]]] An iterable of colors. Every icon will be generated in every color. If a `color` item is a string, it should be valid svg color style. Items may also be a 2-tuple of strings, in which case the first item should be an available theme name (:func:`~napari.utils.theme.available_themes`), and the second item should be a key in the theme (:func:`~napari.utils.theme.get_theme`), opacities : Iterable[float], optional An iterable of opacities to generate, by default (1.0,) Opacities less than one can be accessed in qss with the opacity as a percentage suffix, e.g.: ``my_svg_50.svg`` for opacity 0.5. theme_override : Optional[Dict[str, str]], optional When one of the `colors` is a theme ``(name, key)`` tuple, `theme_override` may be used to override the `key` for a specific icon name in `svg_paths`. For example ``{'exclamation': 'warning'}``, would use the theme "warning" color for any icon named "exclamation.svg" by default `None` Yields ------ (alias, xml) : Iterator[Tuple[str, str]] `alias` is the name that will used to access the icon in the Qt Resource system (such as QSS), and `xml` is the *raw* colorzied SVG text (as read from a file, perhaps pre-colored using one of the below functions). """ # mapping of svg_stem to theme_key theme_override = theme_override or {} ALIAS_T = '{color}/{svg_stem}{opacity}.svg' for color, path, op in product(colors, svg_paths, opacities): clrkey = color svg_stem = Path(path).stem if isinstance(color, tuple): from napari.utils.theme import get_theme clrkey, theme_key = color theme_key = theme_override.get(svg_stem, theme_key) color = getattr(get_theme(clrkey, False), theme_key).as_hex() # convert color to string to fit get_colorized_svg signature op_key = "" if op == 1 else f"_{op * 100:.0f}" alias = ALIAS_T.format(color=clrkey, svg_stem=svg_stem, opacity=op_key) yield alias, get_colorized_svg(path, color, op) def write_colorized_svgs( dest: Union[str, Path], svg_paths: Iterable[Union[str, Path]], colors: Iterable[Union[str, Tuple[str, str]]], opacities: Iterable[float] = (1.0,), theme_override: Optional[Dict[str, str]] = None, ): dest = Path(dest) dest.mkdir(parents=True, exist_ok=True) svgs = generate_colorized_svgs( svg_paths=svg_paths, colors=colors, opacities=opacities, theme_override=theme_override, ) for alias, svg in svgs: (dest / Path(alias).name).write_text(svg) def _theme_path(theme_name: str) -> Path: return Path(user_cache_dir()) / '_themes' / theme_name def build_theme_svgs(theme_name: str, source) -> str: out = _theme_path(theme_name) write_colorized_svgs( out, svg_paths=ICONS.values(), colors=[(theme_name, 'icon')], opacities=(0.5, 1), theme_override={ 'warning': 'warning', 'error': 'error', 'logo_silhouette': 'background', }, ) with (out / PLUGIN_FILE_NAME).open('w') as f: f.write(source) return str(out) napari-0.5.0a1/napari/resources/icon.icns000066400000000000000000027201351437041365600203250ustar00rootroot00000000000000icns ]ic12PNG  IHDR@@iqsRGBhIDATx[[l]uI^KЋzZ˲ulqRvm)5Ph _(?-џ4EPN]'N];B:~Hlɦ,"EQ|}3K h:gfٳמ={sSf|Hww5w{oJ]-?nF$X.W/i>4m of2f7 &ʗK2 JA'H?M$L䩮oH]_e tlx hz^gCb!̞muZ0nKF3AIMsP Ir @1Ӛ&hʎ Tb坄OII:Ha;;Dkl~{(?-=ڜCr`LeaQd`lZCj+BHuDV="m[2Ӷ@bŴe\|jw&7M럀8r!Z΅A)8 w-EXbDXiTJ`%j-'ۚіRh5wcƀVjas@+? .Ihu~k C_Lt640h=.o,-Y-=:JP"r(CŬl)QyL֕/PW%E8z$vQ?XM"pNqu@-(Tv7lUkh֍@>>f=3iE˅5^Y _fZfo uӁt[ ;uߖ!e%[Z0,4F|ƒd zfvd۾1c+豍$o8WXOi{ϔ3w3/}h\\[+KmEx8ʻBF.ilCXns#)FMNi_{5Y{fsخz* D6Hju-ٯ=z-. ZqEa 9"Q/8yRK۶bm% v3p{?ydľ;lٺV'l:HPSw`Ombe_kAWdOI7^) Q166UrHWctf(,5,nk Vǚk_ݬWF 'X.Y Jy)؁8@M䮬֦\:v#B,Si=9Do<%J? EnyIeyX~FθfnhyʞlV2Ըae}:t6z'EBEdq 8-۶Aeǘ̡y16X!:v,nc@Pt[f|Q=ĖYV'2.V(s`ExKۢ#" #_ LJ֦a0t{!@>%BLhJ5$o(rBmMATJ؁ Kgdw!a)7ſwrFKvqrx t`/_7*9+@xwW^ԛ&ft n6Zvp[ ` Ou+pΜeGE6Sv\0` t,=Y敩CQYV<Ԣ] WEbNLXHxn)Aۗw{/!"yG{[}%=HtNnL`b?g?MJ1 \Oi˵,0y `3ȯom' Ν*\ܣ;ރ/=łЮ2y#ZӒ㉐)QF)hS4ig 8@`ĖW* xc]G^vCֲ >\~4@#:3 ^/ P&;~TzYA8Nj 6˕J]|;g?'4,)Cr 7Sc6մKm%9[lE2"! 91kZƮc uQ&DWl ĖZ\ǹaUS48A_GIcnC385].&><@Cr@JA~Q[\Z {+؃Svd޺svɊ =O S#J5kߝ)#{w[}le[^[!\u8th0q{K]{to Shw=@KP,ELJE;}׺V) H3yq0ѡ5mVu.zx>{zN"u. ;q[Gg ;HwWJ[~VR7We,>,dž ~  2qH23: 3/^feM{Ë!vWfkN"/Pe)@Ơex=cw5@eO}5ERdQZ^8w~oޖWvבCjw4zb #zuMKw ak80d[slȦF<DBvn-듅&8 `]yTK^zhu$W+T==l+;mi` OvX;l oqm~d6-pQX!S Cb;D;+H!8т4q'Xm>nX7Xk-ȍ=[B~yW!=`|<M/?~TO<(X13`Z3)QMi~h\u1`Ca/Xɼ2w]CtZfBe9x_lt.{/`pW7C<چQY`'A&"0?~&(&oƀnBFpR ;`^aie} ]%Av,k5ʙ%GWScoO/^;,NT(>R<cLzA·>+k7ih4?b!Zp:x>9 |<ٰa$Nj=o } JK@nak,0`9 muּmkN#_rsXni\,>>؅:'t1(82  Wb$wt _h&EE|0.Qzv m'P/B- (ҜgDn)2*\qe)UƠ#,7Q+\".wrS}25 `6ۗbF8`p3Zn)q)9yZ8l vhBQ]>2|CcyAr*,<.##y`/"nF뻋kr[$Xw6C1F0wzoHo9k"HyU9.-z7Cg f\TĤ RCu@@/ . D;;8z'>,D#jtuKq;x5ۨ een4Hԭq_ZB!Z+heF;vK+e7m1: &/Z4+g? 3VaZR7N,21.ca yќ˂KKv`)`R[Z~hY[nlw?aplD tPأę.{:}^=‡b㣣@ #o|>`m@:<z)}L mZg&3z2΍І:twj,hC,\Wߚ2'qhv4X3Aj6kaK%#PNppߙ4+܏hiLJHz.P~/|EOѿ^Zⴽ}Dk&&  .!rW84cd+DA5~ v(!"WZx `dN9n^ =#ԳO=~mcA)>[O :jy$5Q]뫮^b=gcC emFRjz4y^.s{o^+3ċpT %kew&+V_Ogx[*IiyX+8|٪S_Wly &}} I?;~T}hSjVydHHD(3N0M'Jl૿>`ڛ|y!,2Rc~]Yu[ؕUݸAFо);w#,Q̙ԇ ~(0 +\%Q4#!=a†r (/LQ;W^j/ҴpWz{_@ZpFU%o{''ΛZ1g pbU/*N>^J8J0]@@X>g;!?f0(юQ|$3GSK~5& ^ \,i swzgu,[ 6hL>0!_gnǀAQҭ2 #ّRK/1(@YEߦ va5aCȧx ,?OEި LrD0FgEoR!Kh㚆ahGQI9xGlgpУ+IW; ^A"+{EnSz;  <Lo(u*܍BNhNAuh >IE9#/)SC{W_LY33tMk_ь:nb_tQ4]p];P?Z ,cKvjgA߆f<>8mpLIv xM9lTnVD?cj!2ٳg '1!OP8RiSҦ}F{;vn'nʗl揰O@AƓ)&k|r2oQOv[=,3IENDB`ic07JPNG  IHDR>asRGB@IDATx}igGu_}}_I3Ҍv a0`cq;T5OTUTQ861`-Hv1#i}}of9}ߌTJ޽}>}}@Z)|pr) ޘ&W"}Sf+) z7ߜ@bvJK6<}y I6MN.?f>usۚ8u{~c9 崼 =?H;vz( v_`*լW?k^uL Ãi+=D4߂IVEe]6U55gv^na֬Eg!]>3ǴԂ:08~HI۰uOiK;6H kE9K8˲!:^O8L1YN!r~֚ fBw#W#EQNL^&񰅊ijHeqՙiv~E2pzh`G:;L_o;ic6-AUt )) mvqQlrZ̹r Di3KgHW;t)UMz# Rv ( ][vBJMr(ۄо5g@p͉"V^58{-7O@Z ##& [(1j#R@ZNkɸzZlzX~tN;g۶ "7NZZ^JcjLA#I>t}UKxݴ0%QKUuQnJ֠Nmdp tZjuC&$.YZlVaˤ6c)\sY4Y{1jѻvK`BYNbkuF6鴉U!fH>hPƱӅ-Q:@Gvꅴ)rT$,-Qvv R橿ȸ+$nL]MrPV*+aokID-Ǚ Td @J3o)_7c }U4N̉d(2m d=,J;_z庴P#|m:@.HQHx[ՂcKؒ)3HRKiWrfq% i \p}248A⚿݋dcj}d#_INM@5g^lLc}&iE&Gb.N_J3˘A5sX\` ո1>2(K׿#=ew&!b{S: ܚ;[5ٵh:T*b̠UL˕i2Ë Z^Pl tʥt⹴2xpZ".avǥΩSixh8^*\rѭCtn(oŝ Cq8/VLR1X-`-ƅ+v' 2!myb6i2ͤgNyL9EcpŅtE"׬KVqZX%HiТҨkMd0Ylr6ޜ)\PWr'G?*]tz[yj6<=z&,s352`/\HҎ(fLZuE^lqeW+>Me(& t3eVMb#7' Na}M]={t׭xs.14;N~X:t"VCD+*H#zWZp$L6c˪_#ǴN?8_N-%>ᲁ,X78nNw Oc*& ko}WOJO~(N^Οm!-SIg>h k2b BrG=4+f-X:Gz8R9<+!F x:( vf6iI}7CՑxy*QHEmoXҕ+&JjNlpkAbeC Q+*[7h;SG:26^ygΧi,"QvM<˞tq3|ܜMdsʷXp.hէђK'F%"޾-K\EwV ]PA:V3D8ŒUV}/ گlX P {^?}UZo`*9. i%%|7 $#KZx`Te}|{v]4o,`nn^w^HmT)]Ž%HcB. nа[4):FrK !Aew ߒlq~64X[N$)-/Z. %3ϘQ)6x}n\۲a,:-ĕBدY*!0?Z6c X䄔ӴGxlF}-v`)ɝoіH8 ͂┝ ]='." b\cq?AL'CF!`juuf23;k U*~Ub8ҌWغ`6<+?b3(wT0s:сa'~?y4N^Gݝ8 ǂ$tl`H\Fhy;u=7zPٹl\ = qFnMX*ԏ c,{pҴT@̛ʄVwwvڠk9( dAw͙'^ N[,"LWlݴ2B ./L ;͠+MWRpE \NX2 95]W:?jCXN02\3waF+o)@=5PFC44-QIi;.;#7cZu%O"IHB,oWP/VkSJ5AgP73ljyל grӦ inĝ鋫}ԃu!>X}BHl43Xyib|(a!p9<@2>XC&R(^MBF@?^d$K/iɢ; . UPixʆ:-3W4wotaa(]1G:c]%tso|-XҴfUq6etpDW:nl8 ).>NA2 [tŖG3oG L'7D;\z\=Kߐ LP[qI/`t8c!l:yeγKvɇH6inBj+>bA *,VU0ZVU ]{88ڙg*ZH zP="jT M{@ fYüU;w5acQ1S14I8d e\8t@qDh /7ol\JeU,ֱ_ڌ}eg$a@(Vͺk|C@h`ʼਓNVLb+1hu,G3GXI Sua\PY'uC C1W <4I!.ۘzĶ;Qͅ&k=zP }f+0j4e>rOP7o0Np;1 PZS;$Br$`Qx}v%?Zu^9 ŜGT9 yT0%oഀih V dCL oš=J[ q VTQENQ6l7a27>vI\w%WҝqL>laep9ziPbrGa#-p\t#:%n ]L4G'ǫ'[E+%H`\AP ~Nh$wV*/pc8nĝ7& SQhǝ@tf "9LHM=_}-zڵ6qf`Kb2 ^{):0,j;W{V,PsSa9WG≧oϩY`C: *ef Ē(A:(p Ef@)uZv\Dx-1ˉBjlLBеYTLw!3,]oHJw@nd9@S^0ӫ$ASusk'6 CDX 3|up.ku `>`ۆSA=zH6 mINݸݐHm9ze޾ y_ 8ZqP LoQlEG5I:BN%cㆍl C0۽lWmYw ` r*' ( ^꫗L:SPM*(WRjB">Mr0.c ,7廉`K: ɨ 6 &})O#~TԂH˦:yGĥm&նNJ/P^fBj\d= Pe-aFuw?6ʘu cM0}j'w9 m ]/`$dֹq[g<#|z]3,]BOar!8Lu`TQ6*u#X WPYTU;9QF4unM$:Wa<\٦ӾI'28Bae܍$`s{ p4 aBmC'6YICZ&kkt;@=t@8]\`KsZ<ɻxA@1PVzو'ETyrv)h_LYFڲ~oU0Oa8ss kӦ 0#y/o#4 ;y O uKGJ?4Lۡ U<#}Ϛ%ՙtt9?CpZrTHL-tVY*P.V/$lgiQ J:@.(17ly_::nw pj3)ts0a&]^(RzZNE,V'#) SxP8}LUrHu={ĎAS,uR5}ઞAc>*/8A$ g"#;M8DQooޗ^9p.~J \xxffV[D}^Ӵѝ1uSڼn]Y 4qZlYkPCkl ((#eMBM1=q䖲7Ɍpd6m_>A#8 lI\$fMrN 6"ԶMW a$aqyg)ݔm0{i׶4^']ϕ]4+I 9|f¥tY eDcF&QcGN{58 P2k-yqB&܇16ZN#9 c];X.#L'ʤ AϜ3Y<7~'ir)M2x=L,ݚӗC[5#؏?xwSYu؄49Uߏ\|1,[h$U:G,lb&8Z.?}xMw@1<dTwyxQ.$`\; cG?i6el;3gY•t(\KԆ'&ry'q|n9T%^F`M35=^mHy{ \N9c!h )GBY`*߄iAC/_x6e:sܓvn)ǗZ*nD jsY5Gkr;z T9 K5i#&AL)?gj \gtoq:'U6aL_ Pt%t}*#D"i '[!@QHF i4p\pش #4N" ~Si58Lxݴq9  u{5}%tz4V '::/򓉴̍\eAӰf,[owucɴgG1DZM  :8mdsD5WnHܵ5ݳ)||g.\L6o=-KVC >rTgAJkܦ1)`]Q EIpVpd3x5R}M7mZ$&W_4n!QN1S$hr z. ZIݡsDFPdY }rR󐳈xG&$֤p4ɐa@πG4g/FWqkugKH@*rc N%e|ߒ9ŬUмU=&]RΫ_GO<[}!1=w9@:uJ捚=XrzNY'QF &+rɓ+QlD;` .t0qqfǚa&Eݦ|xObF>OՎ`i q݅cydi._9֜|-;q/ߑOpVf HVe=dopͷK) )FE? r8@ cNjIT|9\WOPVBDO'. Lc0 vmr'_ L\gsfp"63~WɝH\SS.XLrT칡4;4fNj4Mվׯ_=?zk#P @sYf.tYٲG2b<Rf^1Sd*ܔXfO O"*9KiCRr-HKdk, TRMP^aɺ;P0N\: ;DZWH/v: ~5%SGRj xi hl|P t3hr![O>2hdȃJlPH>`&âSe3]#"Sm,v%jL6nszsFBQ[/ t5 ac'tKg?‹Ð}P5QkwBҔ0'Kdp!܀{27#Pn& <<ΟYC>{ʹ}/O&|Y.h(-rCY aS9:Ѫ̀;f]Ġ,,V JVWLUnʼݶ7͠Nlqx?7crP C(i@q҇Ge<`Z9B UiY5:0`0ʊ^$pښ a:4ʋ&]zDUl/ru6WbTNF*$" π`S7vWpW%>`'dqpHFyPz,{ p$M%Os:N}(7Y}poSvɀYX :k qT0MlT5x#pf \Dؕ)vœ]h]ͧk@-3HH}!85g_!.'アrLVFHUJ菺d*;9^F}rG1.'hj8CuҒrղ/`իi@wJXEԴ#pi)e0:= 4XÉo1NOw! (~oxYP8 L6Quw%WpWQ"a`cTg'ZO3Yb+; }qbpVިl<4V\3G~DP33(Qzv֫B^N E^+l7rdX* 4r;~744ztZfE֧_+0L`CIQ}Q]xL9X3j耂bM@&9REg̖~ oiZRgha'E4E  c;A>U>Cra2f*z׭U>/e܈B+Bi:Y{Ulsh S53Y-#pbF"8V"N 6 刉oϭIF]LXY-aU-Ś eNbଋRbУlP-Iw$-<Ʊ.p0q{a`uƅXQSGTTuh@plG24g5/SU +y6ɍ) rCL ;5t(Xׅ"o AJO yy:i:>iّ "RECi)0&m6NM,:)-hcQbx:6gpzڟ 66 f֢ɦ؜'QۼCș>JY7fv`p~<DY!1#30*۹:{*E+WęUʽ7:Xѭ3״MQEHCC>44q0~+SB7|-"̛8pdE  -<4FBX"V3Dn{U1Z#Y.>_3A5m_!p/´|Y$^V>SPl!wpg:R!72H9ayGP}ǧSE; 0501s3 7/Y(؞/0!(ydlX{חH8;xXSR~8&\T W#o&sO$s9r.隷dQN磸8d@Č`f+ A^Qz/"PuzS0!d"qs؁'#Vr|@7F9ršOY8r\;eX6sg:Wel9q:,8*aHEcz GZFY?*K?RƗ2Lfkg{#'y:@։X.8YG$ïhQK8AbDugD!eN=o?{XApS5ʸt" ̺((C!r`8y9VSw (xx2g ar8Ͳpr,4Ex<~8u+7`mLJJ&B*'&oīV`$a{K.;XX9&B#S^ F ;x@P ,Yڸn \3"9͜a44,r<#N9xc/$Cr,Plf eG}]{K+eRtvb0i29̀GHD~nhf0^gvGy̑“V8'@]7rp`yH^LxҌ<;LJ;T'erRqjT2@@.%NfSzAgRP.kI&s k7. w3;B؋hK, `@G:0/q>$bBώsvQBV&.-/ui;K4f*P'~JuJ:{vRW n:s 9rc!g -J :B)X<4 ĴH]O8^?]>DqUt>e9 ! i|g5Ĉ2)r|WkSoSm ˼60tesi=K/Mkx"]]lT0WatދaNӣ`tg}tˡѪ0t,B'?dnS{T0>`O|E(·wbD,{l`Vf].Qs3ó3 ]ku~PiĉW71yk؎C*F/v9F%iδվBgT=ElaFܡ,xПB|`u ( Q& 4}.-Ɓi2*޹vmTa5ol|"'-Ga̒ģ ޸a0a?7M_Jko-|ڮNk>4IYJ|y@<`a G;6[] bZo5u{dzm >!0^$$j]ㅡG1+4J !TG8X#J9`DiaIDJH^[hn{rgh+O 3i~n D=7܈WxW&Fw P\ls;GG? 08WAd]t!CqEƛzԱk/InXN ޾DnG9hj;<8;s_3`e,^Kx8)*M7}P`p MS<k0:Πe: 4H#r2 HGFкF</ Җ{cQ'^I/E oMk-`=™N[N(e,Ej8,rӼvt_ e/~)_=:?|7ڵ¾nWX3\RX ֭E~Ȉxl6B g930y`_#uy@(Ư,e|(mj3];s뎴w-0?7> a<(yD;)!g[DZ^29HSṴA$9t9`7WFfP髇g=r<$]yFBpfVBШ@ݬAN, x(8(m"0!kim'&3˥v<hQߋ+ҏ=߻jZ `DXdLkXs֜gRv_وgMvPh~*m>l‡h>y,/>OkBbw ]1nԏL}iŮ 3͙p$gn,@qpWxjG'^u;;zAv3_~SmogەyFe{jGb܍qV;_ONOuZuɦ|ʘJRјوl8?OBX_/L1@sjDZ@[HA@:W^5ym. dmߍ2Y^{pW x۳zA˒#N** O;݉§hL&_<>u'?v^+[mմi|!45PrU݇3l3 |R5Cy/:CM.%% A/CqtS<~kY4nM ;vy⪶Scx7%qltᐶh(U,NɅˀ7;-QCӗF:w᪦05xJ891nVu򱛽J @pt';p@f ʓ 5X+I[s-1 1H7s zAQH `ai[TBR,P6DaAy|rYuQlچ4 W%uB!x@yp[^>9ⷊ#3 /IDAT/ϭNc q&=eZ&4 \V $B:!jwI0G)rM݂KK~ aBEbVaos"z4#Ioj=Ok_*oLhGK4dc~ jknDe f_={~ʘڎ'W7(ԋv8QL@m> NM@+N5cZ)嘚Q䴩c@C= >(G(>8KZZ;D-f~耀`4 @)t ; ut]szo^$#﷿{(=ԛ\U x eĜ &eMD .q?O۽9ϧ|ό^/;C[:ڴ&O DHS)؏h:g8-c.zo E8jcF83UA`0u=2ȃ >9T;5H)/K?>=|;ӖtȔY "8"EDr+[>@'AW@GFXHT3a"B`[k҃Bb5УKSSpV djɸ~?<3v5LM;ށ7nOǰ?m! @I!pw"%«]YgH$+tOд5%MRC9g6uq긩>%Zj(}͈ $$YP ̯M_ҷ$V leY)_= <ڙv؁/BڃvE*֛@FEC%}EyE!@ux%~YDeB?7:.19 P6 |vVh̃JsD:%3 k `S .lM#Ǣl |$:v?puwf1c&)0Z"<^͜u-u>b+Þ ^ $Q3] ^v`ɵBՇd)Ԍu!A ANal &D. @'Cf 4BMo]t~S 4 B IIϨʀ &2k͝p]`mPmI?p5/m⓫x?xś1 F>^i| ?C'Qtۦ}"}M(@څ )* =^ꚤ3nCj͢2B>$G7i|ASӭ-YG$b.!kQh3@Id>BiL .Br?ea@]9P{#b4@2ZQB n/%C 0*IguS i]F =r%-KT:5b%Hp~rH+~b\tȽDAk⍗| c&UXhB0!SY!'9sq>B!>~i rE}ZhsS_Di -'6|u }K2nOttk&0*i2l|uȝh8bz&$->ªp;ᙗ"3V(' ^#}N0Y2tP1yk$ph8>~ۘܖaM\w_}G%=|{4 >LDkRIR^y0!xy8pGX\ipă^e~S3g8d]e Zy];'- ݲ,Jp*P3]P1ς<lN\>( ~U1J K@Ns$mnyOo} 8ף7 p 2:Ϻˋ9yoԽ ";6uuW-,w.[o]zo߿aAZ^ذkq{o+( `h-c{nolio]v{~y}}[|C*0~̳NL{s҃x0ސțrqn_n߻zלAGau/]A>@A &Ϯoc{Ƥ؃׬Zc{cͶhz/<ߍ0h-~Z/sSS Q;;2MA wȯN=N>lLZMZشoQv>n=K= eƭAk<*; 4S}_rblr?lS<4p;u(a3lg`-^FщHneծF5"@ x*y7.Ew88;>6k%G]cc%UD6LbF{m?sV(~ h#9Dg^HwIdbgE[bjl4MNңOm~2:.c%2 e:Qf0a1lKdlц臍1B67eO%/ RhU݀h!)ɬ1 {I# F+%'"~|:eK^Y&9}}:'&l]/4yUd1QT$DT%h2}Yb &њK>|i8 Q6EY8Fa[Oc4wowOL 6M@S]'cXhHd WL7eJ3Rchs#]RZa܋Zfl[rưeޫ{|| `q+1)-%‰9ֽȍ<*-%Lk&&k.Ȁ?*ECX lgYM9OUGy`~,̼#.\d .6;tPob#M>U ^д5P?X'~Mm1@"h(QC0PB*S ͌T (*>3s w9n.KeIױAÞxў-&jKy%# x/uLH:DEbXvi^ ~GMYMS$^,&d<oGD C[ثnymmlˇ0Y_>oR6'[ N xrrjM{sXf%'{47cTfJ^^*C˙0j7lV?*1Fi`] ki U nޣvn,e"x^ N7hgolb}-CȏNOOnnfMᣫSZd[hZt4^mf,Gy͚ yGQZ-Ɣ=`Jdy"1]\uCF yz\}0{nukݼ}ӭ ZoAt}72o;nmmݍOy7?u v*uF RoQ|Z5hJ5*nk 47*TH!kar*ؼe4I#gz;[ ]:-c6)(dMLt"hwzoj&,S^YV[Q.VY"HtO>q`\ڻWZ8嶸WNvIn%:M\~_ w-KyfrW]u7gf܉#Gxm-h-ݪ:7juS.+c~CU21jL'3j1_5Z@E(]a޸ⶶ値6?x)77܁YwQ7kLy;X*GT`ۃ#5.jF^uR9@=O֫]Reͦshz^O\lLU&ch˴c^bqni?} c-A}Y4F{}>|[nT+#YdGvdVkW,Mє8?*dYo[w3n~~ ?6f+I,eq;nye-lu[nGMکTn)[Xiϲ%itrIأ&,}'Ov_YUSeE4 Z7} 4R/5@i `jX *%z)-̮-t7n5975TkObw]↬gUZOImv s#M_z&UbVMHD Q[42|*״:9{ȽI Ic,=03N;5-o&~iE& =鎓mwuw? q7+H5y}BZ, }i;f!v +(lFN%)6 %/u$x 9\3ĩpAk6|tKnGiI u{{܂G5{q:3=: '^:-7]v. ~֌C3sг[U l `?JG6Gt6ѵ`WiZ2ː%.Ivڵ݇]\w_}{ǭmD+bGv=gzNg<~km6o\wθ|+޾ukf-6w޵C<=.cQi0<5=oo"8G\Xu\r;~7{bGu/?s"wErdg?I_}]o^c|;)>>3sxZ[Uڧb[E]#tG+*mߘڰ4&kaԊ}ͧ:Aj HBG lKn}ҠF֐vȬDb+rnp/}g^=Ch9 w{:f-o#IZjQMpgv݁7<"O>:_nLO\3+ xض;9΍oX8)5Vf\O?qĝ9?y}[EՃ 8sLLWDނU`E EyQ0[) bG\ڭv&բّj*眑k}'DG=L @v>U+ԗ@SHԉy)wߙ8>ྱ}]ڞƞ}eDOG \ޛtκWݳSJ-6a/=귮tNl7nNlJIdbrj6 ԘT"U^@b̓^%KMvHݔzƍxNء4`A|޻n6U}'Nx~92'sb8 ѹ{BbV0)|}g}ˣYmi^C x-ACx%V*:'*nMa^- ӌhUm[}?״{mg!*lE;5K5_uY5F.Њke{6dVR(SSViwM)seӛѽ c5rƼ'ݍm| Nd|.\`o"\F]?tXuG=;iu toXO9 Km_vT# NȲlOD ]2(0 g:A8-u&+U[};QZKx'1_E?/Qo}ψt)kDc-.OOﵭycf .d'< `]V6i@f@ Y :!b" Cy :aL\-7Df+, _9h>5}eΆ5vZlvUe Wo,^/,5{~3Oj'ݷh*!*N ^[J9|j?mr 9c1}_㽄<6e15Zjo;D=Zhag.->RLfX|f–co-?Y:.*?=5Q%݃7$6-z5!,^9X; KϝĻF8)ƻUᠷnW>9Z*SsV P?5il ;#s[^,#M9['FcO*xz$V3Twi?3*=%Uncp_J M4 ~|_iEhx[^V}bNUu9u1ɨ[q@Z&uoJe*PZ*>fRVY?p.uH_ SDEP:mQX[F=|gqi."2pYOQ)E179OXqmwѝڍaVʰ/>8dZ(q'q?h](O.xF`. /+Yy wMx$pi| Bp!Jn֚Rhs$T1 CUv -nY%[J5EE = `FY5bO>}Կ^:n6_<8' KǸ@/OŶI6k4mc)W~w;vv,\6o Xm%ôY` 5H0 m (>؜\PIYGB+!a2eIA?5ZbY_iӳqp>4SXo6+,l~yZB/LG PuL-r}w5AgHB `f2i{O`y!o@A\ʍžg-LrO|W߹>xO^|)ei__|z/6Xs<}t"}xꢱG{zJm/HYa=eBvM]$FL1Q:=#\&љ%;'>ݛ@zm;gKҐjR^9rZ<45D+MLSX쇷 XaU$1cH9G%hT2o[cc/=oLY>aҴ5p<{vcjy&Ӡi(Ԃ(l[w~/=};pwQrSpҍèPu46?T=BMBৣ}LVNZJ' Mu->:g܏?+boP_iA<4@:4"{@d<A<>">K"iKy@_n ˸P&#vaGIHh@+iP5VAPLia Po0AtԪc܄Z"mSi1v_6Lp«KK3e{whLA}㠲N#FhLhʍ&B؀:,{6ᇋ4Wt]K3sNFSV^f|QAV̉ ۷3;-,ODI1ZO1?Ø9t--6Kh^|Qڗ2y(V2&\^9=|Ob7У20`<}R=m O"F1R[D2 6ie/ qqbR:5-6SD~G~&q&4_,JkO>h^ͧ4rLbhϲ2 F(eCSĘ {rjRэ+H?jSJHÇ@,:؄Hϫ0t%v]E{ƿ}zLlpg92㓶QITWJHM@Ol~PLZZM|LD=;"]iL@-wi, UVfiFykh/e9R9[U/ N4:D!i#%7_w*3 0Él(-M &ܥ fW`<:a K!{:`"d 8Ӈho#QNB Kޮ$׵Sx4 Ѻ6NCNP>5~ s;h5;}2&FcEy]uUP8ӄ+dR&Z+ ~r*&>Ƞ@=H%aXOybW gtg#j羼ɧ,lXх88$y(8@t (lL 6Cf8NxI-ŗ3ak b0UjK7k&v+f>ReJ9Q5Xd. &)sN#tݾ-txf+8_g d9Syd&x Ɨ~͑!\̃MbJ2e ս[?9Ĕi4঴-Y%]7,/f]b2#m*s J![j%D%3oDJr‹˼GB5݌?a+$Wl?ɱqUq9!vi;|:J8ȠEUwBb$> >b~-[t&C3P`7oaR@YNպd)Nh_OڥņK2Z=al:3ru/!0X6RW/340hW9~/ɷ&SGWkKs$9kyQI=L/2/0f-`r^beյ)DOONԀESqm|6q13 $LffXTolfoU-VgjGU1aub0^׽3:z> M`ZlYWJ*ͰG'x⚇?jgX]:wNq,gsoP &OAl&%M\}AřY )ƥ|CR1ҷq @'AxF?} ùՠ0iL"-1\!Xǎee;*(!3:46ߣMF:sFalDM|O-3\|Pͨ1_ T^$1xxuMd\R; &f,c(Adxhs!ym3N:șGۖIlg\nVnZO6H 0!Y]Y.- 8!c'SgZY!iWDEWêNl0+ 2ƸjKP.olym_=,4om7𴀏 XL&1! THF~ M=i8&ɂP\x}UzP-6'˴{vn(lo>X[9UmÌjcM\ջ[fP()SSyh-9,|KEٓ&oQ|.Y⾄Қ¯ *S`"ז01$;M-[=U: 썾GV.#&RϣOׅG5axr+p'pRW-W&7B zm:zHފmzbMaoMp0BJκK>&4pՐ`LwzyZ<^l>qˮ.LiK=,`$Zbm"&3E[qH!dE;HtD;!w !.-_Beۦ8>;IP#M4lM\r Ksm"ը QoP+G'wv " ,H5c.Xl2*)CMD)}\x"A2^89H^YQ,amR5W_JUzI6;)X.}FDXJH듗Gŗ4䴨G8Xy&M6SM+vULj*9< ߆I@$(qk ,0+|6(EeSɧnM--`b&8Ahf(ϬlnGCf`N~}sO@ej4 L[zQ'#k[C#PMqSc+ۙnUbǖ(/'omltv/Lş$!/bco+8U)crt LLIn63\c1}co {_DG.0*tmgF BRq _ƹ$?l`dб+V=AYY`2 b4#nqc{.r6y z^xIR&ڙy-?VkVk L rԈdv"\J,+K ܎2U;JlJVx@u+m+ڡ{ B BU(s?mDvU| /R]xyХز)hjUؾ͘/^#6TW$>o2f B=1-`^JZGjn̝`T N*$8AiԽM5izo`DNheh$X͆L|s-\u4SRiuM=y<`)c=czt8H˕<)OP& דHh:]UDHU9 Q:sҕF̓V){nGS{܋7ݜ X CJgIX=A8ԷLSUPuX_,y xB|LK MT>tĘ+Tyjk1{I%ZÓTH.c ~WVe3Op R 3_VHnѪ{u]+]VU4MN0M}& kUŽXARuuk?j뜉7?E&SԊ{|5t$ʈ+NIJdƕZ mIpkXkH:iR*'J-ٙ `mu͍k?JȂÊ:.D\64iڎP5h>.MY3I$viV`-߷Z2C""e(H#fݝvůf3vʹRZm6F^NKb"x8%fSڼqvm)`VEkGNET}ڒ{޾P'vtd(5x;IOB5fCr!,Is-r;Q;(Go2n 8N:K`.,EGJ{s2v%>=27ic,@u2mBmpP2pײ&g3q?k+ \uƈOOVlY]y6$/Lh) d$5y.xX}QOaFsIy<2?o|'#[<)Yn3aK!J a_x;ŢxlUW~? nb]SIi`[I:;UeR:ehkn6;h;q̻hz98UOY|O-̅In}o>a>|j~$x wdyқdhv Ԗ1Đ{LSf_[9-B~B1XDD-G5,MFYBP?]Kc|$ҐcvdP_(L~,2Ƥ{j~`\+c7%W aB@l11L! >[P,7QC@w&;}Amhs_JQ=h`&Z4GwP- H4W~zD`q|W>)M%嵍ə h`r 6j3V4'}p#AnmaÆ]?9u[y(T$eqB2Q浓o #9N{[0)./-??}>yk5a7 Pm"DSqnFb 8yĀ `eu3'9' b)ElCŔs*yؼ4u2 M;#U.7L42 4f 1 ,m Kj?LHdug }^t>{e=ar6s3bYF ^WFW ا(BLi 4.?)hd ԡ^~iXA:lh0T >w(MkSiVMmm$`x+y7vkKp4`b nUœ&~Vr ;~h4_k)m܊aP l$$2}JB[&ƫ:X ͚olR A@_*a{CA]1t)UdebTV"*F *W`I t銵QWϭ䥊ӑhՍ O(s56-1do˨q6[|&z_NI[<|͌k4G4"2 Gg8ZPۓGe`Hcz``[iE4ͦ7xߠMψܛ <>:nvWy*'cP9("A,NR l +梳H U*3ZL~sDYVAKKUO8+U.Yoo$B^=w`Y$/{1ãDI0$43f)Ķ x(XO=L^Beilr*]5Uؼ1_wUmބOr>%\l{v6~خ0̃WIyU+Ùuh/VnTF!KK`I}AFzho^n~2$&zN9pᢝ{L36M Nsy4.-XuHȠبCb/e4"-fy|(g>8~O~[Xj$$Ҝ,|2))&Dn ,g6MuBYY2:^#M V<cty&.ܼ7fgŷ.cG@q=ѫ&"҅ܨ%t0{P&b3%M Gq"\'[b(1j|x }D[n @{{ h8ӅW]wxY(+INf/DWNB`1 nLH)(QMtVQBu/ hzQ#R>*s&I~\9*W3))?'NkƛGon>M>ߑlV П(g8@eri{f1qC })e2UvoԘ7/SM`O< .ƺz[7]HـS ^h嚾|B#ȏTqU'ee9`[djjUа8__xD%袾Ä C>z Ft1{NﺇɌ'@IDATYX醆1lXN܆M)i(6h-WոryT sY!ɷ8,O|wi\b:/<]*7ݕś2eq`_ZAk eEXIy< weԣix& ty0H}F0I"dD T@VQ"6X]}sgOnQ0>A#Dnէ܎W,&6-ͥF^E9~R}d倴Qdb@G}_hoQX_jll5S#YRFBwd{D)ˆKVY*SZLGF+]X񲊂uE\KqL gAdg'~m>|zK_ V~:)Z:CG Ѯٷzh6)i |Ysm =ɽIY`Lqjċ~B7W0|}KܭekiMnk %uHHgB[Quf } $ЄHq*h1{D l/|冮 `uN?gZZw_{~07^91Iktd>]7p e[h$#ThG\@gTմ!S+_-Zh&_}4.:g{{]·1\_-K=[[hG?=2Q pR]-m]kr4b f+$y [6[Kaª`h@ނ(l.E'|_803+j9_}ϭmAwrк@ Ѱ[LsŴu'RZk"3m⣳@BS)ex1him٭{n1G$1fBbd%&x _ >2G͖ Rf$T!ѺFTU΁/<~l` tk/Tq {mNʷQh)u aKTqg|QЁ%uB l! 0A<;{1iNlۘژϜ= 1z=w?}]<虁 R'pSu#9NV8p'p-!9L8;[X Q^~o_6viD1,8b9yyDۮa,c Ga% mtLvXP"/{.堇>LﷇKWwcK#n]ANOK8e(#2?3ꠊ-抉@&䷿ͥ$aϤES> B d!:" kx_I`WhKg=Ww_M% ˗uGUv B¾E@k1B*|)Qȕ< _gqȡCaLS;!8z>#ㆳ0ܧn۠8YrWo.[Ѱn~aM__G*Q dTG/avP)U@$E HlbŗG)dzr…̩( nr %8 e{Eԕ zIU(uPaU&BY%dy6}=xT-ֽ޷V&?|V5'Ξs<ڝ82-e\(Iw6j q-qp?L2j@"R2c#ߡu1[*I(! jČCxO˛ i6u:?w.n-mMLߏXn[ 3nn&l.umT4%3W $w%h-M2)pU'p_A|{ӸiL̯/xH$̓ EJ+Jk-4HqjdA3 x" 9?%@׾:/;2랽p}Sıy Wpd Eܾo+CxSx{CgX햴/}x 3]_֪gGIC^ϩ*FP[^t]Yv/M$$\75MjIRkM@Ql*M&8\O04ΕO A`y2>r7Re_i:| ?XN.|WY7IWT6lJB 2W'e2adUz XYM/ 'FBorc0n \Þ=g " 4DK(0`Kp0Љ'%T/g(l(p-8&=/1Iv5N#q{9]2EgP~%yش%賱hڝ .`u ,,a5ViB *pC&:JXkv)ihV6z}au ? $!7{?sZЙ'7jĕ^W?V`` U$M[~v4eyğ b  E&ݼ&Q Os{-wth]8dp˫7W0(nmKڍb7׶ÏC!N1BFd C v+`p$2qn,.O9>|`f <'O'ecָ{ XMwcq PT~hЅ'ܑ@jܤï,:FKD B h alOY8/|ɲGS_裿I?U&z;}h;Ͽfm Web_-,`B-~0(7 jt*W7BW62Ɋ%P8z_jx[\woMA=whk?CUįc$ᣰ#>7ˮ/^|s} UsTp]=lrcd% %{OuQUEIR =S˞-e_s$$ۖ9BBza`f{#IiwݴLD;wy$>v}]KwqN̎?[nEKKzQkSD_XL)VM:W 9<{DνnA' -5OHAO}b9v6{L zT -͔e)ru \h6DV"MGr{b\ Dm?<23龻9ؚsWv݆ù:: &e b3X*Yi| w91y[NS.cGݴ=Y9`<,ݴz)9dJ7i+j%&L/^p0>$][&-\ʷY'p؝Npwt|;DT1\f'J BO g}VfNZ4.JhbksQ&))l#'|n][k{9A;w~6^EAޔl@a{76ny[;VNGчsvkns; *}cz)} . "%8S}Ɣ";&Z9o㻍v$U 40]ص6@[vF֞Y^:Q& F hebwMmٕnnf:4N ~Oo?qܾX+` =#=nGb7PS "z/%:u *C5]|qq[ߑ 蝶fۘ o|<*6S{Ko$Lb`hF)rD'@Qk* `MAb+Cmk<b3]!zTce(!E_\8u ӛXx-9(at'\% ܳ |Sk 6 '_&Yd!b=$2vpiwfm>>oC|]-T}!u\x~z;yVUNO}X) (Ety44Xf[KSf:9aPE9z45U2'/<ݟ*痾~۔{u͹>_2oҼ~|fWD0 ՏL|JjS)JiŦ|6> =,?N)-bac[.Ф@R}BrI͸h_ĐK̔V&N/ꮽyp̷4?|#r0B< Gp /EuavmdI.\e; ʔy}eQ+ RhC.vw/|7߹*#`!4y!^9LLJ#-Hs'"%8 j 8c # с/~`f 5_˹4ZrYY4P.#Rf[9ՂM )NҢݮ bهhGE V@P!2[8"깧iΞWӌUy rAˊJ jyGC,.Bá1TEAl4i*Oйm 7О#eϽ$S]+W6!~I>jX]MJ[=JB)}F#/zLyJk 婌9*a_4f,?V}8&МC%~O)B5$ FQ>gWδl+]00A؟A#ޝ:-Hy463'NO 7o JP]35_֩^ŝPbj^I=d&Yf;rIrG &*G*ҁ;״*uFoE6/4l?IEʖu_J2-#G>k/USZcBݰF~x V^0!%$l <':?~o9!7H9vhI0+,i Bdd``,"4CMRked)&-{=b)7@PP MXly¢J-oG$P s;%H>`( iuKWkInra#q ]Sd)օ`%9H9фU1w_cG_uG񗇇=_y?ѦZݏ?hI3'.A-+uc\EeUVLEAג1{1f۲>Di#Z0tbBjFr'#HϺi[ݛM 5|#%gP C-a!I>PlM; `St#X.$"ᒲ!"!07/><~H2~O5z:8;>/sP[}kGwv]a};$7ٖٓ)뜦(T/Œ6&4&LN^n|:Y')3()YoE|HK:oڰ\j}&VtdF4?4^ ;xSx)5%SQnd8.0QisKnem,EŦ՗>rfZ1 D\KE"X M+S2D{PGu۫ġFGIy8ABOqQ dj-^:4X]fYlysJ'4- ͯ̋v}b0\|/O<9o5>p-@ $`l"0kQ"?bsɯN.V L=10 )M$*aa9FƮ뷪(W .*4Dj%v-JXN8첨M@:yͷ M=/'>FI!.>uv G];^Ýr1!xB[FU&Bq6ɀyBhxnQY-:T(:q7w<oBұ;75ߚĻS5|9& y(mB$$}s|3Vst,7PFўO%PeL}԰72,S*hf#ю tl(cLQ \{>R468PШًgpC93)-u"^jnؤ4i03_~-dMQY vIk!Ru8@Aov=rqA6ڗt |Ctkܓg[NRt 8I ? 3Sqz՝d:SO#{fKWF[T驠cnx@"/+dE}dZZLXEmu1 WxQ ؼk@^ijiO(D(åB=l4|na4 $Xl1;(f$CA3L_?*ئSkN>x? ʰ޻'Oů`W*,Y mHSX6lƯLnKGDS=jk,v1m%j6:ce#'RP;% ބ4h'8i~dD_6# nM{䴙GVCd-6zH !$(3X0áͱlNm-ȈcjK-Z&b_N>At"1/1y WDzqSWw}N'%*{1=ln#/-h`rqV2lTֳ H3+,E%aQ[%BVϵ-YlI˂7}L7<&Զ cR /բY7 ԓ(0qJgF̻}Q3 gOqRNe*~}X1o5@oMc̶\~ !Iΰ#@jfC7"=]gfUOs˴y)l'AmBU~C~@|9@mKT+UІI,*a63@b_3kM%) [Y(a2ce= lr{fJ Pue" i>hL nB^nS4|cwJ5?֖o/qCKnD]&6+h́|i4頖=" 8oq"0ajQF wQ,~SI~l6>׷Z>?h5-C\_$Rї`1j嫋8iꛬiG)uHK6X r02CvUi{q(OR=F둊G+/+a~dkg0kl6%uW Pt| ͆QW=F4Ͳ55K_3]YXVc+#KLWz5ė`QJ?I`z}oMc-3]sdUQ hvmQ g@HOȯ2F HH#Hi[Ný=r:^[Z{<{~j0yO+ʧ?*@nr׉WN+G^mT`׍"ۍ Ce%9JBaݚO0wa;z^jKXh9);QNYRqJNܬcwwixP }(W6~]^ASxr2S #PlQɸyf}s1)v 5dĔ%c!c l۴g8ٙl'-BaF;l- ~SzPxwl9q  qڽ_CX>.SJe+&LvJl KW]112 [K6j Wk{IXD>dE>z;PK->nh]L̸,9wOcyn$& 0G8_=/ "@e2%ن>ܲ4} -@?* /ҩ<-+i,]Ҵ]{,kހy#Hg?(lm).c1(50N v|ПXh[=򭸀E(Y{՞Tla9TX>x>C)̟~Hyމzwv .T"bYCm8؆NHo|ٞJ0bT30s5CoHM6"ܐc(bHo =g>uLnzZq20/aKR{@gKYqW-߲&}ܢFq`n$;/6fY4B?Xw9H(re ʱh2Ub!˛@I>aWDl[rmD|-3@ۺ,y pS9%MG͊aF[5aú1!OP7V<< )}̙N'C6K.O DO1W,lSNh+:DYMO˖DSg+o!ŋVXebogg"?u,Tyxp* 32&?}<% Av># >qTMn9U؇ iCT*ddQ.nv0_cN$m,.PL1㌴~S_0T i-RiN_" W(C%s=,N=_&V O˶,p ),ֲ/#IƍT P c"`Y  ạ s f 闬9M`>e=mqxm(tKiS]?yreU@KSqLanr  X.Ȳ!kG1ªSk*:c z#[}{1}9Y{) yYR6I6Jsq˛,hm[?θNA:mQau-Ӫ 2ՙ\\SfP, u ^sq\vX!sGX6α2Ff$,S!؇>b',G!{kwC'g]ZMSaH+PG#< Yd ]/e>z'b]ywrr#)NReHˉßYF}vRկ/=y3ٶԤ esiGƷw>#Ν v\RDZFWȚ2I4t*[A;I2r9]y7?y}'G/=qH"U_i9ld6DznGU 35Nڼbfu烎|4>UIrkX6Nfk^xNbttFMeuABȕ*/i/|# =%$F05Jkn3yw-[ү?t# 󞩼 hEGXr-lj!F>12#=}H1;W5l(S?,ꎪ΋{ٗ-enS}4ٯ/3'ђeqXPcji,@h\&f'f!b$鵴 o} D+ʪ-^d̺>ljB%9S7-@_O+pnPAS.X9UΰI-f ]#V>3v]|FLO>e_]Rc\Jg0ѱ)'Ц:!dx6^'?f(O;wʲ f\qpx^{7^ܚlm()b>9bZn)19.{+e=GR0K$8x>fN HADXnD5^s.,NY7F>ikD QzG?I)ێc;\c=eyğPAW8%#tIb^ 9$T$ƕL S/*ϼ~*@=$D3TɎy;-Y(d(/بz໳q~qi(:¨C7F(c )#"#Zb!8mҢc8P/ٝxR0&ruWnW UⰀrl$ c#"W~r7K }vG[}!)8Ne25Q ,#~N:j 5k )dyل@.6 7:ɟT]螲^ D;[r6WYv-)TgHiś|T= Tm9$JX@F6 MFn,u)P4CZƗyQJKkm̴VMi;WIT6 x/ͼ_6zUk2A8688XrO:=LCe ]W7E1`!_Y1sc1@xսu1 p w^rۡooBG{U| KMbcʔZk(hq!8-rɪlwH(jb)u:'sB.I_348Dee"aMMjvLT϶;mٲ~kYsN8u:pv. 8ىLi .-d:,m$LJrMލ>˦^WQ\G)[O Vt6]$\p8zl9{~Pw-67UĬUla}H8LƢ["豔!Ovj15:/@IDAT.g̀ɮ ]'0f׎6,p.u}pjL2'98.u#etq343eT% 7)_Lds1HGb=N:B R| yqCVO A8y^ .&R7#c=eeFwLA{XxyYv~XL^_baYD[09I|)F#Xр,HzKr)BmfO.ԪӰ̵#9-3bDtMKZ/(L_.v8;;> n+{i9ggdpPLǎvgg'՗ *u n&Wڱ BT,wh)OhAD!F"O#Q'lCB+x&;Va?k#dn܊ { Myq_Gs*%U%#_*h$LKL{n5sD2"(d8ç$oKT04Lu )Ƣ#yr!cؼ`X a~}7{8b:iY2` bQ%uКxqQKÁVIg~A×!ۇe߄ ᆄHQX xY. Pl4KQ>pmQ<1߉٧,2UʨPs,NqHұUV M'S_g]e^(p]MAatr!cj`GhL @-psn垼<g| Nemףta# ͓nAWo6˜l2iٺ_o/ }DO$c"bB[ǫ7ƤǝCN}8ir`M[t Ï>/EE,:T+|P*8p8:o,ͨbӆ8L㢏8Iv tK!YBB]};iFYYfvX*@7- A}_m8+K/|T|ٶϊ5rOs{keM5a.Vw-3qa[ XD槵TYe0BЅ-I\d] >-95$`-F 9vӞc`fsE<48`n;p1w$c$sZeC/GleuUk ӷ2䍠u3i; ;Z2Rvme)> k^NhEYxجn ˅[!pӎÂ&֜ur;?3wFg~QQpt;3地 8vFxi.Bb ˮ')"T\0䱶, [ C,E(\3o?=o9Az_!:E@>œ ;l ViNZ })*Ax"&{1&Yr;!u-0sGo7YE8$dy7id/$Dmv.YzXX3S+ei"vg\2.TgRW:gn!J9r8cP U}ԷNL/p.+eۯJ^:)@}?*"~ {>ܰl\nj<Z0(qrQP*kr]xÉ4;8zt+JΓ1,G6oBY_\1U/W@sL޵ wxnxY]Xt[8Daٱ%*y@_ Ua:;`DHs.U9dH\3QH#i" q{a?P:e8RFj t᠝ Xtzw3<ŢZ@ q\m{90 2c,}}*ML2ОySiaJ %xi6 1Dx?tgk,~ ö[j''gi(Cke],JVq^$6} 7_zwg$)lT-Խ_ZҮI~b7ϖhVv,!G!׌aQƕιdd>2Bi&RXvl|,.b2"eB D[HE6M|ؽZLSXiғ^|3E^H1VbU- @n\U]jj:) طxzdl=rJ12jo%w܈!s0=y>>ϟ*0,\L^{ƐzbFu=e4YVOZL;g :U%x:sn.Fhc;HOjJ̃UaA\Mv1yހ8 _ FMzK9e ضCA]{p@_;twmBlBi +9LW-ΡTۥ}۴0 Uj vTG0 (".RfcƏˆ߆`~/|Ơr )=fG<#&hۆؗ G#@ذM!,!B0#2zHb?`i# CpekxL_QMYO0闩8_pvmU-Xd\ꓬHe~waމI8ַ``5X)MFzҪMq+J#`e&m Ub'=("N7TLǢAH 1G>t˱zm TM^|P9;=IH}3 7B$)rz߰],60CM%KM{&,᱅VH U˹XMh4}h`m2 z'Fdt,#Ye^{dLn=6uU%ЬΜPhmmL#٘!r$&,\6~aFK^ 4#Jg,Y[KF9W'Xg? u5X u% 'fvxV|w'T-"y9AɱRLp֑嶹͢XfK]3gq8 tjCʰo`_gS-j5bR*I;oJy A6CPb,A.*L"K9\CsCk`e azoNu(/;p5<]#M.AH=b#eٓ!&<[~;.\wmfeJq򯷴˛pJ&ZYyj]!tW/-rrɅ Y.H!~Z8ِ_R48[Z䩡Aʽ3OUfE#w Vr}=H9uޖSXcL bK}Esc(^A|- Đ`҃'K)iDWMRuN_.Rgyvtt#_aBoТlc6^`Fĥgm> .9oVH(M8H']Lٹʛsr =3 y-@^3?EED`0P09*uB Yq`z⏼ҹ)InDnڛ-c.Ž 0F1F\, VjE _7v}>3}(ts/ZgNwv̡URO y2A p9i5B/ݶ0@ IזiH ב>'؜RXBP\AZPװLY]m7v~?# K|'GBJFBB$DpFG(TSyb*xD_境84 mMM[b]`ɦ[Zloc@J0ݫ/Q^=./C%Jqk{3$"x ͼbZgrRч_ bedj:y UJ!u wZuik ο,~̝ghWd c׹L (w8o;\泳Vï~ze0;o;G{UitFv$kK\2EՊ]QH#Y1$S5csXRϲSlnjK4J,Ed 'deG_̧ 8D >mʔy0fO-?GǛ0h Y4 W3\06ӰQ:T6 woWd?us^{MӮ+%nе{&<Nd7<sc?~go_1AB#ҮqFff扅gl/K &Wʈ+$,7}zBg6>I^q3%/P蘏ꐾa AƃM^z.n5e d_+pǖ+e>uH* 9]0I"[iC{b] b{c9%[+"sw1,! \ Vp!\+# WX)j7\8S~﷡[?|Sds9\@$#u2\,kwOQAq ?v8I8!Uaok1=6Y9R HOFaGߌO^d-hP4E $& rp dlmAsJ9yIe)iQ@Ӣ"c &tuwԠK G5u0<˩~diH'mnq$ղQ>3s+'^(O1h%&.t@g>׼0\p7XwfP'e~0 YHB$uE*N-`hqY5W2 6DPɢT_:)2ӔNReI_}Lx̙Z^t۽Jދ،AR7p!eU5=SKms~K9˽Ln+91=ʂ~AQY< `vgd}ϗK+#ދ>kKMnÚL⌘# 3~=#lBg̞XgE<zIg v!wڳ팓ݕ1'ՃE_5gAͧ5@>ϡ_0>JxWDVʁx\l823`YD\I#:Md9(\? GVBc~pFml?'n$]7JW*=/}GWo~r>zus,/c~uWܜ\DO=>Ůgo@R,GPyE@@ ̷M_Dj˲x58xPM"F&W>G[Tv4I2d'cd AXr#S4\>Oq[v8}d*~E‚GlR>B٬8#TjG[DB+e{dʤi֋"5[ϓhٱ-IvCE+ErE?F rd?,;?/sϿHϾeۃ{ם G9ƎrM=  qa='hxӷ}4}\z.2#|-Q&Wrz^X/8R#9+onJ!NV̸IQf(g^xbOP+N>eڷ _ƣ-)R'P-$)*% ~93.Et\biU8ˀ`3?g= vHy'^/7=y7jh) Vܱ|W eCcl.0 =}7.%;{M@ dhqx!U&!-sοd0hr"@^ _:sTCچKqؠ.B-ʄC0o|I:HrB8}',ooJyp>M1hgA]:hƧ1N^ʣ2?(;><&ɿρ'?񺎚Bپ@MzirZ0cY+.N $n( VZbsxq2<0ovWn|unl,<1nW!Ou6jJ7ƅb=)^"4l"mO}/ N/rRpcr ]Ԁ׳O%M([9YC4,|LͶ:۵6_K iŝ;~e_yozӒh`O} _E6kȑ;yw&KGӌY:d;bK2ȴfԫ 5 % !9H0!-,<8 >t g?S&eq9 ~!rʗrn G\P}lZSU6@tuI&XĜvJeQ}hdXʲ|"Ni(?, Ł(s`/,JJGˁW ]ȂOgϽqx}wz| ?^o߆_ \|} 'N;ǐ HyfDth}yy弪T䩊xpʢjwZW ffDU7ɌFv2c'i/8?gVv]_f(ap~$'qo)vcycOB E}iuG1Vwe:°(z,PlV7wj@Ȩ"@[A’,3YE@sД<ڡr9L,o5>|W;VVr% 3N橧BDGr^C;fWD)A ,i3NXb;F[ z('0j<"RwXLPHwai޻oM;cX}_U>Hcidž=љ t&;6 :w}#2[drR'+:A ?(JZB㕀$P19Q4(>B-y!(ﹿxcXoSe҇ʯ~z\r̍Sv&+4ͩkfJ4ʒYS{(-Ovb&s;6M)#mSrkLn\9R?ws٢6)|ym幓U!ƥTY 7Bph=;+7z<_SO 0=WW-o9-r'~ yUk|X"k2kϬo@ Teuh!1T4([891pf`O9Ϫʜ8"o͉M6:_i1EE C0|\8qpJ!4|Y#Ϯ6# qB6U@?(_wS8דXΞP_\Goqv =@ŃYյbʯ⯔SXsr^n}SȅSהnpϦF 1P岍]\B͢7 :#jj ߶rp9wl3777_?Q~k4 +ٷ|a?Jo1|6%u8s dº:N$ʍGFXөEp@H(N~ W?؀/0F;'&t)Kerqd~01xn$[<DmnH)4[u916#&|jgdJ#h&t1y.5s|G3|!6-/r ͅI'f1y2g]"@\71qk 8 ZB(I vb,\~_?R_9ۅs|C{si!5nvmkʉK[s-0^ע*6oxjYߺTpd- ׬)]>Qv_㚾Xj>@x=ŢT]0_! ݝd=B.iXTl(or(& 0:16'n]p"`"{]&hț\"G751P+BfXNynƯ ryVH?}וHٵ͞n({\(7`g.o.oc!xge[9'ZO_0!#9>me'IٌukTv]9]v./)[py fV)_m{W_,ybD>su3{K{mɐmZ-]i?u.nX=qu aCBqJP+ 9q"Zv!*XF'OT׋b -sR:Z%Do?=3aJ(N_|=zlZMUN+[yglƻ [E%^!]o0n[XP7^oټ\([\*x?LKsQgu[kN,cؼycy#叾q8aNP>}>؞M29~׵4nB~[,Y!]v 3VF4-X~%1I.~}%@s[ ZhEĜ91 vmiS\fԃ@st Fu/k3J(;Oˁᯔ74'˷r-8ڶD- D0S柞Kl2:PLTSdOl¤Skz1$A7M*tB[ t+NFp@+3QȫV֝HuAP`/ٮ٢7ZzEl9R˹#(=dv񳧴ԸxEa"E0uAf:ˌg>yg|k]/^^&RMBܸ(bw&?'*%Ao嫂EQCʂ[_:7cbmG&;/_|D+ i )K:(b9/uoǸ/[{#ŗ)7ް%ȨpXcHdk*ygՊ涤QrEʲxp|gV9KQ k<\.o>\߽|r--HoػX 'u^f#mcrprpi=z^r~L9zl9~~߸^/d>Aq. ' 9lkkD1.H}XL7 aq~e׎۬)Y[c^y/1XKXpNXΜTrgOP ~?ykw޴/d46>_;WtZ}^aWmK? SIևn$[۶-z4!;-n vgu=0ug0R&|66,8sP41q!B9Y;'X|%LvCeK>'+n% ZޕR_@*W͟,“0B쿹٣.oj5j9@p~9\v"g7oƸKyh1XziMդΑqo{+w\{]f[Mt5ҁ]x̹pa.)r^.[ W<5'yN5ADK,X+eYz$ {B ճ|m=iLFբxcL !F&b1'Jy( H="e!rԪ4֕2d( U}^+?_(89[8U9'KÛ;MxruL?b<XFTZڿc6ySAk@*;boY!ѬMXqJofBMAN/B$Ol sӉ'pMX> xxdLsGֱ8n$% x@o5#:Y"|҂^uGlcrmwG^yKpWhcZljj4ҁ,mal9KV;-)a+3(qEƲ;7x l ,'8: yWTP2yOnYb `>U(Tz.s6x*A-?gq;?V^y}"mV[o_8.%ّ}ܦS9(Kc*5Bji- %6oB~y0NRy1 1ƥ4USD Rg0N$hVef30ε@L@و\W"a.Hh᥼<Z?'*m~RĢAg5. &؊B_ϕs-=w=(d&]kgo*;py,Be~kڊLyHP-K'$aB+: g rQ>Qa#s)(,3/`W@!W R [.8r6E e%y/@+&sdGryp X1JE/޲%ce8NCp`EȦ/⥕l.m۷gC 7O;z4)Ibcu ;~ۏzͤaȶu;N ai]~ϲ5N89tif} @OQI3jgy$M$F%dž>Z1hM9̗#^4q=p˫}B<~m h=eTu|5b,g)Qcu# wz+~V,rǏ^~zP9~dQs$ꧨ5x⣷7qO `t+A;]jMqGt͔43MO;sl^ud) 6; _ E@->"NzГpʊ2Up۞sv8:yʫĺ @V^%ovcЊO-&Y)y;PM$j0L+PÚnr)3Xw7{$l߃`~ V'iQR|cTPh ;|aBk5Z@eEK!gv IDAT[{"jIp٠H61ų,D *OU[GCLtqP^`m8s7p64I;%{GL{Wsy撴']YܑY ni߻q#@(WmDBD&ڴ(IڡJv1LcˌIkюV@z!wVduo(Ϸ"K3j\sVMeClrEu|1`pP6vn5lRV$Q"g~[BР6X`.ePe˵w0LF;,Pn#ǖ^kZqTL{}6#]V`n4#r^x3Xf:͗f^ igalj<P:_jm0JǂZúJf!|5З\fR8 OߵJs* buHGyH#TXLEJ%85>2״:|iۻ a(1]qCIҘ湊Tcȇb!G cui n ZXM!:M|0$E`JQXׁ\;tQqKO;7z3ѲUpP%j~H 5 ]G5^}mЙ-!ەifxjTejH Xdف" mf8jk|YB2 Kc屔V MVS@\hWnybh).^,{ح–:Uyr`gubH:H/ 8S&>4"4 X\.DHshcbsޥqcvH*ee_:Vշ]b2V3\u#͝0ւuU^5nQVo̥<;R?O5ل6<>`}dk|{(e+xJ 4՛_R;IhHit`RR* H.KmilƄɄ|ȢY}Yȅ*0Ŧ a j' tfx\FQP7X ԰JXZ*Ef* 6 lzWwK8?S{+'3|o\lumr䢐\lҎsڧ(m.8iS W @P({pgk2m:ZˌJe&[5fr_=#LEߎm]I;DT% ttC$Krtg@=xp|uyɷ։ΥU~ReƵJ rQhaG]V9(y22fĝ5Q!;,mZ-[M\uf6AM1*C&rjL}qi>jZYW9c۪åêSL%U! 1rڄ}ʲ8X`2MBjx!Ϛ2thDL3)jR+U(4l]mRGƲUm"ÌzbR:_("vS:_G?̙`l}߂\dz_lgskau.L*IЊ3I\d4GF|[>xQ}'$'|=0] ݻ495FDCC&cj5|rI< å$>Zl?bVIb=N\{-.d18U30{ |74n5EnO}$/Qhcc~uLp3a B)5@ՙCftrRjS^Lq. =odº<X-vxjR4KHYs2$1`s@f=&g7'Hcgǯ[Y=W^ٺWC]7ל6NK'i?o-VDV!*-YUeAcubQLbA2FK[Y-mYBA1 8mg'k' ;$Y).RIQn1!y)WfJN*4[ovd=2BR8ySoMQfp<-a>\ᛕOO#ʕמUC67|A73kۮvOyg?3eϞ} 7~ANO$pAFqS}7\8*yʤj\+C( ?FClAO(G¹ICIgܨgYZY^&rUb@Jo.R leL/O)Oo˶plV GV5c~/ ?{\yaL#!bq k?[ʾ[+kS 5و -nZWwWl_MG],b':t';)/? [૙-p(gåy'a@*O<]S)^m-x_zɿrij:y}?Evo9}oKkM/=t. Zg8'|š|ojW[_j>r_?v쩗5Ě ٰ}qӆ -.+ˉU 9W[l 0wW¼rяa sr lʫ-pZ`y?x: }عnys 7WKW[j o-aÛCuo^,&{|_%W[_ĹGIENDB`ic089PNG  IHDR\rfsRGB@IDATxדgu&嫫q==nZ\P/" =(zԃҊ w"%Ւ$ +  .}Iw_u7vVݛ'ɼy{/^8kZA X <^{me.wFȑκ'v^rٱ΍2w7v>h-c{nolio]v{~y}}[|C*0~̳NL{s҃x0ސțrqn_n߻zלAGau/]A>@A &Ϯoc{Ƥ؃׬Zc{cͶhz/<ߍ0h-~Z/sSS Q;;2MA wȯN=N>lLZMZشoQv>n=K= eƭAk<*; 4S}_rblr?lS<4p;u(a3lg`-^FщHneծF5"@ x*y7.Ew88;>6k%G]cc%UD6LbF{m?sV(~ h#9Dg^HwIdbgE[bjl4MNңOm~2:.c%2 e:Qf0a1lKdlц臍1B67eO%/ RhU݀h!)ɬ1 {I# F+%'"~|:eK^Y&9}}:'&l]/4yUd1QT$DT%h2}Yb &њK>|i8 Q6EY8Fa[Oc4wowOL 6M@S]'cXhHd WL7eJ3Rchs#]RZa܋Zfl[rưeޫ{|| `q+1)-%‰9ֽȍ<*-%Lk&&k.Ȁ?*ECX lgYM9OUGy`~,̼#.\d .6;tPob#M>U ^д5P?X'~Mm1@"h(QC0PB*S ͌T (*>3s w9n.KeIױAÞxў-&jKy%# x/uLH:DEbXvi^ ~GMYMS$^,&d<oGD C[ثnymmlˇ0Y_>oR6'[ N xrrjM{sXf%'{47cTfJ^^*C˙0j7lV?*1Fi`] ki U nޣvn,e"x^ N7hgolb}-CȏNOOnnfMᣫSZd[hZt4^mf,Gy͚ yGQZ-Ɣ=`Jdy"1]\uCF yz\}0{nukݼ}ӭ ZoAt}72o;nmmݍOy7?u v*uF RoQ|Z5hJ5*nk 47*TH!kar*ؼe4I#gz;[ ]:-c6)(dMLt"hwzoj&,S^YV[Q.VY"HtO>q`\ڻWZ8嶸WNvIn%:M\~_ w-KyfrW]u7gf܉#Gxm-h-ݪ:7juS.+c~CU21jL'3j1_5Z@E(]a޸ⶶ値6?x)77܁YwQ7kLy;X*GT`ۃ#5.jF^uR9@=O֫]Reͦshz^O\lLU&ch˴c^bqni?} c-A}Y4F{}>|[nT+#YdGvdVkW,Mє8?*dYo[w3n~~ ?6f+I,eq;nye-lu[nGMکTn)[Xiϲ%itrIأ&,}'Ov_YUSeE4 Z7} 4R/5@i `jX *%z)-̮-t7n5975TkObw]↬gUZOImv s#M_z&UbVMHD Q[42|*״:9{ȽI Ic,=03N;5-o&~iE& =鎓mwuw? q7+H5y}BZ, }i;f!v +(lFN%)6 %/u$x 9\3ĩpAk6|tKnGiI u{{܂G5{q:3=: '^:-7]v. ~֌C3sг[U l `?JG6Gt6ѵ`WiZ2ː%.Ivڵ݇]\w_}{ǭmD+bGv=gzNg<~km6o\wθ|+޾ukf-6w޵C<=.cQi0<5=oo"8G\Xu\r;~7{bGu/?s"wErdg?I_}]o^c|;)>>3sxZ[Uڧb[E]#tG+*mߘڰ4&kaԊ}ͧ:Aj HBG lKn}ҠF֐vȬDb+rnp/}g^=Ch9 w{:f-o#IZjQMpgv݁7<"O>:_nLO\3+ xض;9΍oX8)5Vf\O?qĝ9?y}[EՃ 8sLLWDނU`E EyQ0[) bG\ڭv&բّj*眑k}'DG=L @v>U+ԗ@SHԉy)wߙ8>ྱ}]ڞƞ}eDOG \ޛtκWݳSJ-6a/=귮tNl7nNlJIdbrj6 ԘT"U^@b̓^%KMvHݔzƍxNء4`A|޻n6U}'Nx~92'sb8 ѹ{BbV0)|}g}ˣYmi^C x-ACx%V*:'*nMa^- ӌhUm[}?״{mg!*lE;5K5_uY5F.Њke{6dVR(SSViwM)seӛѽ c5rƼ'ݍm| Nd|.\`o"\F]?tXuG=;iu toXO9 Km_vT# NȲlOD ]2(0 g:A8-u&+U[};QZKx'1_E?/Qo}ψt)kDc-.OOﵭycf .d'< `]V6i@f@ Y :!b" Cy :aL\-7Df+, _9h>5}eΆ5vZlvUe Wo,^/,5{~3Oj'ݷh*!*N ^[J9|j?mr 9c1}_㽄<6e15Zjo;D=Zhag.->RLfX|f–co-?Y:.*?=5Q%݃7$6-z5!,^9X; KϝĻF8)ƻUᠷnW>9Z*SsV P?5il ;#s[^,#M9['FcO*xz$V3Twi?3*=%Uncp_J M4 ~|_iEhx[^V}bNUu9u1ɨ[q@Z&uoJe*PZ*>fRVY?p.uH_ SDEP:mQX[F=|gqi."2pYOQ)E179OXqmwѝڍaVʰ/>8dZ(q'q?h](O.xF`. /+Yy wMx$pi| Bp!Jn֚Rhs$T1 CUv -nY%[J5EE = `FY5bO>}Կ^:n6_<8' KǸ@/OŶI6k4mc)W~w;vv,\6o Xm%ôY` 5H0 m (>؜\PIYGB+!a2eIA?5ZbY_iӳqp>4SXo6+,l~yZB/LG PuL-r}w5AgHB `f2i{O`y!o@A\ʍžg-LrO|W߹>xO^|)ei__|z/6Xs<}t"}xꢱG{zJm/HYa=eBvM]$FL1Q:=#\&љ%;'>ݛ@zm;gKҐjR^9rZ<45D+MLSX쇷 XaU$1cH9G%hT2o[cc/=oLY>aҴ5p<{vcjy&Ӡi(Ԃ(l[w~/=};pwQrSpҍèPu46?T=BMBৣ}LVNZJ' Mu->:g܏?+boP_iA<4@:4"{@d<A<>">K"iKy@_n ˸P&#vaGIHh@+iP5VAPLia Po0AtԪc܄Z"mSi1v_6Lp«KK3e{whLA}㠲N#FhLhʍ&B؀:,{6ᇋ4Wt]K3sNFSV^f|QAV̉ ۷3;-,ODI1ZO1?Ø9t--6Kh^|Qڗ2y(V2&\^9=|Ob7У20`<}R=m O"F1R[D2 6ie/ qqbR:5-6SD~G~&q&4_,JkO>h^ͧ4rLbhϲ2 F(eCSĘ {rjRэ+H?jSJHÇ@,:؄Hϫ0t%v]E{ƿ}zLlpg92㓶QITWJHM@Ol~PLZZM|LD=;"]iL@-wi, UVfiFykh/e9R9[U/ N4:D!i#%7_w*3 0Él(-M &ܥ fW`<:a K!{:`"d 8Ӈho#QNB Kޮ$׵Sx4 Ѻ6NCNP>5~ s;h5;}2&FcEy]uUP8ӄ+dR&Z+ ~r*&>Ƞ@=H%aXOybW gtg#j羼ɧ,lXх88$y(8@t (lL 6Cf8NxI-ŗ3ak b0UjK7k&v+f>ReJ9Q5Xd. &)sN#tݾ-txf+8_g d9Syd&x Ɨ~͑!\̃MbJ2e ս[?9Ĕi4঴-Y%]7,/f]b2#m*s J![j%D%3oDJr‹˼GB5݌?a+$Wl?ɱqUq9!vi;|:J8ȠEUwBb$> >b~-[t&C3P`7oaR@YNպd)Nh_OڥņK2Z=al:3ru/!0X6RW/340hW9~/ɷ&SGWkKs$9kyQI=L/2/0f-`r^beյ)DOONԀESqm|6q13 $LffXTolfoU-VgjGU1aub0^׽3:z> M`ZlYWJ*ͰG'x⚇?jgX]:wNq,gsoP &OAl&%M\}AřY )ƥ|CR1ҷq @'AxF?} ùՠ0iL"-1\!Xǎee;*(!3:46ߣMF:sFalDM|O-3\|Pͨ1_ T^$1xxuMd\R; &f,c(Adxhs!ym3N:șGۖIlg\nVnZO6H 0!Y]Y.- 8!c'SgZY!iWDEWêNl0+ 2ƸjKP.olym_=,4om7𴀏 XL&1! THF~ M=i8&ɂP\x}UzP-6'˴{vn(lo>X[9UmÌjcM\ջ[fP()SSyh-9,|KEٓ&oQ|.Y⾄Қ¯ *S`"ז01$;M-[=U: 썾GV.#&RϣOׅG5axr+p'pRW-W&7B zm:zHފmzbMaoMp0BJκK>&4pՐ`LwzyZ<^l>qˮ.LiK=,`$Zbm"&3E[qH!dE;HtD;!w !.-_Beۦ8>;IP#M4lM\r Ksm"ը QoP+G'wv " ,H5c.Xl2*)CMD)}\x"A2^89H^YQ,amR5W_JUzI6;)X.}FDXJH듗Gŗ4䴨G8Xy&M6SM+vULj*9< ߆I@$(qk ,0+|6(EeSɧnM--`b&8Ahf(ϬlnGCf`N~}sO@ej4 L[zQ'#k[C#PMqSc+ۙnUbǖ(/'omltv/Lş$!/bco+8U)crt LLIn63\c1}co {_DG.0*tmgF BRq _ƹ$?l`dб+V=AYY`2 b4#nqc{.r6y z^xIR&ڙy-?VkVk L rԈdv"\J,+K ܎2U;JlJVx@u+m+ڡ{ B BU(s?mDvU| /R]xyХز)hjUؾ͘/^#6TW$>o2f B=1-`^JZGjn̝`T N*$8AiԽM5izo`DNheh$X͆L|s-\u4SRiuM=y<`)c=czt8H˕<)OP& דHh:]UDHU9 Q:sҕF̓V){nGS{܋7ݜ X CJgIX=A8ԷLSUPuX_,y xB|LK MT>tĘ+Tyjk1{I%ZÓTH.c ~WVe3Op R 3_VHnѪ{u]+]VU4MN0M}& kUŽXARuuk?j뜉7?E&SԊ{|5t$ʈ+NIJdƕZ mIpkXkH:iR*'J-ٙ `mu͍k?JȂÊ:.D\64iڎP5h>.MY3I$viV`-߷Z2C""e(H#fݝvůf3vʹRZm6F^NKb"x8%fSڼqvm)`VEkGNET}ڒ{޾P'vtd(5x;IOB5fCr!,Is-r;Q;(Go2n 8N:K`.,EGJ{s2v%>=27ic,@u2mBmpP2pײ&g3q?k+ \uƈOOVlY]y6$/Lh) d$5y.xX}QOaFsIy<2?o|'#[<)Yn3aK!J a_x;ŢxlUW~? nb]SIi`[I:;UeR:ehkn6;h;q̻hz98UOY|O-̅In}o>a>|j~$x wdyқdhv Ԗ1Đ{LSf_[9-B~B1XDD-G5,MFYBP?]Kc|$ҐcvdP_(L~,2Ƥ{j~`\+c7%W aB@l11L! >[P,7QC@w&;}Amhs_JQ=h`&Z4GwP- H4W~zD`q|W>)M%嵍ə h`r 6j3V4'}p#AnmaÆ]?9u[y(T$eqB2Q浓o #9N{[0)./-??}>yk5a7 Pm"DSqnFb 8yĀ `eu3'9' b)ElCŔs*yؼ4u2 M;#U.7L42 4f 1 ,m Kj?LHdug }^t>{e=ar6s3bYF ^WFW ا(BLi 4.?)hd ԡ^~iXA:lh0T >w(MkSiVMmm$`x+y7vkKp4`b nUœ&~Vr ;~h4_k)m܊aP l$$2}JB[&ƫ:X ͚olR A@_*a{CA]1t)UdebTV"*F *W`I t銵QWϭ䥊ӑhՍ O(s56-1do˨q6[|&z_NI[<|͌k4G4"2 Gg8ZPۓGe`Hcz``[iE4ͦ7xߠMψܛ <>:nvWy*'cP9("A,NR l +梳H U*3ZL~sDYVAKKUO8+U.Yoo$B^=w`Y$/{1ãDI0$43f)Ķ x(XO=L^Beilr*]5Uؼ1_wUmބOr>%\l{v6~خ0̃WIyU+Ùuh/VnTF!KK`I}AFzho^n~2$&zN9pᢝ{L36M Nsy4.-XuHȠبCb/e4"-fy|(g>8~O~[Xj$$Ҝ,|2))&Dn ,g6MuBYY2:^#M V<cty&.ܼ7fgŷ.cG@q=ѫ&"҅ܨ%t0{P&b3%M Gq"\'[b(1j|x }D[n @{{ h8ӅW]wxY(+INf/DWNB`1 nLH)(QMtVQBu/ hzQ#R>*s&I~\9*W3))?'NkƛGon>M>ߑlV П(g8@eri{f1qC })e2UvoԘ7/SM`O< .ƺz[7]HـS ^h嚾|B#ȏTqU'ee9`[djjUа8__xD%袾Ä C>z Ft1{NﺇɌ'@IDATYX醆1lXN܆M)i(6h-WոryT sY!ɷ8,O|wi\b:/<]*7ݕś2eq`_ZAk eEXIy< weԣix& ty0H}F0I"dD T@VQ"6X]}sgOnQ0>A#Dnէ܎W,&6-ͥF^E9~R}d倴Qdb@G}_hoQX_jll5S#YRFBwd{D)ˆKVY*SZLGF+]X񲊂uE\KqL gAdg'~m>|zK_ V~:)Z:CG Ѯٷzh6)i |Ysm =ɽIY`Lqjċ~B7W0|}KܭekiMnk %uHHgB[Quf } $ЄHq*h1{D l/|冮 `uN?gZZw_{~07^91Iktd>]7p e[h$#ThG\@gTմ!S+_-Zh&_}4.:g{{]·1\_-K=[[hG?=2Q pR]-m]kr4b f+$y [6[Kaª`h@ނ(l.E'|_803+j9_}ϭmAwrк@ Ѱ[LsŴu'RZk"3m⣳@BS)ex1him٭{n1G$1fBbd%&x _ >2G͖ Rf$T!ѺFTU΁/<~l` tk/Tq {mNʷQh)u aKTqg|QЁ%uB l! 0A<;{1iNlۘژϜ= 1z=w?}]<虁 R'pSu#9NV8p'p-!9L8;[X Q^~o_6viD1,8b9yyDۮa,c Ga% mtLvXP"/{.堇>LﷇKWwcK#n]ANOK8e(#2?3ꠊ-抉@&䷿ͥ$aϤES> B d!:" kx_I`WhKg=Ww_M% ˗uGUv B¾E@k1B*|)Qȕ< _gqȡCaLS;!8z>#ㆳ0ܧn۠8YrWo.[Ѱn~aM__G*Q dTG/avP)U@$E HlbŗG)dzr…̩( nr %8 e{Eԕ zIU(uPaU&BY%dy6}=xT-ֽ޷V&?|V5'Ξs<ڝ82-e\(Iw6j q-qp?L2j@"R2c#ߡu1[*I(! jČCxO˛ i6u:?w.n-mMLߏXn[ 3nn&l.umT4%3W $w%h-M2)pU'p_A|{ӸiL̯/xH$̓ EJ+Jk-4HqjdA3 x" 9?%@׾:/;2랽p}Sıy Wpd Eܾo+CxSx{CgX햴/}x 3]_֪gGIC^ϩ*FP[^t]Yv/M$$\75MjIRkM@Ql*M&8\O04ΕO A`y2>r7Re_i:| ?XN.|WY7IWT6lJB 2W'e2adUz XYM/ 'FBorc0n \Þ=g " 4DK(0`Kp0Љ'%T/g(l(p-8&=/1Iv5N#q{9]2EgP~%yش%賱hڝ .`u ,,a5ViB *pC&:JXkv)ihV6z}au ? $!7{?sZЙ'7jĕ^W?V`` U$M[~v4eyğ b  E&ݼ&Q Os{-wth]8dp˫7W0(nmKڍb7׶ÏC!N1BFd C v+`p$2qn,.O9>|`f <'O'ecָ{ XMwcq PT~hЅ'ܑ@jܤï,:FKD B h alOY8/|ɲGS_裿I?U&z;}h;Ͽfm Web_-,`B-~0(7 jt*W7BW62Ɋ%P8z_jx[\woMA=whk?CUįc$ᣰ#>7ˮ/^|s} UsTp]=lrcd% %{OuQUEIR =S˞-e_s$$ۖ9BBza`f{#IiwݴLD;wy$>v}]KwqN̎?[nEKKzQkSD_XL)VM:W 9<{DνnA' -5OHAO}b9v6{L zT -͔e)ru \h6DV"MGr{b\ Dm?<23龻9ؚsWv݆ù:: &e b3X*Yi| w91y[NS.cGݴ=Y9`<,ݴz)9dJ7i+j%&L/^p0>$][&-\ʷY'p؝Npwt|;DT1\f'J BO g}VfNZ4.JhbksQ&))l#'|n][k{9A;w~6^EAޔl@a{76ny[;VNGчsvkns; *}cz)} . "%8S}Ɣ";&Z9o㻍v$U 40]ص6@[vF֞Y^:Q& F hebwMmٕnnf:4N ~Oo?qܾX+` =#=nGb7PS "z/%:u *C5]|qq[ߑ 蝶fۘ o|<*6S{Ko$Lb`hF)rD'@Qk* `MAb+Cmk<b3]!zTce(!E_\8u ӛXx-9(at'\% ܳ |Sk 6 '_&Yd!b=$2vpiwfm>>oC|]-T}!u\x~z;yVUNO}X) (Ety44Xf[KSf:9aPE9z45U2'/<ݟ*痾~۔{u͹>_2oҼ~|fWD0 ՏL|JjS)JiŦ|6> =,?N)-bac[.Ф@R}BrI͸h_ĐK̔V&N/ꮽyp̷4?|#r0B< Gp /EuavmdI.\e; ʔy}eQ+ RhC.vw/|7߹*#`!4y!^9LLJ#-Hs'"%8 j 8c # с/~`f 5_˹4ZrYY4P.#Rf[9ՂM )NҢݮ bهhGE V@P!2[8"깧iΞWӌUy rAˊJ jyGC,.Bá1TEAl4i*Oйm 7О#eϽ$S]+W6!~I>jX]MJ[=JB)}F#/zLyJk 婌9*a_4f,?V}8&МC%~O)B5$ FQ>gWδl+]00A؟A#ޝ:-Hy463'NO 7o JP]35_֩^ŝPbj^I=d&Yf;rIrG &*G*ҁ;״*uFoE6/4l?IEʖu_J2-#G>k/USZcBݰF~x V^0!%$l <':?~o9!7H9vhI0+,i Bdd``,"4CMRked)&-{=b)7@PP MXly¢J-oG$P s;%H>`( iuKWkInra#q ]Sd)օ`%9H9фU1w_cG_uG񗇇=_y?ѦZݏ?hI3'.A-+uc\EeUVLEAג1{1f۲>Di#Z0tbBjFr'#HϺi[ݛM 5|#%gP C-a!I>PlM; `St#X.$"ᒲ!"!07/><~H2~O5z:8;>/sP[}kGwv]a};$7ٖٓ)뜦(T/Œ6&4&LN^n|:Y')3()YoE|HK:oڰ\j}&VtdF4?4^ ;xSx)5%SQnd8.0QisKnem,EŦ՗>rfZ1 D\KE"X M+S2D{PGu۫ġFGIy8ABOqQ dj-^:4X]fYlysJ'4- ͯ̋v}b0\|/O<9o5>p-@ $`l"0kQ"?bsɯN.V L=10 )M$*aa9FƮ뷪(W .*4Dj%v-JXN8첨M@:yͷ M=/'>FI!.>uv G];^Ýr1!xB[FU&Bq6ɀyBhxnQY-:T(:q7w<oBұ;75ߚĻS5|9& y(mB$$}s|3Vst,7PFўO%PeL}԰72,S*hf#ю tl(cLQ \{>R468PШًgpC93)-u"^jnؤ4i03_~-dMQY vIk!Ru8@Aov=rqA6ڗt |Ctkܓg[NRt 8I ? 3Sqz՝d:SO#{fKWF[T驠cnx@"/+dE}dZZLXEmu1 WxQ ؼk@^ijiO(D(åB=l4|na4 $Xl1;(f$CA3L_?*ئSkN>x? ʰ޻'Oů`W*,Y mHSX6lƯLnKGDS=jk,v1m%j6:ce#'RP;% ބ4h'8i~dD_6# nM{䴙GVCd-6zH !$(3X0áͱlNm-ȈcjK-Z&b_N>At"1/1y WDzqSWw}N'%*{1=ln#/-h`rqV2lTֳ H3+,E%aQ[%BVϵ-YlI˂7}L7<&Զ cR /բY7 ԓ(0qJgF̻}Q3 gOqRNe*~}X1o5@oMc̶\~ !Iΰ#@jfC7"=]gfUOs˴y)l'AmBU~C~@|9@mKT+UІI,*a63@b_3kM%) [Y(a2ce= lr{fJ Pue" i>hL nB^nS4|cwJ5?֖o/qCKnD]&6+h́|i4頖=" 8oq"0ajQF wQ,~SI~l6>׷Z>?h5-C\_$Rї`1j嫋8iꛬiG)uHK6X r02CvUi{q(OR=F둊G+/+a~dkg0kl6%uW Pt| ͆QW=F4Ͳ55K_3]YXVc+#KLWz5ė`QJ?I`z}oMc-3]sdUQ hvmQ g@HOȯ2F HH#Hi[Ný=r:^[Z{<{~j0yO+ʧ?*@nr׉WN+G^mT`׍"ۍ Ce%9JBaݚO0wa;z^jKXh9);QNYRqJNܬcwwixP }(W6~]^ASxr2S #PlQɸyf}s1)v 5dĔ%c!c l۴g8ٙl'-BaF;l- ~SzPxwl9q  qڽ_CX>.SJe+&LvJl KW]112 [K6j Wk{IXD>dE>z;PK->nh]L̸,9wOcyn$& 0G8_=/ "@e2%ن>ܲ4} -@?* /ҩ<-+i,]Ҵ]{,kހy#Hg?(lm).c1(50N v|ПXh[=򭸀E(Y{՞Tla9TX>x>C)̟~Hyމzwv .T"bYCm8؆NHo|ٞJ0bT30s5CoHM6"ܐc(bHo =g>uLnzZq20/aKR{@gKYqW-߲&}ܢFq`n$;/6fY4B?Xw9H(re ʱh2Ub!˛@I>aWDl[rmD|-3@ۺ,y pS9%MG͊aF[5aú1!OP7V<< )}̙N'C6K.O DO1W,lSNh+:DYMO˖DSg+o!ŋVXebogg"?u,Tyxp* 32&?}<% Av># >qTMn9U؇ iCT*ddQ.nv0_cN$m,.PL1㌴~S_0T i-RiN_" W(C%s=,N=_&V O˶,p ),ֲ/#IƍT P c"`Y  ạ s f 闬9M`>e=mqxm(tKiS]?yreU@KSqLanr  X.Ȳ!kG1ªSk*:c z#[}{1}9Y{) yYR6I6Jsq˛,hm[?θNA:mQau-Ӫ 2ՙ\\SfP, u ^sq\vX!sGX6α2Ff$,S!؇>b',G!{kwC'g]ZMSaH+PG#< Yd ]/e>z'b]ywrr#)NReHˉßYF}vRկ/=y3ٶԤ esiGƷw>#Ν v\RDZFWȚ2I4t*[A;I2r9]y7?y}'G/=qH"U_i9ld6DznGU 35Nڼbfu烎|4>UIrkX6Nfk^xNbttFMeuABȕ*/i/|# =%$F05Jkn3yw-[ү?t# 󞩼 hEGXr-lj!F>12#=}H1;W5l(S?,ꎪ΋{ٗ-enS}4ٯ/3'ђeqXPcji,@h\&f'f!b$鵴 o} D+ʪ-^d̺>ljB%9S7-@_O+pnPAS.X9UΰI-f ]#V>3v]|FLO>e_]Rc\Jg0ѱ)'Ц:!dx6^'?f(O;wʲ f\qpx^{7^ܚlm()b>9bZn)19.{+e=GR0K$8x>fN HADXnD5^s.,NY7F>ikD QzG?I)ێc;\c=eyğPAW8%#tIb^ 9$T$ƕL S/*ϼ~*@=$D3TɎy;-Y(d(/بz໳q~qi(:¨C7F(c )#"#Zb!8mҢc8P/ٝxR0&ruWnW UⰀrl$ c#"W~r7K }vG[}!)8Ne25Q ,#~N:j 5k )dyل@.6 7:ɟT]螲^ D;[r6WYv-)TgHiś|T= Tm9$JX@F6 MFn,u)P4CZƗyQJKkm̴VMi;WIT6 x/ͼ_6zUk2A8688XrO:=LCe ]W7E1`!_Y1sc1@xսu1 p w^rۡooBG{U| KMbcʔZk(hq!8-rɪlwH(jb)u:'sB.I_348Dee"aMMjvLT϶;mٲ~kYsN8u:pv. 8ىLi .-d:,m$LJrMލ>˦^WQ\G)[O Vt6]$\p8zl9{~Pw-67UĬUla}H8LƢ["豔!Ovj15:/@IDAT.g̀ɮ ]'0f׎6,p.u}pjL2'98.u#etq343eT% 7)_Lds1HGb=N:B R| yqCVO A8y^ .&R7#c=eeFwLA{XxyYv~XL^_baYD[09I|)F#Xр,HzKr)BmfO.ԪӰ̵#9-3bDtMKZ/(L_.v8;;> n+{i9ggdpPLǎvgg'՗ *u n&Wڱ BT,wh)OhAD!F"O#Q'lCB+x&;Va?k#dn܊ { Myq_Gs*%U%#_*h$LKL{n5sD2"(d8ç$oKT04Lu )Ƣ#yr!cؼ`X a~}7{8b:iY2` bQ%uКxqQKÁVIg~A×!ۇe߄ ᆄHQX xY. Pl4KQ>pmQ<1߉٧,2UʨPs,NqHұUV M'S_g]e^(p]MAatr!cj`GhL @-psn垼<g| Nemףta# ͓nAWo6˜l2iٺ_o/ }DO$c"bB[ǫ7ƤǝCN}8ir`M[t Ï>/EE,:T+|P*8p8:o,ͨbӆ8L㢏8Iv tK!YBB]};iFYYfvX*@7- A}_m8+K/|T|ٶϊ5rOs{keM5a.Vw-3qa[ XD槵TYe0BЅ-I\d] >-95$`-F 9vӞc`fsE<48`n;p1w$c$sZeC/GleuUk ӷ2䍠u3i; ;Z2Rvme)> k^NhEYxجn ˅[!pӎÂ&֜ur;?3wFg~QQpt;3地 8vFxi.Bb ˮ')"T\0䱶, [ C,E(\3o?=o9Az_!:E@>œ ;l ViNZ })*Ax"&{1&Yr;!u-0sGo7YE8$dy7id/$Dmv.YzXX3S+ei"vg\2.TgRW:gn!J9r8cP U}ԷNL/p.+eۯJ^:)@}?*"~ {>ܰl\nj<Z0(qrQP*kr]xÉ4;8zt+JΓ1,G6oBY_\1U/W@sL޵ wxnxY]Xt[8Daٱ%*y@_ Ua:;`DHs.U9dH\3QH#i" q{a?P:e8RFj t᠝ Xtzw3<ŢZ@ q\m{90 2c,}}*ML2ОySiaJ %xi6 1Dx?tgk,~ ö[j''gi(Cke],JVq^$6} 7_zwg$)lT-Խ_ZҮI~b7ϖhVv,!G!׌aQƕιdd>2Bi&RXvl|,.b2"eB D[HE6M|ؽZLSXiғ^|3E^H1VbU- @n\U]jj:) طxzdl=rJ12jo%w܈!s0=y>>ϟ*0,\L^{ƐzbFu=e4YVOZL;g :U%x:sn.Fhc;HOjJ̃UaA\Mv1yހ8 _ FMzK9e ضCA]{p@_;twmBlBi +9LW-ΡTۥ}۴0 Uj vTG0 (".RfcƏˆ߆`~/|Ơr )=fG<#&hۆؗ G#@ذM!,!B0#2zHb?`i# CpekxL_QMYO0闩8_pvmU-Xd\ꓬHe~waމI8ַ``5X)MFzҪMq+J#`e&m Ub'=("N7TLǢAH 1G>t˱zm TM^|P9;=IH}3 7B$)rz߰],60CM%KM{&,᱅VH U˹XMh4}h`m2 z'Fdt,#Ye^{dLn=6uU%ЬΜPhmmL#٘!r$&,\6~aFK^ 4#Jg,Y[KF9W'Xg? u5X u% 'fvxV|w'T-"y9AɱRLp֑嶹͢XfK]3gq8 tjCʰo`_gS-j5bR*I;oJy A6CPb,A.*L"K9\CsCk`e azoNu(/;p5<]#M.AH=b#eٓ!&<[~;.\wmfeJq򯷴˛pJ&ZYyj]!tW/-rrɅ Y.H!~Z8ِ_R48[Z䩡Aʽ3OUfE#w Vr}=H9uޖSXcL bK}Esc(^A|- Đ`҃'K)iDWMRuN_.Rgyvtt#_aBoТlc6^`Fĥgm> .9oVH(M8H']Lٹʛsr =3 y-@^3?EED`0P09*uB Yq`z⏼ҹ)InDnڛ-c.Ž 0F1F\, VjE _7v}>3}(ts/ZgNwv̡URO y2A p9i5B/ݶ0@ IזiH ב>'؜RXBP\AZPװLY]m7v~?# K|'GBJFBB$DpFG(TSyb*xD_境84 mMM[b]`ɦ[Zloc@J0ݫ/Q^=./C%Jqk{3$"x ͼbZgrRч_ bedj:y UJ!u wZuik ο,~̝ghWd c׹L (w8o;\泳Vï~ze0;o;G{UitFv$kK\2EՊ]QH#Y1$S5csXRϲSlnjK4J,Ed 'deG_̧ 8D >mʔy0fO-?GǛ0h Y4 W3\06ӰQ:T6 woWd?us^{MӮ+%nе{&<Nd7<sc?~go_1AB#ҮqFff扅gl/K &Wʈ+$,7}zBg6>I^q3%/P蘏ꐾa AƃM^z.n5e d_+pǖ+e>uH* 9]0I"[iC{b] b{c9%[+"sw1,! \ Vp!\+# WX)j7\8S~﷡[?|Sds9\@$#u2\,kwOQAq ?v8I8!Uaok1=6Y9R HOFaGߌO^d-hP4E $& rp dlmAsJ9yIe)iQ@Ӣ"c &tuwԠK G5u0<˩~diH'mnq$ղQ>3s+'^(O1h%&.t@g>׼0\p7XwfP'e~0 YHB$uE*N-`hqY5W2 6DPɢT_:)2ӔNReI_}Lx̙Z^t۽Jދ،AR7p!eU5=SKms~K9˽Ln+91=ʂ~AQY< `vgd}ϗK+#ދ>kKMnÚL⌘# 3~=#lBg̞XgE<zIg v!wڳ팓ݕ1'ՃE_5gAͧ5@>ϡ_0>JxWDVʁx\l823`YD\I#:Md9(\? GVBc~pFml?'n$]7JW*=/}GWo~r>zus,/c~uWܜ\DO=>Ůgo@R,GPyE@@ ̷M_Dj˲x58xPM"F&W>G[Tv4I2d'cd AXr#S4\>Oq[v8}d*~E‚GlR>B٬8#TjG[DB+e{dʤi֋"5[ϓhٱ-IvCE+ErE?F rd?,;?/sϿHϾeۃ{ם G9ƎrM=  qa='hxӷ}4}\z.2#|-Q&Wrz^X/8R#9+onJ!NV̸IQf(g^xbOP+N>eڷ _ƣ-)R'P-$)*% ~93.Et\biU8ˀ`3?g= vHy'^/7=y7jh) Vܱ|W eCcl.0 =}7.%;{M@ dhqx!U&!-sοd0hr"@^ _:sTCچKqؠ.B-ʄC0o|I:HrB8}',ooJyp>M1hgA]:hƧ1N^ʣ2?(;><&ɿρ'?񺎚Bپ@MzirZ0cY+.N $n( VZbsxq2<0ovWn|unl,<1nW!Ou6jJ7ƅb=)^"4l"mO}/ N/rRpcr ]Ԁ׳O%M([9YC4,|LͶ:۵6_K iŝ;~e_yozӒh`O} _E6kȑ;yw&KGӌY:d;bK2ȴfԫ 5 % !9H0!-,<8 >t g?S&eq9 ~!rʗrn G\P}lZSU6@tuI&XĜvJeQ}hdXʲ|"Ni(?, Ł(s`/,JJGˁW ]ȂOgϽqx}wz| ?^o߆_ \|} 'N;ǐ HyfDth}yy弪T䩊xpʢjwZW ffDU7ɌFv2c'i/8?gVv]_f(ap~$'qo)vcycOB E}iuG1Vwe:°(z,PlV7wj@Ȩ"@[A’,3YE@sД<ڡr9L,o5>|W;VVr% 3N橧BDGr^C;fWD)A ,i3NXb;F[ z('0j<"RwXLPHwai޻oM;cX}_U>Hcidž=љ t&;6 :w}#2[drR'+:A ?(JZB㕀$P19Q4(>B-y!(ﹿxcXoSe҇ʯ~z\r̍Sv&+4ͩkfJ4ʒYS{(-Ovb&s;6M)#mSrkLn\9R?ws٢6)|ym幓U!ƥTY 7Bph=;+7z<_SO 0=WW-o9-r'~ yUk|X"k2kϬo@ Teuh!1T4([891pf`O9Ϫʜ8"o͉M6:_i1EE C0|\8qpJ!4|Y#Ϯ6# qB6U@?(_wS8דXΞP_\Goqv =@ŃYյbʯ⯔SXsr^n}SȅSהnpϦF 1P岍]\B͢7 :#jj ߶rp9wl3777_?Q~k4 +ٷ|a?Jo1|6%u8s dº:N$ʍGFXөEp@H(N~ W?؀/0F;'&t)Kerqd~01xn$[<DmnH)4[u916#&|jgdJ#h&t1y.5s|G3|!6-/r ͅI'f1y2g]"@\71qk 8 ZB(I vb,\~_?R_9ۅs|C{si!5nvmkʉK[s-0^ע*6oxjYߺTpd- ׬)]>Qv_㚾Xj>@x=ŢT]0_! ݝd=B.iXTl(or(& 0:16'n]p"`"{]&hț\"G751P+BfXNynƯ ryVH?}וHٵ͞n({\(7`g.o.oc!xge[9'ZO_0!#9>me'IٌukTv]9]v./)[py fV)_m{W_,ybD>su3{K{mɐmZ-]i?u.nX=qu aCBqJP+ 9q"Zv!*XF'OT׋b -sR:Z%Do?=3aJ(N_|=zlZMUN+[yglƻ [E%^!]o0n[XP7^oټ\([\*x?LKsQgu[kN,cؼycy#叾q8aNP>}>؞M29~׵4nB~[,Y!]v 3VF4-X~%1I.~}%@s[ ZhEĜ91 vmiS\fԃ@st Fu/k3J(;Oˁᯔ74'˷r-8ڶD- D0S柞Kl2:PLTSdOl¤Skz1$A7M*tB[ t+NFp@+3QȫV֝HuAP`/ٮ٢7ZzEl9R˹#(=dv񳧴ԸxEa"E0uAf:ˌg>yg|k]/^^&RMBܸ(bw&?'*%Ao嫂EQCʂ[_:7cbmG&;/_|D+ i )K:(b9/uoǸ/[{#ŗ)7ް%ȨpXcHdk*ygՊ涤QrEʲxp|gV9KQ k<\.o>\߽|r--HoػX 'u^f#mcrprpi=z^r~L9zl9~~߸^/d>Aq. ' 9lkkD1.H}XL7 aq~e׎۬)Y[c^y/1XKXpNXΜTrgOP ~?ykw޴/d46>_;WtZ}^aWmK? SIևn$[۶-z4!;-n vgu=0ug0R&|66,8sP41q!B9Y;'X|%LvCeK>'+n% ZޕR_@*W͟,“0B쿹٣.oj5j9@p~9\v"g7oƸKyh1XziMդΑqo{+w\{]f[Mt5ҁ]x̹pa.)r^.[ W<5'yN5ADK,X+eYz$ {B ճ|m=iLFբxcL !F&b1'Jy( H="e!rԪ4֕2d( U}^+?_(89[8U9'KÛ;MxruL?b<XFTZڿc6ySAk@*;boY!ѬMXqJofBMAN/B$Ol sӉ'pMX> xxdLsGֱ8n$% x@o5#:Y"|҂^uGlcrmwG^yKpWhcZljj4ҁ,mal9KV;-)a+3(qEƲ;7x l ,'8: yWTP2yOnYb `>U(Tz.s6x*A-?gq;?V^y}"mV[o_8.%ّ}ܦS9(Kc*5Bji- %6oB~y0NRy1 1ƥ4USD Rg0N$hVef30ε@L@و\W"a.Hh᥼<Z?'*m~RĢAg5. &؊B_ϕs-=w=(d&]kgo*;py,Be~kڊLyHP-K'$aB+: g rQ>Qa#s)(,3/`W@!W R [.8r6E e%y/@+&sdGryp X1JE/޲%ce8NCp`EȦ/⥕l.m۷gC 7O;z4)Ibcu ;~ۏzͤaȶu;N ai]~ϲ5N89tif} @OQI3jgy$M$F%dž>Z1hM9̗#^4q=p˫}B<~m h=eTu|5b,g)Qcu# wz+~V,rǏ^~zP9~dQs$ꧨ5x⣷7qO `t+A;]jMqGt͔43MO;sl^ud) 6; _ E@->"NzГpʊ2Up۞sv8:yʫĺ @V^%ovcЊO-&Y)y;PM$j0L+PÚnr)3Xw7{$l߃`~ V'iQR|cTPh ;|aBk5Z@eEK!gv IDAT[{"jIp٠H61ų,D *OU[GCLtqP^`m8s7p64I;%{GL{Wsy撴']YܑY ni߻q#@(WmDBD&ڴ(IڡJv1LcˌIkюV@z!wVduo(Ϸ"K3j\sVMeClrEu|1`pP6vn5lRV$Q"g~[BР6X`.ePe˵w0LF;,Pn#ǖ^kZqTL{}6#]V`n4#r^x3Xf:͗f^ igalj<P:_jm0JǂZúJf!|5З\fR8 OߵJs* buHGyH#TXLEJ%85>2״:|iۻ a(1]qCIҘ湊Tcȇb!G cui n ZXM!:M|0$E`JQXׁ\;tQqKO;7z3ѲUpP%j~H 5 ]G5^}mЙ-!ەifxjTejH Xdف" mf8jk|YB2 Kc屔V MVS@\hWnybh).^,{ح–:Uyr`gubH:H/ 8S&>4"4 X\.DHshcbsޥqcvH*ee_:Vշ]b2V3\u#͝0ւuU^5nQVo̥<;R?O5ل6<>`}dk|{(e+xJ 4՛_R;IhHit`RR* H.KmilƄɄ|ȢY}Yȅ*0Ŧ a j' tfx\FQP7X ԰JXZ*Ef* 6 lzWwK8?S{+'3|o\lumr䢐\lҎsڧ(m.8iS W @P({pgk2m:ZˌJe&[5fr_=#LEߎm]I;DT% ttC$Krtg@=xp|uyɷ։ΥU~ReƵJ rQhaG]V9(y22fĝ5Q!;,mZ-[M\uf6AM1*C&rjL}qi>jZYW9c۪åêSL%U! 1rڄ}ʲ8X`2MBjx!Ϛ2thDL3)jR+U(4l]mRGƲUm"ÌzbR:_("vS:_G?̙`l}߂\dz_lgskau.L*IЊ3I\d4GF|[>xQ}'$'|=0] ݻ495FDCC&cj5|rI< å$>Zl?bVIb=N\{-.d18U30{ |74n5EnO}$/Qhcc~uLp3a B)5@ՙCftrRjS^Lq. =odº<X-vxjR4KHYs2$1`s@f=&g7'Hcgǯ[Y=W^ٺWC]7ל6NK'i?o-VDV!*-YUeAcubQLbA2FK[Y-mYBA1 8mg'k' ;$Y).RIQn1!y)WfJN*4[ovd=2BR8ySoMQfp<-a>\ᛕOO#ʕמUC67|A73kۮvOyg?3eϞ} 7~ANO$pAFqS}7\8*yʤj\+C( ?FClAO(G¹ICIgܨgYZY^&rUb@Jo.R leL/O)Oo˶plV GV5c~/ ?{\yaL#!bq k?[ʾ[+kS 5و -nZWwWl_MG],b':t';)/? [૙-p(gåy'a@*O<]S)^m-x_zɿrij:y}?Evo9}oKkM/=t. Zg8'|š|ojW[_j>r_?v쩗5Ě ٰ}qӆ -.+ˉU 9W[l 0wW¼rяa sr lʫ-pZ`y?x: }عnys 7WKW[j o-aÛCuo^,&{|_%W[_ĹGIENDB`ic04GARGB+NNNNNNNNNNNNN98M'*321/023567'=DB<,-<<>ADHIK8>E9HOQ.:=AEHKM8@E:B1+c7=AFKMO9C=b874f2<@GLNP:H=a><9Y+<@GLOQ;NGaA@=9S19>AJR;Ub4lD@>;dlqyDK<]li9sCA@hERdv6>gxsn*SEFKYhz;@ry_vJLR^l{:A}{:OPWanzlDBWb^[f{\?aC[?B@ATccFfwpic]ZYVUU4).-,-/122&6;:5)*5579<>@A37=3Gpl,579BDF5B8qbceu*8:?BEF5GBaZ[^aS/58:BH6NY1hUX^f}tyDB7Vb`6lU\etg19`nid)^^hx6;i{voXqam}4=uzr7fqt?={R^x^:W?zU=@>=MZZAcxphb\USRPON3*@GFDCBACCDFF:+QWUO@ALKLMPRTUG+RYMIng:JKMPRTVG+UXAZGKMPSUWH+XQc{zzmCJMPTVWH+]Npttsp G"Is4 L304Y}`0xh8:}x<ݝ-_yW|6x` >=3FF= q݂JT L304 LVFl ;ԛ~ ~wp27x`\Ύup.fʀ WS; L304 Lp6@K3lÓo_1\ώ'N&zVj?4 L30ӛh?޹WMnxmu6pu?WSif`i{FoW@ >38iV9hif`LzgĆ̀o7i u'x``Z)4 L304 `FR߽paovI Ƌ닋+ <tuj?4 L304 ~gwygׇHWܺ/ |>34 L304]20|_-e)Ulo~?9=Ol0ܑk>4 L304i8?k|)MEGu _oN;M304 L30@,=zusmNn~߃tM?4 L30@ ܀~~mݯM /7'S4 L30430knoT yRScDoOřvh t4 L304 \Z[[u2vK7n|^ﻠ_β633f>Ɇ;.e Zd 4gƝʖs)MCT7Y)Ywg p50#V= HPQ (4S$ 6G⨟O{ɼ ARMcvrr ;:n3txtve$ΔJQ"6;k+!+}<^qS:_ty0.:ϗ{!Eb oãեftcHͬ S=0[lKL Q1iܒ(YRI4UԲMO q]t|~z)[AWR2{ĭ[d:mYd!fO )L8蕆xIXSm8@ %Hŷ[k#M0Ɉ^F|tF)С6݃o}G`fӈ휞ݻ;0995|&Q0eǢRi yA TkM3YzTV?[̯>B? /Ƿ4_EJY&\& T٫):&Y^\`/=s zk޺6 ~S\ZG_q/ɍ{)cqG2w(ےt|toʐRrb:y=Gt:hEg޸g]csG4Bp58_&g܂iRR9 5;ʄ4Y5*o'ϘuKyإl~Z|}Z u΄ ʡI=_[T>)gL&._s*֚ lWTPyy[q*8Ŋa7Yc#pN9QYygn )w?yC=5Z%9/o|j gF/[` ,-, YrX$GtA\2{y/r@S)䝜dT RW/rY&b7ń3cKbOyz--$<.(g,k<0z5+ ԧD.$2w6}{:Y-h|=PݯȰRPvnCkcYm =D ˗Kْ5&GtpkVH (ɤ!ag@L\0SD۔%鵱8[F<Č`O#e`L]]t=Rx}e^yX1xf87YJ9/Qc%xc!vPYb|sZ'>[uXBIm_L,#qA`#3EOvK J]޸ϨEq6xW/C,xA_#Q){17&7jR=7V=|/]hpoD='Ҍ_\F{-B ~XK I۹z,Jkkl½1m; m@@!j Or2oN",a?Q^pa&183m˓DW{SA^1QA`Sɭ^ic7D |} <7v]ͭ'>m.Fs+|;섄&@ {̘y|K+jx‘VA4Kf;4N/˜`մm"%ҾBycЋ2zVYq~=}xㅙ ;aa8KY^6 4ojʻƙ˘u&Ut\v}Y0`X}գ~tytJ}-3ue|sge\ p 8W8*4!X&iޡHEn5ZlRiq®n=Y,J~tI+ GnQ.pOڅꔄI zyc[:Ur(e1l+i>먜|c3=>: C6 U_G|gӠNN >FnE\G9#k@iWD1̕#dO4vPs} 66$t3z R`"N R*ě>c Wlc*5ػq`jSMž+fRNR ARk pHb/ q1ZE8\ql kZȟ.4_o+s-iN³uumЁgT2+ )e,!/V %Gb?fMEr-迏h]6ҶPٴ>4cn*%\ Q?D`݅K Fk-N xew륄/ojEY3m:t0jʴegܼq,[% Jy8-Ag0h>YLcܝioK8MYyiF #ؼk)1tzӡP?HGǨۊ.ә3X%L%诗h)v ۛ&0X`P32ZBLߎDz:Ѫ <^}M49C޳fE ܝoDrSӑS.A}+USLY{Fbs8!*bx Y2X$7ʴ5 8h8B a#c1$ESRO6YlU<=ȪNYZhZ#n[[G%KP!Wi¬p0f?a^˘&Lg `ΊxJ_:ɝZ$L?O,e۹̼?l9O ȾeEZx갎yE3NP/0iH NB>Ґ~B[b LU g4 )TalTݲhLd rV-WM6FbZ;SGc%ҙ4Jsbkvrj!AZ3#6kc:#ybnt~JO)+b^ dqm 61!JY&sOO Ǝ1?jV*ۚ/]qE.-OKׂ+T7 gOIc kzQXU울1O#USoݙP_A9Ӹl |mZpGD=Ʉ!s4R;Na2utzʎ;N8p Gd#8E٥E]X[pOfC6e3)zEӄ0uqgI%#]Z%(rXnW`ͮs&j0D}R[]e3r@r[2ZWm՚{VeՆdS~||Cvzt,a=pqǙi33_\d &0Aa38)&* {mf5M[LfY i{)꯹vr8trD$ėk_9 C.RD %&&}N1-a:5gCR}nb ږHJc͢yJx֎ N%H$_xL1˞ܹB3'pG7p~]|^TPh@ IY_GxӸq#y["1jbj[-uX*:OȌ+Hڕ&}Ml  R*Q2 ؅߿{n;*L<|!Lx~=o .mK +pC7Ђq+3a8t^Yt%Rqx&Q(@[tZi\fj{4SC|-E,o~<4y|zMjL`=|a LBނ }>>dsssln(<h2cJ6~I@ІP$^pmۚ-1{٫.jLi%5kc!f #`%YDٱ8mSHE7bo'd:Q[;w|&MbӓOŕev~[G iV+*]_C[{UdB=dVIT#m&)Sv3[mli=867ּ~b7gpm>~vwv c ̓;[ZZb7.ˈޞ*6 g7UngKZ@SBV7\V 8yJaO: ]}2@Y5]E'T$~NNVfMU=O|IY‹vv.<~G so+g~ML&+җT?g~K0fP[OLsdɊ/ޤ9"*F?h~*=M(Euv+Ć%Ԩ{bKs#'-2 R 9@ Im=⵼&fZ/^>~>q}MRō:ҡK@_;#lSQJ Yftan{mvOz"؆ nm .EJ$l ?'j08Ewi V9<4zmЯQ͞O.%P;xfPG;?u("ʎHD3CL|NOgg|t;l~sVrt&keT'ܪ!)}b]C>$N< Y*k)0hK4J|2[5/4`ef{g(BrvJUq,ʒE/C{]vt?qOS?-.̲y"a}xS8b ^X^ֶ98dΟgEʨL6a_r +nbI]. O̮]uROuVȜҒrO)h]esbGPJ5i"ˬ%h2 lyi̱9v266,;#a+pE`=xlwm#x _y;إe]}ZS)W&:S'e5†+oTUʵmpڤhqj;NY^&1xOƐvIt0EGۺԴn*:UnqOn{`PwGx?33dW. {8rٌ Й~;oc_ c?`Ğo ,/.{h㘤ʻKFu[+Y7be9ӹ}5M>Oni\:3s$nX))L_ƀݚ0UʫBz[fݩ418c'w߂#hwf>x}Ex-ncvF\Rx ?#wE?]g'gsv`wut]^ؗ>!p8u-Dd`麑Uu$/ŸR F%Vɞn~'k@\EJj>3`L黗p~j-jIq0$Λ`QqeOϲO|/+U1L[_9p'on?f8s{x3ŋlmnOSwvߥ0U1)8MS=T57`_8!8DFkKwI]?kAKܑ8"l5D{ &iE3K P4K:I}Ek. ~Ώ>B6eme}74[m<k}ϭnco ʟf?xk{ _C.'Ln-~VXȒlrr|%jO_G~-!V')Q_p#FHKu՗LXBOv;d'' ps_>3#u{:{*W{o=|_2ؗOhF U  ]mUaFyi);TQ+)BH/P\]Ҽ}]ƸmK )QK2_38unޅ=/.Oz>Uwe%xS :XfG?fos > ی̙'Ǔ{#>6O_;N')QM1W)o[,}ވS ;VQ|pVH<&zU~*V\kw۽o^ǟa?xoHӆ6^[ao~}ـ@׶,6G|)kwp;:y$6 }c( tF38wܺ"݄~%f+n)/&l{h kBrw{w9GC8}l14[O{>6lDx["NbO~"\'o~}O`ڼ-_~ʘOt;p$qpp?=vd!{4Z{^ fT'$08d`}q怭c6INKܵטk.%kF'+L5y1tHI@ b2[ ([W"q4]t[ $Z|ʘx{g`V`P,g^J1hbV:8lN3b A/j%AoKlRrV:m8ݘcLc C+E+lsGb33fjc|tID6ЖHkz予*{U8L1 P^P׽w٭4QS:&JTv.q{#RǔnZ\!)ڂi;Uͤ:RuhiK-$e` >S'68J2Yl찻o~s3s? O^+1%w4wW>\4HNh C4n`, ^x'Ⱦ>:~g5>D8^5b λ8'M|fH߆9 jٚ,@̧qDpzZ9~G ,\G$e@@gS e"àjw/@  #e)+wzy">ԻV|7ϱNۋ;Y@3 p#zFKR ergH2b'unW+3s[haH)ڶ~3ػ0 }o+,c~.D^%?lHĕs9\XK+9MPB gEխB|Ok)J"334 D5Hhh2Z}~ͷ~࿾~>Tmp۾ƾM-,,qi Cpxs=H~$>Za`*9x@=$i۠51okePcټJPjnC\|wh0(_lԢM-(8"nZ&hBS7,SŴj^IWTH18-U6Hk7RՀQGi8o~MNV:]Ze_`Mg2 xſw/h#$sx?@=StM *08n{W=xfE8<{3j,{N<G3atO} )[Ia1tZL[՚iUjYbЩZgP,Vdw";nC1˧8r8)o[d_f/tt?=E9+ +'+#B$.S7O}B,6_}y\ 4y'=-Y&F1+Lz1!;YъmRn3$A+[.G !*l&c@9ы=F5V�)c q~C"U oy '2@%NGIm)y}FpFd؟I0~_`+,wĎ5o5buy/ݽ)Lb:q#0F׹3(& 5ADMjTyI-M.=qK.ZrU~[۰шUCYu`cZ Կrp£ӵK~Vm⋆ƽ` 1E 5U`gW4xG"2%i`;?/} /?luYvn;']93Tb)0dFƲ@.VPxhr*7. M}|b=Pi&7h=Cf\kT!yNU\kwNٝ?yő+qmo_~#Z(%i&Dԥ~'n8uϼV)ΥiSTZnt"Nv;˅%x}vnݹחv6~pw;'8#0{+J36h`4f^7TL])p@ū̵U6/l<)0#,^ ]\񒊆@#e7;8n MP4<@]U h>.剓wJ|v1zJV3imR*%e C*O-<iskKkd\y坣%n]f?L~Q. %©kVm'%:aȧ~2c2r ./.ˋfaXg!}\𝄫>y H,Ķ|; P K( 4I 5FYPcis6?3Xϑַ&m0RcgxY^^Z!M=EKݥ":4vHw&ܠUsko.mtm~ Gl;ު}` @BhW!is&xO3} )e' r׍ܐ ߇''2pC~:Y}8X,AC~"6 )Lcm' bIذΠZM+MFqiCԸõ&h>ß>62&vw*yn1AeU0RUNeZwޣ}x?_e?+0/~ӑ?u;Fu`5,h{v1 @a[$/]P1M51;4eۤkj½_>0J7w\::%sD^@e|Mëcf#',y 4 m dljaĴݤR_C/ʿI#fZsU3lA5p!\xc7~xO]xߟ־ȆR'8^?N-nEm2@#h;ڿsĶ2oIϿ =ن~uCuُkտ"JZ6G7 8-?64ECM[ۍU%:>9GY  ʤ8-kg2@LTWOdRmQ& ɛ#W 5ӱY}_ȿьN}d2Ѭi,Ps~-J7.]KS7-"!b/ck.`lo6Uc )^\S97RhZWcnx@0!D\΢7FնD[>?h W'K :,?Lv#}a K WG@)YB8ݯ?n$UO?ST7v#,\n4B@ L<gí_q~U_m8Zs㦤WqM ï\/^ñ $5QQU *]1*)y.pk|{PҳlnV״w|Ď w}5vV !6ʅ$Z㩴/8mYc?394,"\127 @GJÌG# @h"KwX2?[wǗ,8K8;Ch?z-UIg w-Y @\yp1:Ϛ!Uޫܺ.t|ho xc36%ڤFMc.>Y/ Ӎ[w+F`zGi0A9j)%Z;fJEpX yAnF}ܿM$sW?[[-{G9U/ƞJdL/l@IDATqC0ʨVɱM" 1Šx+ǫ+B1DTfGwfq5} Ȯ@O&魀|k)3HSHJ׊ >NBj{)FNC QB}K~&pZGDXd$5gu(J?ԫKݼ0>N{xv;(v47 ?*K"o< \ Nk  o+5[+wa_W,lka&?3B{Ysї]èYJ_K5+)3wot-ܸz/fgG2Hɻ 0?;` tYA.e8 T]rIJQӵ65ܼijS_nMcv[D JW7<#:: SB; soZ)6@PneRl־3~o_cQ/~x@2D5P4zp/InĆ 9@klizX6SlV۴2|p_ʀ}KRJo Nz07jNN0d}-BE>6i 6'E3$ GO^ ܋VX) FwYQ%v34(чxldpݸ~"/]a?Sv xvm{EZ<y9 ӭ.[ZmMiBҬ]IfmxyT>ܵBVRm#,m%)4܎kw !{PQBCD̕:, l6ZAaqu۳DrDʺ?*P/ixkH<궘!a̰/³l?Ä3LDvB;HZͬ @-FZ]T|]W#? jNkZ#<ùeu攎 `߹n9Ѧ뱏Wו;#|yx>W]'8$[N b$&aY$"ۀ>r]=kYqo8J|Є+ 4aw;ĀOv RI\N&$$Zti3:(am!-DTO`zj^^e<i+&|c~` ~HTxNk;F/#R;dsΣl뀯ڤõvB]T'2qb3{h@/vv0~}*$!fkT< -P5$~*2K/@4/@QR@/U&heTHWw:iF˚/!J.ۅke~n/x:`YKI'-r#C_r#!M9;d(7ϋ!;6 }'_|-c>{3o8m sΉ__ )JB5rT|?g§|N!]]Ņb^ؤpLOhA1Q|P䧛=d'~u]a\]Xq5ϙkD>ahԅgGkI`mdB'~I_#uTO~)𮵥F !O4j~ x.,W..<pq`8RWψW@>9AbBbk 75%RPu{4Tۤ4H6uZ \e} UWRPitDW_wy5׉!<~-Q,bl,;pf L hs 7ڤb)A;@rT` 1RY׬aE>",hB!r|p6Mx@2UbV1ح`f(-W,'eZ;t})×!`mK6~2dv nqM7D݅4e|qsÿiܰmw#W:9 4XNFs!@4p9p>NLV 4"uF>Ogپ̀k\Md뱅-̳w-WW>O F>j59{0WY2o?o2O rfMesD}~b kJnȌ-,- vm풟A*]%?[ Z?~$w׻vHᣆ 1NsQeZ(DBSuwg&WcY_]`?+Tp<&9Gu\Y*gFOkg- JugQ+2@*֔4[-HU1q:JH)U5*\>۶Ҩ8u^q_yv"BWBdl}6w nҡMo nIFRIZI[@SW:^S``VuKuMGPT] M+'(DAp9AU팶vA_ßzJӉ%p?LpQ&"nqlY'Ǧc4nVn3,+pӆU:-[%H&? Lb$:l1Z:" (@%7a%kHY21]Ȫ~\ۤV.wt ^7 r<GP,g/@"*7wQTe^_G[iPJt(k$C#TVІ3H"cfTlzy/ʏa7lq_M5V~vlofYb dP\d1}KoGݺ̬D@] :?MŦFeA xB~bSX_n!Ʌ ݣ+p 3r[7H{6"q%i-IdmT E`Q%m煶f%JZ, ul'-*>^(^&NKm@20am*љN\M8OFwޮ⟹Yw᱿wQfk3B.kj .d >F{bU EOJ00yh`-&@#N,;&sYKLqV7Ӷ|yL`ZZpijaha(~P\7Xc_:l$mTmA~[RjVZsm"S?W>%U҅n܎ᇁfBJP"calۊ&R\GE];pEa bG[(MYыH vgY0-ʇYI#QG6S|/f10.\w.t6f>n |RFpW4>Gq#<@$e-<72'8PI7-3yQPrIԣZP2ޏ3*ۢ$R:eG&21T] ;|[m92w!G;>i2@n1 pts88*@iF]הߚcOj5:(F3- ҂߶ :L1na vTiBɒ9!D.~.F[ ͅRiReV F5X1"]g@ȍҵ fmRUPAТ\c(gwwyćoG]Uvm-Nq[ Hm-ĩ#My'w,V`qFA&T~Z[bٳDhmb-os(t&`f Q|Gye#ӛcS4:&:ws|ե\&\JE٣c'$ȗ"<c|ȵ txp=ʋo59NWRyUHjڼI n;m}U K=.+c ]6]% 䴮"^9<9m!txr {rQI72ZZlwP/0#mĸutfa֒[NYjX-{t`hT5}g4-VQg @,dU-*k,ϐΤ1\i|Ĥ6^^ oq^aG0HQ87ᵀB{ JXA9X%6 [~XxhɞnHUQ&`^!"]kMLh(`6(k6'_g]jt(P/Bbd~14E RYjZ!!loKJYN:)K7>R:=Ob<< T Lʒ#*ъ_d%/|Kl}y&K6an؉bZbVB؂y60~F%;JZKf+Uz;ZZmS^J47N]QB Z*2]tдz=^ߓkH ǝxE!$P# Ӊ6142 o{l$0i h_G4PC7C0.&JV8M8!pݪ(jdM H8՟Th ". ]@;.Bԗۓ'Nέ̳ٙ<COF@̡WoiA,kcTj8}MWhw[kZZS4*Ñ""Esb:5kp4%n$>-@QZ~Ͳc \ZBW~Ԓk9#DF|| Y}rMWq/Rv.xS\ަr(~hq]xTjoՔ-i^v+\ƹ_ybC0?Jx88VW0@%9Q\!l@&I"?'/s [ `rz8;dz"V.f||閉\0qU%Ά[Y [Ѻ}KWiMa4,D6xT(LoO껚GhwPe.*2@Op}TOkMہg;cmoZ/v յS:A4h;vZUy2ǩr|%?TuO (N1c+ ⚏̝;W>\a7tAxb6Ҍb;)5B!D SgNeȯ@4! 1^AZR0/eU=1h;lyD\]~grKIW<2r#Wx@`AFMݼgi>W\ãfѿg hk|A@>Ljv5N:Dȁ y(A9iqRt/ ( )6^t'-PIj\s<+3Ϋ_ ߧ}. Zꂲ9vD;;5W E6q--uƆŗHOvmHΜ= +,3R剓)k<<=f^g7^}i=4Kxv%,pTLmm(d;ux?)p3$JnmH`:?3)9Ax .ʷWnXojcx/Ye@3D#8L(Q,'`XL[lj٦kW]}Iq*CO!+z$Tt U3I|6ޜlCfwffFޯl/-zd>.A如tLW\ nu u)Du)#M_YfbA=.%n%F|RH!X[m'Y#,BTkj%!? X-BM6=Ε*mG_Z"$ :uQTZa0} vn ʖv{ps:jf?@Bu JkҘuլ7g*$:p#lwh&*2C;ulDž&]JZ7@7-Fj1j݀F[UnHN &ȞˆHx_,r 77TVUtv IUMGB+xYdS`VY| R!]Ա{²!Jrq F\񍥀N{~jsqzP܁j,/9x[[r~Nm [כCAX 2㍓,"_8zżqF"*TU|Ð-ӆبdtBtD3$(˻E9: m$EEF ,\d1I<.KIq@Gg. \\[@ (5wNd²Gl׏v ޝ3?N\{:|fX 16P-ԞoJ=4@:bE,J[r r9RkM\?yLkCd@Z!rࣟg<3ѭ5prr憮GN]꺝jmJXlen5'*9`u+fQ0guJi@}yL̤{v>w]}[|hf汵!BN-m/^;= s is6JC ]!l Ӭ[[8jnзUѕ<Η>lFUҪjZ#5oG=Ux[ךJ]9gK q0w6XDA T![K){9E/LER#ctRNp.1,G~&>dm&nv`AIl +wq3W[?ޑ[ Fj'Nҷ[APACVuGU5>?Em*<٣O]YUhxSv!Uܟu3Kk{ro 7 * o*$n7}Ra_ʮF]6?ȂwO]y) P?M?cgs!gȥ =^-YGlW1f]1&8+UAܧ$o (`'F&Yf}tpbSZBVk [T{,Fc9ORZ*8B'93P/Wh/^;뤾vδzJog[Zxη<|ru8JE~= o4Od1/yㄝ7ti1ߧQ_%~Sb 3췹gr,w<~/_wv_NJUT,~Em9LjQ7|qZiU|t}W5Tu(Jх!b I84!?c0ظ H&<bqRS|T8'dЛ`_İ+($eOJ"/*nr@$@RbsGAݮ W/qw mHǮFR gmu@Y#aM0Wj :n%rfX.hl@%DQ~ R#ٍ]x<\I\_X6E:v%0頇Gtm9p؉$qt)@:HE$Ƹڰ₠ .ڼhBK<{ ]FwMp,W$SG]Vuíq¼F&ʸt*Usr #2t8OխJ( :#@JX5$\J/M@^wAwXy&@ީD_(;p&hNO]+~Ϣbr\ lKhAX.K5db#|LJ>M6pڂp9 Мƕs9p'i@# @DX;*#KD`xvAoFO=6әw^G;_Zk0H\*mͤc {z {?b-tOimm$r( 4psOqX \"J}u$( 6'M)R&oHh\ k:^7 9 ݌;Jq]vt?m @Ӥ7=:Wa2|OĊZ`k~?X J\>Ĩ+i WWki= Ep~[d cBЦ 'lN#-R+}ߋ^roMyRtHFS!" VZH 9F갦&E5*ʤ"|-*s@vs%]nJAFΘpA mκ#pN'Qܷv,>HT~`MNc+,`۞V7]%wsq߆BT3%zj:A/܄hIJLufY͈jq4U%68 Epo5Aad,<IHyjIPe!U$iL.bWf1,x&4C\ /5.EXt+ڃz"ܱX{܉kkr͗f y]qgZx/5/5s#ԇ–^0AE5q=5=ŀ>/h]OΎCM6G%˚m7DH=~>41.hWuUءUDfYTU*F-+TU!fY7fLv9 !x`z$꧇?{q`0$ HH )qdQ:gb?>Jk*R$H &7z 0@37lJ=b?SYgem0¥藙֩*jʱW(#%;ا aJӫ_<.U:% xP"2]=)`qnB-& tP 2]b_}b€ %1(yIL IXTDUׇ"ݛ-My YWoHCS\zѦڒ~ȮUI˻rEwʁ7WO[c@\ke2t^6:aph.'9o/@Fbҩ0G~7@:"8}$oԮ0 +UEI~(%m d@ $LAP\Z9Kt_Jt8xoµ?Gi~oG!Ά*g9J_G`ë|!J*?4@ lwTc/`=E]q_G"H / z7Z {ݫ! 8 x ZhGp*c0rK&]&2*Q%_f0$E'LT>p.~*Q?X☕KԏhXQx찏.B^'^R UQRxH ֲǍ>!{8EAl#cך?mڧ7(sdɔa$EY^Z6 >>i}Y_d_b@~18O|~Գ\"e|IbQvxߝaim6,kY(mw"_8X[춺!6SדS-dNb kEU e ˭̎8k&Zt.)~ze2)])yG3AǞP-*"]QR?;u;-gxk@8&Qe~[X&LiCN:@|.*eu;!yRCըM&I|Ih< 0p[ABy"9 eHE:$ڒXW{:-gJ*0O"侓+zS=<++W8o)$ )KO&nbg`Ȭ0 ژ]gqv|C< ;&Ž ` ~oO٢ӻcIT)Ă ,>l~jee@ȱ̷)>8RG-{烐1(H'cN saSR v& Om:oe UEEX&?%K;tS,QԪXlh*zQHT]fp^BqLkk^l+$6?i0+߫ BftKc9#u46P7Hsr bYKq|\?~gv( gnväG_.;'%:`Jъ>y *yu _Z)$(g{*%oWVR8|W p}zK0Kϗ&N۫{jkmg틈W^T*~iFQ*aխkkl :5a_[-Xj@"؅`}ot\ߵ0O'tqm)5.^kM =0tr6P[ }_Hʌ~ q2u^cPE9T(ĪDE!tL;9t b ab\T`nܯ*}2-/B ׫FZJ쿎]& *UN\VDl<>5xQF{teMz֛>7@R+g=MEuqI.5nx+^'mOk3JЋe^`)[ Ѕ `G?p'Τ SlL]6&fslҟWg".[t ) GpǸq@߫F #K7b5ZZwǴDꮾ^JP!B0n*WznYmP O68~;z,m]:5 t,qRb9#.D6L@\1 @Q),UqB!>߅v> tL:B">P Eq6[Ohx( ?֗le3ni .[CL~E®Ĩj(ӻi٩kt̺\bXʜ%DR PHeB$~IiMm_gjv9rlxt5:I5OLL;ص2˸%H4XMX+4Rlh=I-./{0ƚ; s_pK氘DF.!F|8J)mNVBgn\hy_]1?fewы6c_)8)3|0=iwq 7(t@%ƒ\%| 3anۃ`uD-Ƿ4,ۺt2 'ܯ%c PJe(ĀNt`=C]F>P)eb)PfZڒzxYͱ07:_pNv^[<imݸZ#2`) 4/Te'OQ6'wq= ۭGvUynj8"3=N^<]3rb&}6h q7ugƷ*H{ \'~twϽvо~}#=J?O]e%E4gO'D|T!UgRhfiւ%ZX?"G鏞@sda?t"JfjB`G&W,v:+͎pi>DWƞz^aJDEBz02kD|PTHZ"Sc@/!@v6kb >3`u*G:^27^9IT[o. ;=pn\LH"/U+q@_q]pUbDuMk<=^:3H&hv1NwCK'>5ztg$Y Zsk-a{RY`+Rk< Nev?~P&W钷1Spi} ^R(J@D u@3/:yb֜}4!uSjݠ fc}d|!E}PBتn+2HOfB=lKE,g&,DYԥ-۴ 0`KICRS?yI|6PI"5/dR@A G:*, $tGx&$Gwu9=>tmZf'{oJdyƐ C$}i/TLYE=bfF$ܔ380#hK9?WpUI*\Al#qe6^`;'B /TΙs萾 xkbRB*x׷9A*o>=>*/y9ދ*ufєGN ,*}Y4%@+yx"2T8$~)ao {mj '2yP&D$>%^oB=!"wmCZjp`w'/譡˫@_xq>KYԗj9x }R{LWz`p<8|\_kB-F3thDиѷ@zQ*9Z @TJ Ulb K/~YoX&i7MyjӴuܥa!t-98}#>6׌BNaU@ئ$MNr!A[(D#sWG}RgRr-dH)mE 5 ^`h۾hاGn(_xjv e\˗|AJbHȺH(MvPAl QK #t3Bz̉LŪti/GOWEy%JOM+ݿَ,%kvߗr*2!hl%2Pim&~QMö,/:Nl ܧ޷uK8K$~~+WÇfs! ֑mzpuc% JWM*T}}:WUr8AE's?hFį1e/NlQM ud`Wy;bTXK^2(7X~Dhb*a@c gtVT~&HSl+v# v Nပ"Qޙ4u>iav¼q|*`߬nU+9lɠjvEU3QѨJQ(SG(CƅxhyŠwAbnGJ u!w : esTIAڦBz#t3G69a:v Y}'%p5ST<%!ιk:_Z?U]59qnf_{Y)y'#k.-mZw$ Q]YUE769d]C) "W"+,R>UN T7/i@˺:o :m+zAvVyBD"u<=5qeu&\{fzBP $^R,=1r⁒f'zW/gکCmr6zQ"OD`(19n}XvxnICx/ǏÊMmJ;2|ɒs=IXKױ6iT}7~@k2-ʎg WFH.w[qDAj\=w)OzNY-GE%XaJT&%6}zq%9\kBwHP,Js1B.:'K:nL.1ttmYs ڀ'~3aҥ>inf¼%ߥSoR욵=w+z4>3Z CBCWQA-HZ%, ӘMUꪢց jDOM2m]& 09`I(p~vFYyh6ݳ.,o IE#>ؙ7r]$]$TEvmľ%{Z,L- BT-iRJtlNڥ }'E$;8@\xIKgXb<{h/cIpt%jdN)&DTUt)g]"_T񐦂o꺵r,445-b_[Cj[~CLmri [2=Umkk`'9\01r~.֒+b,Re@LA-qH쩼> ̷} 0m~frDgɝ!֋JM\?r30Yc1- MA۔5hp0jH&̙!OdJҠTR%Q?thBcPւ#^>{DcGj,*]w(P[jAbsKWY:04pyAkxK}GfѰp)rVY/}eJ ǚlNudm=9 2<˴/҉_2,[v gn|\|`ݭwMvQa;*`OxRZfD@]RU@[lg`XZ]CQx(i`?c5}P59Qu|J;xd2O]9q:I)#3}ǔ:ezX}i` 8I, r~plqr7"/T8-6qjW@n")=KG2iY{tPvrk7~W_YǏ~__tlme|`@O']o--"X8jBN[Fq]j]hxe )|q,t=hOFtW~,\nv[}2Hhn~nL$GV1SԜWN5u_/uvpDt"*[涐8\;̡p2!"୍$WTJ,xrGt3͍NGs/9g>xhN~VՁ3k񾠽пBuMK1rPvUut^`۠(tEFO YԬG.LaB[nXWJz"=ܦyh0l{&Ka =npG ԭ9/\r7GӲIQ{]zjqEl|c_$>p'Ʉt<)Pۑ^ W$툤·L.ͱM)<1= ~6C#m+fipގ{ ͓\eAPzKHi2fAtܩ:C Ql-ʊ- A5)4m,qrM <ưufئxޑG,+'8CP9qfeS DH<@@Cs@" ÅO;Cz' %/pYPN!wt7Y".[)_R#tC`¹ͯ]kֈn߾kvqزjl%IUY*h#}eWtְK<1vck5| kppӿ6Z_:V5"`{Y(W9ԩqeEK.OHqܹULӯs }}‹FFè` UcjbgvФpEr! $k<$.e#yS+i9s!$rIb:ƊUbBhm<Ą_xyס}f}7n{iлZF,2r0rR1m˰u[uQ#X&)} {T 2N!jX`y̟57E9zjbwcOF `~17e>/B!u5LηAxEXkʡMaiܦi%A5iٌO޾ spp`{hV=?*IU8KWgG@aW 5=$&Y&wTY,$ey_Fj hs3rE"C5 f鳔2';}D7>}$N}MDm:KP,bL\03 I5ꢼ((Ҥc >Fmw|wmdɥ JP`8QԐ@ǵg(6vB`a4]XeC'q; Mpn,18qm u=xjVAcCC*yc ]qHr1 ?zEd g۲/Ab6MT犍;k6!ҷ~ ,p&Vܣ֕L#Yts_¥W|uOy5uyMC^2U&4Vj1nQXd=eJ{ r,ZYN߹kx Pٿ\&!O6=[;R~ ˇʉ僶 7xlG4j[L!^uXWp`7WE*7\(dqj<P\K]814'dىˉ;M}I{2M~.Z8n>\Ё8(% ƪ"#S $ݪ/'G*o)mx$p.4Pi}# ~s_|LRsDgнlLxтq HK1 m+&#h:q8|aRT;Bhq]bwľ[,Q\b`35b,$Drbv%oq.}o_K۫AT,{嘉../LPE(5ꘊ.1(mVEI\v>СG$R#ϒRƁyS`"ak.E9_?6;Wқ*$4:][Yfn̑ϱWz>YA/x 2ATU f`Tg fƴi=֙37X&܋~Ae/6zvJ(n vU7sΊ~D_J}z7@=H xzɝApufm5/GFt&M'x,Tbd}eMu) 6fS >$Tj4;fg.o}Uz2Fw;ҿ<|x떹U²@Dۉ[OB',Jj)jLLY/br;a u_ p|!֗j /q (S{׀ MfhSv3fY4E=n, 6:i,iu!<"Ǩr!F9̜tn4I_Y6U_5 +tC_@ʀ!ybZR:'4A+F%2泣uM pubxu4*ET!\Xn+x\ SVFXK꓀`E_zDm0C7 ʚ͛&Bp?8V4% IXW" yaK(I.M|(WB.yY,b$բIˆQx{w!v;ۮvN. *0auL̀?_dvtzؼ@ o"i!4P Лc` b/3?) n<|~"@AEI(Z1c7 G9@c )z?w!_9i{,Mqi}csa94?tr4X$Pvap&{U 'ǗH)rĊ25)׽/( $H(_o= >1tC5 ު@¬7nW]ʕ8?|`$q]8!X94xc khnȷ X=FD@ыQOyϿrB rB& py@;([PYW g,[G~C9&&8ׯ2[3ˋc;)`oolo2Ox6zn:O]~Lq< OTʆAJMnN 5Pܲ@fE=br(C0;JAP1LPmv?m ~C$^q~edA^މ鉅xy@`C.`W׏n  f, \Al*~DJI:xN*1\u*Bc :e)[E/G_O2q*nyM Ƞ/ysis^4h>r;_f ;k惏onlm}"Rz8-83 lb8*yi)VN1ܢ.RGBqu0U p,D87hPutsv$4v7)1Q,@]6̌YZ7;;@lrHYpru،kg StOwEǼpHA7$HE%,Kma e-Ͱ\s k^̷MBGzly"vXFD0TlM}}rЄq4]5Z) lmUFgi<cS 3w7]dT3dYAQ^gǥRJ岦S7)2ie*3j8yX+$ J!q-asL6#{bk'W[b{@YU]ݬssX\3}4رJȭ tBҺ@*XmW̴ X8HЍl@u.& $=VXhH*Ē?Tz@>s:VL0rM0=gZo.Xd"= v+6Kw؀r%RO와hЍ|yܲY7{Fx JL/2[[fkwlҙ]zK&4Ҟtk٪s&iJ'*CG!g]u/ޒ ySPXNls$ā Oƅ Auu]pX襆QpuaC-EIYwYgm)]AL2*?.eΚGO&UWVw &^:ћ7ɀ[Stk6n*Lը {RѷEJ)NX )vGC (l@,*LӢ #V"ѕنp931Ssϙw&cdp)ml5._6ء|\OЫ7  U]<H,w+-mu 9m]Gxh0G|VgFiAn:ˤmWa+uwo~ @/%uEq%!%e.qUË6&"H`@h.ujT`&_KE>%dˊ)vT*%2a.9k?~T uq:M; t&@-ylho {&肦G[2ToWwA` !ٱ>htXI8,+,ʏ]#x0g 2IK_TBx\,{^\P!p-P'$ۢoO7}!nloߡ;n#]?ik`I凄GtYf49L=Hd͉E1m"s;D{򏳧.8sY0AitJ=\ \V⦬.KŦ)ܲu0]4 +x@CejxQkًYͿp2fZU\y]dzA &t)`]>l0Ĉ#T IT`d#N[0,9~-my|A!J 0+/F 69NJ1᣶@x25RbUx@ =|7֤ya,LyK7_/_GOӜl&ـpރtN% O񨧼qB߶5xsnܥ/U4!d6hs ęLٟ52ϱ`p3K]ki+Fzr>$yhRdW=AyA-5kFb⛰89|ܺ{kd Kͩe3k=Θ "mϊĀ3&c%A4x o/D^!@rZR褬?Jon dyg+bn.=ݨ&.ynvr鴹xloc=8hݬc>u@pԝv=*|J/"'[8T|5[ƬN\M钛JK@RQgŬWM/]pYUz9z:u"œ9]@q//eF9ΓLQ!S\٢HH0\ G >3*r-{Ŷ@FPRLgE.@g'R8k9働)>"w(ҙS_hyyU3˰Fsgk'}jltO 6C/nmۿ;ؗ!e6[欟 ܴ DZ wGs.Bl1gCŎ"ဈIWxi :񎀛+=ɞ.iRdn_/@ UE ~7(*Wj2c~zRvT,B.P @Dhexï}c Xt yH5e.[$Q_c?kfs?O{[w>23S‚%M7EK2}YZ/Ɇ]dD/~~:iUXj U5`p)-!ljGWwv}HUڡsTg$#$=80t~B HZ}) Z0ua$A@H %x~i u\2X[<1t?fFoō<:w C$h坭Xp #IUjW,U[6')%(c[_:s8q2 Q>2?pXzWZz |}s3~(Ǥ@њ"me+Nq{[*OJP/KlJ} ~"v.X'o?1?{LJH&/,WC Ϙ9z7J=*qSsxOÔQPj4(aݙ3lD1Me6U A #"*L$ ?acz,pT0 xo^@EgKǫIseenmV38Ւ 'L)uR KbDqqp+ef\SJ#Uc}/u+#`U^}%*j3 z?!pgg<ٟ4/[(Σ*LѩS'fstF`~n<|/Zg*0^4D_I[7*$*Fq5zid˕<0 HppH޸ gvcpVX !IDz,zCWxU~~wO0|&w& BBUYGRrʥ, 'HʂX/bzqu b?`d -r9spQ'딣Nm7^>i%S~v!uÏ4X=/yKYصB^TѺPUBKpΊҫIt=@![D\?8)V9y<դV93m,b.ڿn^~, veg^8qlV=})۪MTm#n <іi g >#HG Y(*ڀ#V>oGxy"|l7>ޛ25!(%#py3ݷ. g,wӷFsɧ ~g'wi>cW~iZ{x=+%guʅ`|Ǝܺn+'|-pqa}tNPIIn9z }lhoh4 )_+5 *ri\݌q@,D;衺(J)*jZUSIp[* OnPǑB!%z^t ݏ@p&>D/XZI^;4`ѺQYZ0KtLmdCnCȎWN t+CZ-YzC Cί 40yz^ǵA&83Aw6q)'`jHI^YySB }dI=?X_y"<):*]2Ee(Q";AVyj,^J)p44 HI|>:A7F-A K' w92H,M챮dB1])"hxMG4[%u释iݸ``S\lLѠD4#JqX]&ʼn$Jg+6V{&xrGȠ&]͝)FB~lЅQ* t~|3w޸`޸v֜XZ1=Jv'EOalw6D'id,H}~@A>뙼Kas]hvNrlAQ"hE Mܰ9=_688l #"n~@:t@AwIzf7~rz\z!|tk< Ђxvml_ԈOG:v6^ƤޫOhf$XTՋ/ѥMs-HG4ڗSOws?2' ?y[znQh sRh5j _?^ǃ3yy`.涤ʗ7p*&,ņi*qEx `Kҧ~%<&<s7`Й/3gGm'g{_8!{+u_<4X=Ԡ- zSLStvjky4pcFy&8}척;~_~3 /]Z$pbУDP_yg>媹yiMzZOzJm?5_z zE]T@;h,N^ ljdt1z8Ƒ]5JcZl#;v̷aVW@2Cͯ <">4)C[ ,' C};8lЮEe؍@,15@ z+ŔgǾE&)jx>K įM{"Lh;ǝ%6?Wx`rzŧ??_:;Mϟ):ߛ2fx0|6}޷l>4aO.mYTOSxAS t͔˓f,m 3O s\4=ZTgo?94[?"妵OgPb{sfJg+ #f626HjyL~Kwd,0Z 28 \ͽ@IDATAa}Fﯼef{7ǟ 5:.@7oO?^``vgbS+W)}tbDu>>IX@I:M PhO6 JaP-!o&M6.D(jdĩmO_=鍂o/`כ)giP6g55lݶ[82ph3݊Iwn$/`R΄Пij3o_5uz)ÍC7?~l~h{أ~ty嗻](lQ֎FXt ?p7&Ǝ!T4ױc%a(+/pEU izi'tS}V9gLfeu/ I :O 8 9w%S۾ $\j5REī-TRjS3eҩ!k o QXrh6KH'jD4D,q9TS~ĝ` rw1a 6E+1 {h^0CYb1j 9 P\4 HUVY5l)"I<ޟ{I(hsqĖC{f"b(0ITEMz&N.^'no O*6U|!L*RA,$]AT4i<'\Ańsk8 acJ h#GKvy"0e~=knϘ]UxjNgfƇ f̻hn+h_ ̍ pb,[\9voڢQHGRO@@Xt{@:Y P\.bqr T(AN/M[->p+rNĻL ;+k+N81 s3(br+3t6&[fG2 Uىqnlq :rDD)I3dٞ^UXꝽ tXQ\bwƠN~v:ejeE2x"[ꀣy94;tv`֬Mѫ$ gp:4cyysfxh<6tKݑ'$uo+jb3Լpv@@5Z%R5 w$抒"(")#_VW|ʒ{SSE.ez*辰0ybhF`ڛ1}*t=Fg.V>>=3gN̘ׯ6gϝ4Y F|m&xTky{mu J<+ bAHi44 gv}7`i~)4p))}DhHCihRZ:J*`eڲWw/gܙ Ťĕr3 TFt5m$UB_\*EWuT%-JamYRFz{3Q7p w1mZ3tf &E|{^:7o_=cn0(߈]u33CDYn;AWgO^M9IZˡ}\ 9T8!x! |Y/A=aWNS'OM=Iәn#8'dhGSng5 ψ F9D!Y&l U"2_YX1XV 5rYē|#t{p){>k  z?Th] vh'ܟ3g4?2Egcy>=wbq|Cs. -zQ.x&itC܍uP+5H!.Z%݃ uS,ޝ= KdxM8 n¹t?$൝s2u_T1 tZnJcm4Tޕ]$Dn`uUamYbzpS76;]J."fS)Fr9AR02Q-a:AšZTDgV9Ŷ`u8pv`xx0cn̙/[oЄ.`L ;|^TS/^>c~G#.lnoqJ빶jQ,XOnYWc5CL C ]tB)|4ֆWxNKhFy/yGڱqM<}U|Jg 9]BYc!Ya{1aשO_x$h^tٖd.V#[ URm5`Zl(iwt65c~tg#Sĕe]I4 Pݼuح>!UrU1t7QD2E4<d Tx-C_ӌʵMvY{\'r7`Pl9[M-81p`!x@x?'%a5I&E. 2dE^1> D[9|  KfyзR3Sg-f Yg=qGFJE)J~"P'2f9,/|of~9}fGm8mNwYѼjɃf.ҙ8e\_И9q֣a4g9޼0M4G*}U܎A&S$R*[[ Wk43p: /Tum%1-]"plz F%Nʌց8?=uh2A^DqIAF6'K(&52tj -6?;WY9˗?7k]mKso gbK)Y8*ƶ=c1F{I`[u!51tIT=>j_L@udf`.Dduc &~s`4ѡ2=y/Ο0׏9z0*pP#ID[F] QP% LztB qdy|G{r mSH#KjSb|v(o٩c4 2(]E Y '*?y8ݴkf!j%s*>"rexٺ-_+=|.ϳs뜳ȗ?P\Z}JY Ni>VB)^$0$O ib0$@We"8+m\3jN6 l;X݀uQM6nWuYھ-ф0\wJ`+)\MWd yHy#%ٰ>rFBO(K7½*FʗlEۜw,nqan8!3;ce߸Ϩޣm{,ElXiJܽ J$dzt]C+H )9wd!Yi4 VֲOiCtb7rEUAV4 *a0+߮|`?WT`qɘ^2fN=Q[M^_f xdC>:eXNyUHΦ.n͙9\PZ[?_?諅a@0 x1^q?l'eP{& tgj0F(d=&}~N)Q>`akQT[ /Ut (-Jh_[ҘVC- 务5K^X,~8z 2Z^Кd|$|lA9D&8? E6O?U8Q21e)%K2\̊]fCfL@hm'/._h(ZRns[Jm$emIɨ$;5 6p GI$(o/7n$} >Xس>0 J]U0 X}p/ɛn;h~=>(?CN_¢ZmV$eP{A#QlX5n`?)W` b%E.~Jꃈ$ El8vljia|_6.\4ר;3'{85l~8 kXp=m._oiS0ܶS;ޞR0u9M ^pIU׮HʹM7Jn:!2!VgJP隅[XׄSCٖܱ+u uhO-Y'u8n7Q0 ℆D(CGпsV/~&sxi>.O6p2xcJDhCZDy['),fj),8&L“\xpI(p;6+*:g 9TTAc9>洵}`~n&r)@Wn[+d֊<8iNV"+J/E2,8|_/w4+v gj^kH^Uhp u*k;#eq"ò:t ): ̉٢ Bkq;7>۱$䡤eBjdmMz'ls,6{ ֦d'Ll,k**,1;_i:ЖwLg`@V2;UҀ4|tkQ4iu=qAkLOmkUM%;͗4Nْt&`r ?HǨ բᨬԋ 78x< q)/p[+31#Q2e"Iq@louOo}-47*wNWwڃO95ĬF\,\Tg gֵDZzP4:0 cX9;}:^)G& .[ ?A/LSW;epH /y  12GXUUE]Ӑ:Ay10 lKL.rbz1}T>W7^ׇyS&>hu+-|Iف2(8Qbm ԋ}F8]#OzmAmmӋf=?ǩgcLjzkFf'Pkf>n:bҴgOT̴S4T#9I\Jp8ܨkz'(TXAc@6f5Җ\Uj^4!§yK+QaA|#<p~sOȋ%hi{{=o?ul{أfǓL,"66&Y  `'vuԄhPiH{NfSqSvP'WJSa$8>~K?{ӿ6}.3U~@.a]"nXjl%~hSGhWM5홿X&auA#z\ϤH|}[d=Q3 HmeS&6y!֣Jio<(j@$LΣ6NaVm: 4k-+k('L 8J35⭀_J{g;3+kog݅.161c 䩗]#witxɓv ˜c^0žf%; flc{7,B!Y n_ǒ!I?{ߘݷp]Nt%A?d_Ȁ@uv@ XOl1v f>oDWm/קCix~Hѓkd@Fiqo|m_ X7;PGbLqkh 60U}MUOǵDڤ$= ("e2Z"VJV,]a/)@ aI1ۊRO^NՒ-vIOC^Lvyv8?pm:Bô7_to]^zC90H9n1ZFcc;{@--;a&l9Acs#<,ڼL}@HխisŀJx cMf@L2fg_{}zpWanݝͿϾ;iƃ.V }Ϝk-X( 8fV_I8 b.*s _66ȣİGZT:Cc5,S(yrTEo19W2ŹU fmnӠ8trv—*ye BDX#/>%>9L7qe;fnMQh9;QA҉uò~3.<z$fzOA &yƬ6ng|f럟]|xw'?[eG<0ÏԽ&7 g...}܇̹\0Z_ʼn%PpZ<^qn V.CpVT]e^YNHzfN mw/ԆMMOzpP{'ogFr*?x =̀-¹S~|OƑSԔD,=f:i1pRQ)kr I/-CDat?`ubdlT`iB͝0 y PNYe/\s }S%?_D; )ן=1=t' ^鏻ЍYlcRkKosgsbBjXM~EGqM28#|ڣgj:0\?B5,?< mo㘃T?#&o.WF?]{BW/>{M~Z}ηYAnܰ3Ǐx<$ױ u 70DXm(W¹<Ww@ԖE3[immvZEvTgXO`]u2x^оW냿(wi׺s ő//^z#睆u9}Joib0?Ⱥ\}4ۆ쵌Mjіȫbj6y>%'c[OUu'R$6#+D؆ :vPc0swmɨ er[dVw?>+W|у\[ysΏi~$4lq !&C]|{ YlZ4\j c 5Mw p1b1 Z@|l||7c[?FFVcmGbRuC2[ZK [-ZpԝtL2(b ->1,"wən.%.حe601Ol.50f 5ȱP['@55Lך!,sW~M:ӃӏLwD1?῟7dѲm4~]kP íw?0ռp9vXX%:D(2qіb;gF3qF8*mѮ;tZp~<ܑ/O_ 8jǯ~z %80 1W쭵]tn;/Cq )̉pkINNcօh#eEc3yvύ3LmJ1?NmkVi|@VA-(ZͻV`^5x^.FOha@AYD!ꪦ̋g}ɏ].5;x[ָ'sc!9 t\O<ة}9euǑfcU]ª-AК"kɭ*CJԣ zp;qנSq˕ײڵmтoG-{n_NiM:*cϹ P+ޜҒzp׳8@v+5&uZ} ?t"\Sg)B'jChqJT[c5VZymcPQptdظ#~Ί/݂[l= ":q\#Lڕ^ܕ/P~&v٪V=eZVǖzwBV<(84kMZ[1GhnP}FIٱƠ"z :yK ӂJbe05}1c ᘁ#5&9̘M3L/MzS+g<_7*ߢuHo*ꅾ6jgG'44uOga{6|;Վ+%S̙7hpY4C6*%ڝ 3YSќ(VUDƖ"I]@1ѐҷ+FEV?ݷ,Z-UV=??gY&OaHJFo۱Y$AdGh&^H<8}VWKg瞺cPx;s4ZwLմ4>˪f?%V91]GvIuN#jK1UQxUC8`@-g"=gv&@zQm7 <1#t'X̋zb4Z0i]G y|)W7zO= ` O9ca8=k&%n6۲GCTbڰND-Zۜى׾hm0Kv!%}H;is˓aiwV+ \/ ct/`}۷wߒmǹ&w^~RRcvpuU}FjipS"~GYt}8}C;.B|"]V(W|=@4vk$Ce1,u){{5C[zEҫ/=<Üz1.9Z8f!^*3O+5VgwqܥmSGȍwZҙH$X ͱ~;aؠzF1vꌸ=᫋N.D +b1p7ɤ'Up2[x 5( `kOOZc# E0`_6ڧ3BNVOߝ~nOW_|<G]KYf9R#/MT(;-R~3y ~]AKѶ]ڡ>deE(GAŊ"E*;NV[C6ڳN 럅N۶O թʅbGyiء: Sӝw)N7qޗUX N.u~Z⁎'pս,," !9ƴN*&+{қ%Ւfk]@ AquGbBf h+}ńcn?v}Uu&,Kx7?ugO6 Dŏ;1iO=_=-{h* T1:!՝2lFpp%7Y+&GcF~bڪ+$w팴gM)±%%S),n-x+"mֶrutdtD"`J@gPyjyW=v>j+SbU#/ Ar5qw?%Y:#>yh@Jӊ$G;{דl8 MAt㋌M][Te8.d6пW8P2f@|bc Ν~_>Ķ+눵Ciowi.y 8JU*xwr .JMA5oGބ8* >#;A \qOLk0bOiEqGt.GvѶ$F2~J(8\w6-Ăe6hU 5 wx7a}`Kb_&x%6G~_xk 1F c7j!fҜLtt1Ldk&oWd05@$4Fn eplP Nr,$6ZXe8TH$-M:yZ~Q"`>.-zíY~4)kt _1vCfZQ`m'R1;lNԑO-9֚iT[TmN. Whc-aOZ f_^+uގ JCD-h߭yMmKsGf޵\1;n\Ove$|]\Ɩ pEץ\pحf7j1!,&fuh!̰h0.Hsrzִxc+ '^JT@RW4ȷ~O=]9;?{{~F5 ׺`GKv_ȘtMg<7+cCk4YBiO[l2覓bձ hM/fPw* +lVMӞwe"@/8հ㎀nI0S3ӫ/<=|ƺ}1}?ݺ^t }+b>x\ls xrI0iO{Z(67l.mLrZDDkj.y?R rm 0Tfg\:Vڕy+S-ꒀ#9N jfs1ZT+ KEz}@5p,0Յ$]ۂ iMت@IDAT~k.=bhfY 5Cb5pM[n4zl8=JS'k"`\otU Q4%#h~ܖQކ@!\2!.U 1V-K*L kD$uxL ldіsgO vqcOpLm ?z`kB7_}f@_=jf<ߐȣ{վJ8Wy |. a>g(/7t$ܘa*r_#\'*dۀ[t)]Gw(W+j[$` l,ujg%#N[T=J Uvv$c$jofZ#۽+'-`])o־ZFȊ˘uZiv 9bV:NQfcX jfrҩÏU9ž# 7P@Yv @HNJq(n5&Le$`/ љG7~vκeF_o5Վ#OK8Fpq.fPŞш}Zs4TsA6c&>΅clˤ*.ɠ-}ikC- fms8UB@ B܉.9֭٠4/:[IEF.>p8;ts{. 鹧G77uDvwq@u,m܊KbǩiŧմNo--S1\-VwvXDT#fkwMǬgi .d>jĒSx d?^|鳿1]zw5ݕOl->|r Ac-w:"Coi2mb,5.h3ބkwgAQ]CU@RvE ~*2RҺöeB\WF^2/}+_<Ĥ7>8Ypշ'}`kgjk'96Qwm_{In>~wi&&lj~|!@$O8ֆI;wbQ[Xu@ظ `}؄$۪??.pp@N%>#@giX2 u-s1cwodVH3϶ҹ+}e ڕ[VqB#oW],%Er,)v}}|F?ơ@WT}UvUum'v9j64 WԽMiݎ8l=o[21LlUjp!a4 |dD8L`JfmeQix#mUy ]fKާhClN҅33Q㬑qon}# , ;Z7L?=5F2Pn|?- b^.AV‰\uq &wcnb4u>h$x|C!|򨴷Ixf^Z/@l#~NKt-_v7rmv ~ g鈗-Pm.Ȱ)sQU-QuOKbsmuBT|b[R9[e.Z!=UX<OwSU+!؇1 E KB"1\0ǃSWp1JN||P%*+#MsϟfK$' d)u\M=)e,ÔUtU.k]%'O8$ÂX Աs\:kc1q5gu>y'Oy? H5 Jslw'y؆ir"Him Lt=ܧ~F ^QJ,$"QQ;E6-mARZ6':U4+k!7[4,G1Q2-@$٥.͋Z S~UX_Te˗{sܦ>*z?+0?|aC0Ό Y0Au]Dz" 7EieK꣦@+] UOj$@( V |yU<">ۜ C=8\ү >~qw)FO|A{ ZwX&W[\u<|H $3'wuFЍ1xn#Djݟd_$R% DlӗEtopKAV P-n$52EԭRG96u,"㦍6Q:v_axpiۃO?y2~~Ӥ6FfO8$ ^jH~rJ|6:Q]xc yhDI,u1-c3QBc!f ҂|EBz`h~vpme_cӧ_tyU0bYw?>[tX֢e_u$q'8v9VL{G_\#Uغy$mSzNdvRۊCSd=uDSm19,Ky }]9:QmC|\:8aY)$Iyayʶo+\xݼ2_]Xncs_؈f'}ewjU6Fcp :Yĸ]e&jkUnjijqXbireEߋC0oYPA`lI[kfP|WG>'yA[vT?z{wj/} e^>h}C?STܕkf.65@~M'RfעΥ|V咕rSMTzl4BC=8V$9穣P/* .{'CҲJʹ_ɼ 8ì AR1Vk¶ v_| %@diŊ\yn>c@7Fw.|(A}VWl@.U?}SȢ;%.t%ud '(w7d剢Il,>`~ax#vPNO6#@8W,3JD3a_||+fsrp'|fl)6)%̖D.$P5;eݤ 5TRvf6&i /RϾ>|kKNqY&몉2!zy.\ijNPti ^5tmI@03=l<Tʨem*AR%#[Vv4{  `Wq9[ߓr_{HV=_(>jZ[8xd@VL`;5ikTiؗC9FѾ!+9@GdF?983 WQ";[)H!xC" 5 B@̙&jXS?>)ӯմ  DQq4{/ \7%Xf3 A"dgLN'W/mߕ,m;j]9+~";\܋ͥ0ri;={/w]ၩG$4D&v wj*c<ICw|=DSlAu>; xc~;WO``٭z45oQ/ułסw7Nn/L`+u? ^ܶ?N7Bhql<Џc˃D>{أ|Lо/@J[GqGh/6x٢Y/tbஈ-ΈKC9eLKQ1L۴/ d;߸tCv9ĸ4`3R)l _ymtR]6# Ws6n(A v{d-ł7:XJzc-gC -j 3fѳbMgື9u3SW!)!it~}O 'rNjau,FGs&[av N5&hjXw'3{\@HSZouRAq.KUOqȳ11<1|)mϱQ:(/O֖u3R[+}Hx"+Gk^"X +oCde mI^%'OĨS'd۸ZJl)b]QTơ_?$^BђUBolbΟ??}WIupb[۟À^9/c[b'u^:GZ{w*$iIj V'-'DӘ4 6%:$[=COx*m ֶcYtZp@s{ȌQ{Icnί= m;+)az%q{S;4[6F)&]D-:|+l#CT{xk4gCUiBVq"րhtچT~/9;4@~0)[#gh?Ο֐Xh ށҔHIk㨹@ipޯxˆWiPڭq4?.o<--|,+ӏ,1>&Ѿ29u ̟ NaLwwFQ pӊ;6P)EʊA]ey B|}n1} 8 8<0k]FMbXAا%/FoC Q(ܭo#s^3[caMCկyx w}ڟ͛]jyW.f,Kw%էwc퍑(^(Ny#gEЗiVDN5krQFg*7y-]WY`:v b|`fb?-~׭?W?t^{tX16.t+*܁)v*B֮ ˁ4AbՋxE9u%q9N?P|LLU!+(QhSrT25ӏy{~fom/bRI= k&}EcF׫ꯃq6 -@!lMw"\ n`yPgg3bj.s5=?dnesf˽/3 ,) ;N ֟c0.z9Ɲơ/Z+[D%p9XwDY纀4z1ͽumW&6ta07wI-) Xe;"Ѳ*&;yYy8n\7zm4慕7#ƢסaR=*/sn?uS3%˭;/_ΦyPriOs sm[ehNH&)hf15…P'-@sX+0剞fۨA:*@C 0:@97m\a^\/L.l84|oV*|/@m̻#\p^!h++l*#ԓsGaMJn;W2Rhm5b#(d3ؐZBk[Xۂ 4:e-BMx1>csjP?KBf-~6* фѼ GrOK+ј kZ4B(Pfb=lG{p`hMun۱,ڝ2d̫(z-#ey+'n`!vqS;J.Jcڹ%nᆻxZr^I02hrSwª;HP:9J SHzӍxP:F/= ӟ?l pU>Hٮ*c8[ qn8Xdv:639itp:=yKAcĺŸTFs\K i= эo5=z ZP:sl8Ll1Ch}q^ze:}nblX_o|~:҅rk@8T7\չ[-v+{< W{NKbϑY3&D[զLvQq3*didq暩$Uj,'4"Tbɭҟv<~l":zwÁtw$փrKu t|epCQ0&%z&R+!8q,-K$zİD[ HAj3Ƶ<_ Goo <+w#s$:O8~:JFdfs`޿[+]0T3rG݁U)\CA jo?j.m!ZhȢ:P>l#USTȦd6O͜lWX&sB[A)/*4Zh=,p&@[D4؝'_&I60&.•K6X. 9l( 6Q#KUQax`2T21R5L۾Wx6 뷼jLiNBcm:Q4g謤$m^t\+ٳL1Q\ztd`VKx %]6XC^>3יpu(p[XfKnCW;eBE7XyigsFihƸIj A0Ȥh&͕U$nΚ},8LQQQ aNJ5t5=صx r,z<ꍧ.¼lIo2 ¼~Yp ۭ=\ޗv]b85H'(-2.Z*Ihq:pu`<[xG@;#~G$1^AbP{ٗhc%MAv ǁ%LCt)BP鑳gK;+̿n˺n4}|sXΏ#`ԌT1uNcho|-d+uKeJ}4Y6zgfu-he^kXOfߖP@HuxcsHFHm&ʉ# ~~Ǣ6>|:4:8p-Y,z-7fXp0RDjE814^T,@d$[x\)5?||e#bM) E*GZa.uw#1ǀʅ-棤3l0}CY8巒flUƟ#cぱl b 2H∪="F`'3!K|ˠNokWR$%}$,ߗOR2Saqb;rUWp~k^đϝHkEjwW $ qafP6ƯtjNJZ(VUg]GFQ:X] B1bsFeKi,~WīE=-ˇ֖ Bbn[Db-Kl/LmhQ'_ʋ i=1uUiz M[٣oy}$Hj9${ё5xkbG|GGܼu6ߵw8܅=\0a֫#9a#%K{{Q`U9)yV!x\E+*AE ^$믃 MD_DDO*V́3ZϦK<88Ym@) zt|/~GӇw=sV&s1d,,#s-|n͡n5w nѰMpKp,vBbޤfk4mzXD4XAaqrި4^z>MpmvH݂[&Rf&rtqI T=XRyqz1bl*@^9;>aZ/wxqw僧Ner.!*Q,H:fI 䮡sL'^,KHj t'L;&~XXj~)ڱV7 ! ]wH0͒_~H`λN񍟯\?4f^7` ^TĪ(I5 blU h5RHr .xB %LxtiXʨolph*WAJX ❥i|QPWAֲ!RJ7簅0rGl>]P­~A~ܔ _HWҺM+cy6|?jù'*΂ mɵa%yD3`{AE؆TB1D..P 72VѩBZQjvEtՒJP*uPU|0 Bأ7r ?W=^dphC x~C%TAgMAP3-m$ nlҮ^xOBmd=ӲDIJGN*r\-cy~KXՓu9V>ؚ<Z̠qlM]~e>+Dĭ+y]B&h}fcSgE^(TVN^{z1!卷ޗ& Mܰ, y*e/@wr$` SҴxh(%N}9ȥDm$$jaN/Zk܆kI7|"Ƒ-˵͸IgHE۩gւwcض< )gK olG>>JvN} bD'TNvIE};ǀ'n1₀o  Yxe /͑AyF@֡ '*HIYz fBK9) D(M4~t\$徼~Ξ.ȲXLo q50~_g~@:WQ?0F%!J'.DI9 dHoـrt6iVtx|BC@0(?j}cL[k{g~xS)Ak5v\DX1CRڙC +DmNrW$Dmd({M쯺 1ֽ1%1/N͈~hh6PB#J|T۰^r00-#;i^aSBCfK-bןc5Z<@C8&^d;}̓O;aջ,?+kqc6x4*ݐtwfsD69R1 걼BB14 j~C} =K)[b-s+aKN47:|FhWi[ۀ4.#\telDѩ"MFuSyP\cPSӬ֪si7@.D]nZs% 0XMNQyd Ǔb:2T죷 늳kH]^hƗ=oGkRhP<8pW*4(45vn#C9LqA/NB0xgJ]roψz pIOÂWb,y]eQ#hmu6 *Lwխg~}@:1+Jii5De}) Nv.GGh-\;q)"m7ǘ:2#DZn8OT9m Ź5Ӆ+#Ou"T_P"F[hU\9[ǜb+8on3SaQc/Tq+bafk@M,kgԁ*gFyr%STWW3+V>J6x{cm_J50\(eR}(؞L5.S_U/^z5|4E3mmV#4 /b$PeHx=^ 4D:8hBNpq\}-6y'a+D<kY__!IY!Ԛ2`(ի@6>Oޞo\A>m?_ttG֡5Ԅwelk*XmꪫݚQlF0ɼ\;c`b %(ǔ`+RvCWhXJN#{zD' IXᙝ.&xЈ=(gڢ+)&6/跣 {FYb j̹́q\=1U]ط NY'epbm5 {w+dDiQR46 ׸$vxf\45"[%c'Ʃ'D_'ZQPr*WI4F2 ɡ~hV|U/0~<*uwr#~@Hˏjr3|{"*R!RZ=ҶmYvҹ1c.F@/ܵu5*N:,5/?DRiiIǎ "F5fԘ1KE h{F45tԣfI&}G&9v36ݺ7c v<6N ݮѰYh%ytZ4j@31D*THmsm`huGm:)81"^7#jeEZA#\P%kKzG> _׹j9eP(>|-3O?CMy0=|pݶj:- '}H}i thSt.wj#o=*B S|nkb[ăA{´aA8o0'!(bp@kW͗ R?*JhQq H]:&!dΛCdjq RnqhU;W40޻{<vm-ܩR}ֱ ƂどӍ{@g1>. YM؀\F=Zlhȡ$.hNX"brnaKg*~ B6t] TZRa5 h!g_~ɫtzω/qkc6`-JiU{[};UzX<P/xڍEL.c4=ݛm6J(pP(0B ~_$ӫ$LGvpnb.<@ ߂.BҖ~B}gƝF_ʲOxgl@>Zq..!3|n@k1޸aݾԘd'9.lEA,LZl,JzjJ-􅿱)4ekL/ZTE&jmՕhcZf#F#gz,M>x( &<x1k3oq𠱌!]@`h˜映yknli5kNX@P Na/\c 0j:9_)]p b&g۞]\d$Dl5 gN՗]VA;u"a;"`(pM! \-}z A}y!cTwoC6?V?=\Ȩ;P󕅐Z=&b1rn`g? &*n&'X,0KԒ"ݠX <0ŀd.*>EQn [V6XqX"x! &11z/M@J™"ZږU})9xX~&|!S,W樵D-WɛWQeqvR[\C[ Zв>Q|6Z>v.H {CD1um\፡fmyP5crpvnـ3#F=>B61<A lmf0{w=<@^o{Ξ>c%_փMn:b(K:O'>L_}[.L:A kЦ!Gw,(ϻF\wcClf_ p!@h4 YOf>լk6KZ. *ƈ+WS(V\ ZPyp]<7&m`C%' txq'w)v PuYE( K૲H~lc2%bdž+!h?lz>vӗͭ -b~jFcf' 6䦋Ubz걏˫oݾson:=@z@IDAT vqj6H֡3rvP ЧzPo]{k=Pb>n~NpT+r()x-AMw! bn4mdJns繦eEk ƼxBKH2hMyaGhr:F+9rzy+1+$Р{H,zf @^H5 [omސu/sPץe8òcN;^aZֿWQc\/ j([\ ʍA :򖳠dRsb5XcPEQǬJ, Gv:[0gК/k_6y3qkfQOsTj&ILj;V?%利;>TGp[[ ~J> j}qВ*4!UXD߲GS*KLZ;z:Z زMVSe"[LSׁUYdA\ەX1 PldBNdlS'ou+1WL̓]kbIqfhYghn}x3w<䂨f&Ǚ=1d;iӲ7;DA"^L*9ȋb%yUJǫbЃwSg;CqbU_ina!fQųBuC:l 4ӧ^DBW7wonjsrFA'qt!w},ĕ_( ΃ھ!a*NfDuTӴOKuG53gmlF#'vǭ:^>%Ȥس-b=-USYMT\}z Лûsm䱔K C?ow-bKzK"oycb]0ƶ>fq̊oF_Z3w\ '8$;a ^0-oiIfG7 /L`T<ѣFQ9t!`1Вv! O!Aמxe.k2 />t3y lUΝ׾i4͑ޗfM' ~ _{kڲgBêbjtivmQNzuΛ,âGwM1U?Lnq5-.I 9X\yyҝheMhm̎n܁Cޠ]=zttk%5rTNؓ7ߪͽ2.n}^>#gB`n_:+EEVւ *eؾ R+ pwk2ߌD ?0N9%&aUDjm>DAam-"lN+Sv_hKl#k4phAC؎0UQhgƁrƋ&LD Ҝ_4xp}sz{3.N92Aul_Zu$0Tc/JG .pԛ;/MEY-t],5T9 ti,Iڶ@\  m3/0ι+O_U^4zst`Ʊ5vd oz[-'c9;^@7O`| $`{r<qBj 9UW)rh9)c9pKf_(>c*Ǒ,BN& ?JFzeNuL'((-H  k͍6glx.ʞRŬGSR;?' Odkg\Mtt/@%惵h4?GoA(D/8|&K ? x_޲zN~5R+魣찃vWaKs~ F?hWd>fPpnE1ir72xMLWH(6:3|Zkcu3Vʖ w˃UŒn 2> I6!in$t1|(/,َ w|}"fY[9໣c*Jy!Tg&- +%mf ud@k". 3$UQ׈8pAT\TJpm]m%JG^`Qw%Gf,J%!]U͹PU^_j 80բK#MF#eo,1XՖb9Z(;׭\] {JZ [^Ì2ŋh..Y;?4:4.q <u-v}(veBD͈ɘa. Gr1wh1X3`:Zk>N-NH$GV#brfrTPց4Ֆc)_%m(@؅`Wp p\QM!;t墜,wݛmQy`t d}a_)Jk]p1Dmܦ`S>Y29vU 0qAhm(+au'I09sȀ%nCn%P+mw uD6aA cOsgއ\En}Ck N%͂i;[o{} `ju^ %wo(7g PL jQ]*Xo; ɈڸEZl4Nj&DӢmn7vZsWbn'9 Џ?=,?w ݛ>q\W}_ .(Z[iw|i㘉qcwK(ӖEKEQ b#sϹK̪zyZ2Փ:hK(U,d4g^*۷l-=8n>9yZe%=}uMUSvTGKL@3Sq(`W/R֪' rϛaQa>IlJ?h~+H׻*) i s;t@,p0 8'U{cո!ORZEj΢XQun\] 0OO_-!@*7r6n<<>nH=$݇+R=sB:.-Q.s>uERMIgjEhbw?_:@v§b^Rpxz)qĀ9 LIM|G]ΡBxF1v x)5mvO4ݱFz&\U[vC\"gIJ4h<",6u!s}zj"6Ep9K=/ ˜SrC*(#(I1_u^9w|vrz_3cj[1XO xuYЗ聶{ښ-QZ&:rxr `Ne3m_?4[Bwh2xU,#8H \,1W[<20xu>]^ԼH*ȝh c@@>-]faӆUx Sb BKpE8谷D'|i(m$G愤V&[푇5؆s&:>nmҋsYv5>n[k\G.Utx%!F)K7x,EM&J B-hca5"l ٩ZKU5:7%xbv'/-f>]%eJ|`4tlytu/r?օd1t8L s.1گ9FƄ^:L8CJB}3^ EFRPZDr 1=5Xs@I<&QIDo5ipKMW::W۬sF`'}l@ƍ[˿C-ybkԞ]ێ4Q=Dc.uzuODHtڰM@:$>DZG *Wtj9CEV#:jynZ1\$ qt Մ&NS:!_3@C#>?K蝳O]7_N>cbߞ]f<5bOț2ǎX8#'Gm+`2HX̲kb  v W-O -0X|y^9{~/ >ו_X]V1;e.^8ۖ)קl:ِ:L$*a8Z]0:P'͠(3VAm1,*XKJ*h@V ~_qN'd@mXLhހ$ jf9Sj;6#:ɆN*Wn͹uvwwnD:׫k~~HJeU9|G$NOm!GvDh<\ aJW,B%|2 &p1 W4iiU'+Dz.RX1'Ac+3T6SJ?WD|Z$ƣn²rڍ[K9O˫Kj$mʎeDGӕm/#Kĸxi/F.[uX4N:YԎ({:̶VYa Keuj˹ ZUK뮟WR3  &%h~ 8<F|7iyl雏ʵ)؏VrrJjȦ>ʋf צqNTl9@njV.PaP =VQRjkܜ  ÀA4zd^7Ͽ7 #*>yD@l$F-M@4?:ݥSi'垥7gDk+oˤ5 ?~v0=r t|D7ҪlcG/v>ES,֦x%"ZHɧ cY#{k:K6Cԏ%M(ξo7-Td/ Ә_n,x~Q]&/ ӫ>/?rԺO}T@7K1-@5=6^n :B$aP$чdzZkѤU6~w:’uO0Z SX?8' $GrSM 羨n=z6T~g̒׀C? MɲyR};dq[U5HؙwEp$qRD7q?j K!6BT,].}9 Pf~F:+&'w2t2e?ApuvFw2n0hKIR957 U7ݽ]?X`<9m[<%]וB'gNY>n]ܗ5ʑӗz6t RYJЃ mWCB [őByz%F.A)ZVoaLf() K>0!8 0բ8Uc,?$'ylo˅ΕN-ǁfHfuk:bzw0\ 8Ѣ6?6_)d2F=Z~CV5 ܪmB̗,N`&:%P1 NKN$܂clŎ&h-199ԣ\2]=%Mf;<|1C,e۹ʦ˽37ʵ˝;ʻIbGf˃k Ѳ\L=w`ASTVVE9W/)()kTrlOY4D = )# Y{Pͱ0 se緸%&Hrx r ȁ:39hػgo9va^[;~U[a5*.Z&M3PUH »eZ,M{yH۩ \ 4A.&1.߮:Nm/}8tA W&.\h2X uDOO cnw٠QfEr+Jͻt I{x,p}"6 "9v\kЕoOemq}aFeLm@\f*(sDm>b}/[:DzI54!.n$jkA nSc~FnX1]K.Yiʦf4,c CoNģ.ձJN~o?>` ob:@e2/B\-jp9tSVT}#۠%\ql& {*[nԝ{'!vڒ'({=0"`x!B$6QVxĢZ?hmZtfz ^Pz,QrM 9ݟk OJ=_qS?ף4"-D1!!TS{T[5ڌ@yɖ{^ !^n3j *J>=ՁlMqݼ`t56n!>VFVyGo,Lʚ3y'} SN,o-ye\FcHt5*`1WP驪%Cy%з6t,Ѹ%WH'硥ӡ?)L<kZd#I#yӀ5v>b Bwt&J XSFvCSwcO.Qie+do0MX@9 7{kBL_!Xk:P֪]Ou7]:?s!^?CEpG04uf,߄ 88lXvmkx:tu~{Fpqu耧=ȼQvr3hZQGT0EAP..n^+EkN ZN< Xвcrl R= @s& Kk;褾Ge eLҍ._j1.B@6< KNf|:o¶yPY4N!&KӀ*k%hd`ɊL5*PsepWA|B ?^L)MGR/ںqlw!4M%+ py_R=8Y6>>ps9bTdUO4\畓F:aò׻!'+|Aw1NIk]fe˕@7}'!(T֙4.zLHSO3\l,V>#p#`7_N,B&CNϱWVP7O5G%9ޚvj m x?C{:Aֽ_UB`& *mv&U.(Tر}G9vt37jE$) FDa]>7 UdD|aT:wkEnx g[|Ocq`8X=8˧@ 4=5^{تRB_^;W>EqG15hVLL˗6Ys+7=;ź:pp>$pCW ́:0K8:DKh{I~RQ!yɋ0p!$rV\D0ɔ[rI'91`B<CjnG"\ÂDKuB4ȳDk P8\R+|7.U%8 j1 B W|QQ>Y]O{BHy ̈́)vK0Nts%p{uh#i|µ! mĂC1փ'Կ$ZFt@ѣ2J~p lǠw_c'Ci~ X)ekf_rٞo9: L@ :pYB!im0OUeQFv<ىCDnz=-1 N!\/VZ\^`,81~@5rzEQU)ڋYuhr)V-ȷD~r WvˀY rvFFoYtZ4&[F2 Q@YV떫3.75X4*OTù18ڑq,aHu0U5aȒ瘍aE/ex /#{ x/M_61ө_Gxyv&:%~otnqr'*7?+wD9xw_;yz5g2wPF6:<1PɀM$hI>V4.a(H\E0!+~Y0X[}r@%^qf:d G:uUKTbgRP"5/V7 ̤":sh!I"Dvt]e޽9/{ ϣ,ض>]M%Q[_+#N󑢚rG|F-B53!ur Zo R7LJ1<.$M%hz^2Ξ#zELS49\'wP.{5XglHSoAJĊWd7!M7Z}{?+[/%Wg|C*7Eǒ"2[;q^w 8 :?<\OH6 b* bO 5V|=T hZj8 k+^O@1YD')}J'&^'.=SB3 do[J wイoO"p,~($XU7Ic'l a@;K,v5NjZƶuZU󲀽2OHiF |&=m͡4M%Iߎ*BS}g @N=6 Op5mi 0 ltV~ QfHN˿Ts:Ђ;w=F++ ]`':Ov4TW+ -8h , `䃹(q4İIRyCxҁ,srXHR: J\&9RƮ0-UaEV,ĩkpiI/ QB ֺdY=2 Pli`~jmdo~uftrrmɏ _;K4JO9_r'JG }TY5i29yLLûK?xv䁎s8wlIu rK"qΝrTO \q>#>m_vӲw[ZAjd\!zlmQVkM '_.n *1)z+bU9d\l (PʔmG|XGi&Ϗ& YCˎšbU?f,̱zjٓ5UOX+̾W?9s9{m>pđC }l}quQ%o]4[yÙ::GE#.]pTj떌0}PB_$\O@85v9*Ƞd Neq]4$BAj[{2"Qg@GۊkGIkleVफ़׀;~7T޾qK.W˅mHJ:O wJlј5z:հ1LϬ;nۋØ9FUeѨR#sﵘ89=@ܨ_CCWҌݜҌME@m6ߕB%<j KT- `iLA#*Z-7yݾs '[ͣ͌7h^9Ai6AC¿)&bpC$Nh[ /OtG p w;?Vn>#B!Rf! $ڣ ޴]qިAN#-Tc]%ܝNLkjR}4 o e=[U.|v|GG ݿ(mu:CF?M-ޟ_nYI$2T_9WόGW~P)*|H^D8)#tTbL)HPqH/zM99)ƀOIOD_$h?EogB->ylr.h^Ǔ^;GVj=\^1;kDY{hm+Kq& o(cޕ2i up-n-{(ܞڪb9 EK)G|m ;"8Kцq4I`ֻ5`+|ɼxi2~+Z~bW.T^Ӈ_7j1 #l0Z`KCYWq3/ [%/_L>-ڀ&XRj|2љY,q\N O5mHJVCB%]/Ї z(yW̳uﲗg8uM]D43+k1M~>iqD損9UH-G^"pK3Lg[pzGέ~dN[3< @/( !7ĠUAN%Inrk?s&C< "[t<P:;Rkb'h {EQq"PQS,Tz66غ<*O-]hn1|qPk:ft!/=+nܾ]>=u<[{eyީֈ!.*cwK-6kz T*3ۘ=F*Idv ˣPe١V4 l+(j_'g>+)Hoepdx{?N=_&]l~{npFzױymCEpʤ R4_ LBsD@qȟ|YGs#g,n,yLp@.`Ƌ4 -lV-e, Iw/뼤 y@R rhўm}L5]<0EA#d1Scn ,GNL$ft G~ O}ֶy?lw-h>[п?CMYxD/{u6ƽγ527@/4!d'L ۞x;(y2Uu&م:qӗ.)0 ;< .]g>:]>Ix9[ѕ$Q+=` 0 + W!\mݤ*>nj\+tM :4a["tV FoF.6)m*GXҲ<#;2qq>vl'k{cU)g/\3{'%;k~($#&Eɷ7,-ʱ < $p !1t>B x0srtQ4 o3eG?_W{W_AvE~h#^`f)yp MPxֈXY & ʂd{;!P{ /@@/> b0 GL>be%OJ JuLP3Y`g\EcRU\G}pT|b R/F֫:c]Bˁ޻wCfY"8YyrH+lڢ1o]@46(Ԉ7ϝpFfMM!ݔI#] 3qF& ]@IDATPVf< zیs s6C[`4(/CufqxN)~q5%<:4 C O([X4R@?B*V>#a@D~)O )7 CAOYP&(O|u(~ e4":luص{d#Xqry /DLOG_k*{:ڎ8ַϐ7gzwu# #?!/|<9kYp}ɧD{b2`u 2,;.8 a'v`g:TX̔?*"'?V04tW$VJ7P%#~jB1!J CS%._;.}d_X70iG^1:bIb&-DYY*B,/3OCB3 K5X*m\l`7*h+ up B=Wpsz3)ӺK?$ f'yAŢql`D͚Q1ۋT颒Ά^YUfyСMG*+J B3'圼d[3 ҠHzjis"RHFg|èB7X⋶D#ǎccsNyʯN}w-5o5{U)wuI[tXk.;kLN8|ZSX5ZL2sPwlvB&ii8ؘ m¦?Lv>3Zy64:ߐ8 uzbR.":t:P.ޫ׺D6'xe2Zg" |LWZE02bhۏ AʍI  )y$/.IY%2R#H3#(Ul jb\/|Ëc+8@si$Tj%}@De٠bNKK/ )_ھsrrz)䤈I`{׉'7L#*XZqu!g_Q`cDI#k< yߝ=_5*wqmy ќƗ$U椐NB*QvJ2wraT%2j H)e oq{gCq~+_~otT"ot|~L4SԒ98?*zB)DyMU5d38 g8rGAuOy- EI|VP&f#v{|bX Wcbdu R]R=c2\;NwmK~hh -P6h:%z*$c)'n<7n*7Di_zN$$+}rZ<@EڽPJY٪K{rũ  Ҭ. 2Zf8>JA[2Zdop8w;19XRW2^wF2>5Bk;bVVP7wdG,ޕ-,]7gٺU= ݐ|sąË̏[ƅ3SZ5UV-cp!POQ}0 2\1jTko(BCDR2fr cDN}V7׸pCwQʓZJv?h0r.[CuH_e'/-ʯEm[okr:p|,v(E캁v-:ՋrL]K DL0D :vxA-$]ؑz1Pxt;JC X L@RlinG*KĕH k}Sl"kQNJ2[hqQE_f(i-JDlPEY**z&_k6')ׯV(cb HH>7`2g8 0š]3<Cε8#vuqst\];HP cz 6}fمGЩ aӁқ};ˌjD:x=O[L@sswpeh;?+{Nு 7;'+O0zrЛ)2AgAX#|EN&;X0gyN6#|܄G\-*taIQך~Ћiu8Bn j݊ $ ,1*S4i1)U&+DQ-vC^XM-j+cWTN}pr-‡wࣀ>o5Վm6K seS.L=6{S mP_607HK[뼦8m!@%9NKm?]kv/M]?pgLii15CܼRiup_E-пk>qV>9x<*_FȾ2t7a-qz?t5T1> 0J}O>Wk]( ⊎z-=p"5-+ 07㠣`ZT-TkƟe`ǀռ4NA#y;QI*&Xs_ղY;T7iu@'7`XPn(u$pvT_h_D'Xq마̈znR'ZuYڭ(LS*u`Sl"*h6sڿw_yV 6-Jn+Rn'H÷L6(7mA4J8X۬ El:4B:M)Z!ɆN&ẋ 2r5;e}I@4!4-&"L0Y5]2Q4ٰ2a^=]])x9q bO!_=_bq0%47/e'$*u!d݂FgSN 3kl1V5:rV7}&<ڞD_.0V_W ^DYݶC/]yˮNw4/s dN䀅;1M`F0Sʟ}0KP А#<@D/ښ,GuTX"f>!GLf1z7apJ$XqF.`Y,3":ţo?y+_Z6=rΊ`>{vٳٷrҠwNR/PCRCEo+vGaY> P9jCrS]DeZ&&x}^#}_=1JU}<ψMoPv\:ֽMGX~_?; }邿\Nr_;bUwr1k ғ7J| /qzv$'CP|C>Ī$XQ38.3٢n q}:70R4B &:π,IMcڀGAtјԵR2׳FBvY,P]lv6P5bqP{X~G uvy+(`ȟZr[S[  # ڲ^Ȅli[ok ؇e8M<2P2-5JU1AFƾh45NP %h[lL1OdqB9ʶYg?Zz|gˑG#đch rt.7{k W.zJ.7/;܉ё9vE 8jGܸIu^t^qنa\pЦpD2D5%V\"BF0u߾.G[ja"w*c [^X m9Cz"7"L YUU8KluȆ[7ncA}z\UYx߶{򒠛{vM}5ivVi10'hy&!ԜjCJ]pd,g\ N̼lQq.Ak L}i6|}P/L_$˸rn9/˱7$oomKo>;|;r$2 ]7\o&k]%i]]~]:,rI tqcqȡOр3 W^ExIƺ HDRkn6caQ}uV"`Q &1qkɱU6Ј6$KqVfHv6@8F6cz 5kӏb=Gp .])'~ng˭Z| c8Iu:%MQ-ֳ5aXi4zQKt̞}^ Lt4A* sژP3Lvcg<ݍDQ],!!B u h4@seP3ĉ3 ʝ 0S =nћ>{  zXj};ҍJ8 YސY39*Q?qK~ HhcQK#'vQP5y-.sn?{|zG=t<魌Dܩu6i\u[?KUǦV\Է/\:KuSԠ02VSS_:ӁSkЮS9 k :U М1k)I"Jjx(ۉ0㫏o۠c51fOm] <}iݻwGo[=}wL, w-_fQ-p|k/ \a aH3ä`;w:Is? ciRW $qM9~U_:Ρy0uSsj^"K:m,Lv@YU12UN/Ps+4r*/b#~t)|w4h 50&Ѿ_(>,Sl wbo9kMv|g/_0oTyFyne| .+n%' OMkaDp2dal)k o".^sh? K~r9|ղ|˟Ar_g/v͏(:p|_-;wxP!]1`_ֻ W6i&urZa!!3YD<>:%--pÁ 3AlS`'5tX%%?PD)oUYMiv\sv8-qd_q[>Q{۰;foaGp(>=>sϗQb^9ʛSˍ;}p /}xQr0i&0K4s5AVuEIj2VV1ܛFE&&Όe53d@U4`ץMdu<q=,[?`T}~g[)?< 7 >5뵋-r^DDzQ]d`U0 vL.;p[tb@|q)11}w8y/LL.o= ` ™s$`t 5lAUh\ ⃈+b]TA) 8ӟJch]&h`hxӣے6Q;(wmstByoڶҫ < xj4jŜ3隨02W:ޏeE].hM-~\yvzipuye|7z]po"o{|xiKG*Ю f[Yt֏g៺2tc%tQǨN O!1^M9n5vx-{ {HL :.O*azZ9rgo+}+Q7˙e~̓Yvʯ~vyOt>v&䩙K/S,]/%K;O9xM^Sg楊EcZ`k] CTK vr@8w$R 䉪>P]݊Mwa!%[8XwXdbS3'j1D;N4&  9T [_V o^yFjIGUS}9 zWQ;*xw^׆@E$pK@5.Pqh"Bd&vȶk3on;)j"l6|,Yv_CG}R0䓖I0lQ1)p">I#UJG8 _ u =8?>8[|zB:@nuO/EHaJ[@650Wn6eöL0Ч"XKص: }_]rYI/T^xvOo^'D_rZovYG:U4jno| xAny5Yc!z>p̐6l,z2_)E5#]hhZ!)˷ȿkw6]Z\x~*x?sEvx ү1^j<0-ʦ݀kplPH 2ewhuX/_!5󊌱/xٙ9 ]~_@ .zPhQ2s tPǞ8YHC0&^Sw~֩V@6ثI,,~Mj>W4.J!O&`]N47/;_IL^޽R\3ooI@6K:Փ8lNpSvRN̠{Veeŗm-2fx0@"nu;@/l,.SzGeυF?wo?_·/wv.mLj^?mkb18>bVn%ZYd t'ڿNkrD@꣍%I8x׍݄ ؂lk.T`p&F1 OTWDOIO|GAEk$U͌ה/H&ED4'2Ә-NQV,Oh.XdZgAc_ziGsNyWO߻UeH|dmeAp腾=^L=" CugX?j$MZS>q)f5MMe]X7*H⁽e#F%^.ϼQܹ2Y]5\.O~Q\]xm<| ?_ keTl$1BL-I쪺1ٹ*v>Nm 8J 5^}*{4x[RU Q-*z|FXXb\@wb'$ s,q9xF{% m1iF?~04KWU*E%AmQպIlJi}jḘrf|;-^o/~ܸ-[h#oo˓|?ʉ=;.Rii`JFd]핆11ٞe/S?$q0 nF5>ß} myw_X߿x\vs]pݻ|kɝ < l@ƒk945TЯj(U'Eˇg> l*C<p_ؿ[*[Q У[Vd &gt!}WD2;/D X )&0W|EWē |se:FE>aј DA9TIM; Nw4\P@[Jɭqy#WNWlΈĝetXR`g| ^1r:/^ߵmi7AcV8˟>|w[!VZS>?}'|ev Zү=:ԽsrsL $Y^]qI??XeH>چB:`UF d1ZZ(&CEaÖ`6 D_3ֱ:h#B%g0yѲՃG7tqxJRV[&6[r .ߒԸ/R~rU(;Ǯe204ˏX@tK@qwU;j jP '9c!O;~b]ŦGE{dh'DD>>xgEi04g3Pe"z΋l'}i|pJ[$2+@|PsPQ=s6ԂUk&sB,w#H!/<Y,I^L`_(E;'1y_ o}x\@]rʵ]mv'7>[! ޛ$rNQ#eGTJ]`D٨տ|3GdS+@'eG'mDs%$ϕJCy'}vV4w=㟽_nno^6@;urrh8pƺ]o=[~f2bw%砯G*=xBۡ ،chJX"CN2`jH&5Gb fȟV ^szs"V]t)߈I)QFCfDPxBN5m'JU)M2 }*)rA[ڟў뾽l۱Q“~o7`0Gw*Е%K۹U)@*U@J~5ǯh&ƪMKYR4m.):!NJSɤPt뽌[|\>RyL^38V.\U_Www=s5#{u73y}먭җ_owkp^l/'~:pxHĩdWD< w6iEޚ5Bt~w ,AhkDHHA!{.aW|iC@ y64r2r4bs -_qm\*X6lSh&گ ?.D#kg3نTʾ={?(ܹ0yzyWsO? ܷ!z9NyҮr.:%$x Q֮C͈1 ϶{[r8XGh?hGMɿ3yeryj/LY74o޾W~|6/#?\s>osnDkS˾.|N5(L2N'U]8i2贁DlD!ݻ#Bq`` R2p; * @d_J9S|,4Y$'o#Q/uKqLR!KeEKp ֿx5 62#yAxCHɥ$Rԑ =n,|BWt o06ٰvБU^+S7ow^{>Z,ߢ%G.-^U>)L(-~rileDU!63^UR7fai 0BPܕ}iy |?p|P^|&=T/y#+_ ؠG(x$.ń@vRq0?vQx܁z'=}8 N5)˞a@r Zw0 P(&CKqric "S4+>QʜA"<0?ݯܼ}k2Ɋ^ F;w}[~@/: F=[_+gnno ̃˩:Z[I[%}8W@KZp+]7۲ ޛY\WbkU{WUW 6 V$á3d3f#C1I$ӇL64$AcFȆ 4zC襺++++Reh{= px)2ݸVn] _?%mo _pd~`Ӹ?Cۣj}pG 1:NW_JfVǣ;ڔ2O:rnIG\55TC6E;!*լȠSf@^~$@{`#ۓv,}@VMs6<@~0PVF#P">x౐Kl>$9e-rfF&`@:LEn%[xqD|iƪ[Gi`mLeM*#A;aܚ旾~O}쨸 pc᏾Xx^\YɼޛՕ2)!)uJb_sTqj߅Rjg4UU6|xP-p111t­Y(s\狂N )>{&kW]=E|G:D;-Gtݱmw"/9s$""mcu'L Jѻ4bg uUiTYb*1Jpz" #N}[8ڦPm\0^H`!jI۫ketC/D̞[8qDrqxsp4JF''D/boYyW{E3*" z 5Ms||<<3YVgO ;O~ӅWgk{սV\=lTeց3Wt82Y/'bƵi[>b7 3 My3]}/yIm^߽~]^?rXx'":bЖec0``畎@P ryִ nNePuiIqTa4r-) d[7I&;+'9:6;qd%v|G AC;|h/1> ͓70AǬٝ7Gm] k6Ӣ樍'8e-}p~'АGpڮ S!ZTTX)@եcTP~9ܸ~? TG_',Zvvm ϶|vj8ÇxYr\ge+I hȒR 3XVوXǮ͎(ne;$[aFٰr؃icok-?;ũpmu[ ~p<_Ni8kz'i~2P$L* /BmGfnOM6 GQYW6ơC ٹۚ<t.F65 fB' ,1ʕoșe;AÓdjb8u@97 )ܝ>Fg0A CSE2h: 3'c,R ~z839&_/A sO1~t}<}jo\\p5li9Wn4_d\بM "UTK8-rx_䌣_Á˸CK~P߻uMA/6$` ;So-Zv+mҚL$j'I:h: DuA''(r%%j-/3C ơ"~x4(xrl32h0ش5 s9 F!d@џ 1Q+iLc%//9bNc|rW[d1GQ}<īOm&rHzS*MR{wFJ^S/hoÛo(M'Zۿ;4NMCLT*:4ʝД[@IDAT芽6 8FOeN|=~){bVFP o?;z~ vzD~ZQ&g{ST&^rP'@iWE-+p"SXs"@.2! 8j:֓. ^)oԝȊ1N1P0>IV=>jӫ;(yC0@3_(b5\61ئ{'$}ʇB-I\eN>;HJP~7Bj=3$kFbFۥJQEޓo}oSXsp?φ?]o;ɵg\ 7M͕pqc 130[%TlŘ$Kݟ8eQO;9Bػ^ wm\Bŭ kI;i ]?pܵ|uTVVO|&<ݠve%;I 6m϶N9#-iL[` 򍴲@dD*jd6BO5dF6R4# %rA%G~(AE.pvb.J2 :n}!¤>+,`e>c;8nUmJ `V8bpTb]~$q;Q"0 WPsirlC"Ò"}\d څ). 4o"B/}[_p1~k?|->}4}4<;$Ͱ=˘,̟fmT([I+^(Qi׷K6fo{3y.GXKޭaa߻VP޳coCI:Ë >WGw KgX;MkXq2]dae֪0'HLgGm-, tKf[j.T!8TaЖRlnlhHN:qv6 :9"Vx9PzA\B<P72,d, w}b&0v[~|[̅ӥ<_u*MClj2!K~bX|3s"@'u󰱹߸~™:wM)5|uVx"OvP[H2rfd$jvk*,+_nO+War*?>|ƃ;~b^t=sy>]8do 9+^[ wκ3qSOCx_tQG?'AI 219ts5f;]kB.9)RwNa̰Py]0i"@>5kyu:˰(F2m1 ghhAY3m W_$o&$v 6O(" lyRTSoG*iL# zc&ưD\hYbKnI4=!&kk _ƫ n3 {ّ?eo^i uT  ^|.\zBLz <{_=+a$%d13l1xa|ȃ~z7(VѰH7 V9l1:i!&>`[ MԂ RTQ`Z@a8$Yi c}+-h@xIVvUE#ibY|<;𹲸pP*P: rX#@Dր<"!!AR(c^ .@6$`B%N /HY=t% ZkcY9 @c]y)2 秀> N `pC]SIRrUQ `veO߇wOzkonN@rs_Gτ.Ef'"O~tf~t28}!~'"2so?Q0然.{AeKy1ivmحC4i Il"@Mc2 iSImJ:'_Q$!A˱1gKч?ᧁ 4fNBAr4,`ɐIЊҠ 㩈H#X0."8?;e"e~QM:E8%Mb42s+@xh͐R+㬉M[R> F8\wCGOuJwm$߇f?1|Ͽ|Mk]w%=g ?|/r@ɮŎ^v@u]ؐ-q[S2L9A qT,]l2'׋XXt"w] t/&U4!Ԩ[u`js`PS]cS8Jd jbFbyQ-U\WFek†.z3$ыNʥtG krnDIf8k/l@f Q2g"حgl8x|M hw6۵7ϟ7_8={pWhkҕk7{o'>=#;[p&ƿr<;$x z[aH{qoG~84v/҉dt:Y{똀Z2HnK dЍYZ"Ne_4\*qrAObUmc(Z? YQ$5``tc9rFq*x./2cEsa:4 x|Ƙ /~Au*; vj̅|?I@Yal| Ͽ|*w/?|hoػ̏RKçwos3{W;'/V嵼_OxtC!}xϐ~Ζ垕KK +9vp(wJ`1k:tJ4vE8!I:dd`4-$+r% :?"b!(^P#үx|`ϬpS zArY $ H(Ȧ1a4c$l/ByY0kl74DI_*44W3&ӑvKJ'q=9_)5Js??W^ TX*|ËԿáx{O͖ |t&+SǗWߺ|/.0?bd^XKM 20`7_SX}0H3J)HTMw"^6-ќ2N"j)(yf:F@TyN$@>R"r1ā$4Üqh\ &2I (8M.?IzP^pIIM̓lqƢd_n>պrte6@+++4r>B@\RhzSLqߑv׿yWΞ=;?ȿUy,_j }wÃ_+ុWÇ#4 ynoml…Kkr-}|MݤSj:£>Çe*%ѕ8싂gNR X8CGcvhQTM6 }\FL𷝵!5RtPVY/Ž4@7i\WWF"2fؽ#X'n9;Ȥ^u*m=$*Ttz31B ; `L"`'Kr %='wH\ UY9)1- d;tℨccG&:=|%U@H" +>[!諼ߵw\ +{ýw ޽W.< [By۩Z&v k7tPru= 0ȯBΗ,'N܍:9~H؃xKԆ$,ü6w{001C6qvnr:~a&Ӡo0~/)[Ve#j| c(:8$@\E9&Spz~6؝yDq' qp ҨC[:y#$<{Y-4\]PNrd) eSKsETܿ;ŪbqQPG\Zr&Q+244"O./?lopl!6n3W\\\~R[Ƴ ۃ{Pbⰲ>_[XN҅sqO mavίЯozk7oysο8Oo[SVUGäml@!n2^Ә5hB}t%rcqx2vz(C?J*GK2pSt!Ke[ן;ڢHl>ؕu* .Kɉ.1~U8z@]*cR#N>ۄʧkɹ"P̙w0msCѬIh,Qꪖ%&ImTjoL 9݀G 0 x՗Ù3ޱgkB^_WlIB5 {\8ؿˠEN߫o}Ulixc1o.S3^K^yvNv]#@|hkN w[Z)_5'ӊGHDkVKh!˴TL\6)+",q\bE& P-'БТ">bSLrP:9\[*Ɵ'/O1o vťpJWz M"hS X5(Z5GkՒ=i (;U80 9raxw_z!\vMp/WM7_b]pC(p^d\ca䥨dqOIe':#8?)MMRG(j1r,V*=IMF1i,P'ԤC|cg(0`zKu2'oEJIz(ku+)o{iFIbVb19!)+ ԯ.Eթ쵴%&֧Ad/[a8ٺk+d^kZg1_)uҨı'Gpe; %ՠ >\W1x=Vx`ON'ˑj7 0ZqC;7`<Lʯa)MZm_BS7RX ۨeS:$q(EV "`*ZU\ͧ~(VJ<6 MHbU @ ƺt_l,qbѥ &VUV]# L,r\ B=(T1,%#^'KäuFFe\"Nڗǘd!q&!HPT5pxF?o)lU/Q_պ q'Gs/NeNuR[Tۿv 09f.2XKNT5ZD5#Gk ?ۿW [/f_ H_Cكmx"Yc6);h?Vh ȋsYѩ_G.۱ %:iNl%v*9ٻFėKvnO9S-wKn"AaOosM覜 ~\z8kb@߄%|CPkOyF>/c֚\g?QHɟ,,\^pi &nh]GtbRF 珷Q9Prٚv5 TrUSR3__³ᯟqI<^~Nlo/MGз|۵Z^}I8c;-tJ2-enɹsHcii=1әnIr(>Skl6=A6?? ;ܩc 6<>"`0oTtza$} F Nko%A\!1P vZ׼pJ3)dñb`=9UK1S#];e*9@9,m5 ٛo="@1'X+AslzsC;a7o~-Kɬ)=ᑇ'}\=a}<ʣ .kֳF7C}+mŮ2'Lxr'ұ;mns! @5iP|l3Wt\p wW MYbt^m8IYK *$#5$D ԗhe mU7 8Х40Q4̠ ީwr^LhwISw"8sH9 )bvFeE-QK PT8-򫪰gC ofx7O͆W_>!yOBYc%f"IQxBevIuG w(\D`E2l5iZzVsW~, ijT'.9(<c/e+ I[$q RH5ǁ|zl*sUށ<a Wc:*i4랭ئ d[g_r%*zH^Zqv`0,C}bz߮I)Mn_Ǐ<_ǃg›ƗYg~'z { & |ifx=gdӇ1M- f@yZOig`߀)m&ՉUb)tj1 <@ E;-Mu5/8A\](urE]XWycְ&&\\^)u 1%"n1HӶNL.\Styƞ؞-rԾUE\i1 I@GQ 2^%[@z ex띷~33c=GϪGqhh/ v۟M]ܪӶO8 @Gcub؝{۫&xϿJ/Y4wj$2cwlv) MʪD'U, CUDGh*f~09.Jccz2 KX'/eFA+bKcdB\V~mQ+TG;1b伶jQõokL!14&4HTx\ןrxs_g?>~铘[ƶ+l}3gypw{],ـuZZu=)ˤS%u&@$M)\j{jcatlж;2/әxFt`o&'UtiXoac9 HX|W`箧CEw~%z0gOZJ8VWQ}(iD1Y&,4D~E5nQYwnhw$4 %s]RGg+QHlKsEMP+ѕm2$]BӲuZ]U=[^++Gz/:_[ 0|p4~zЍAǐw?%^^AnC wi۝DA]%'*elM]VxPņ݆˚<ؘxeOj ̰ EVeR4+zl]8[P2P=qͫ}F `"9Fd?v &iJm)h!ۄbK >KҴ?}R-BBX5uْ!)&,D$m*fI.I=<3ぃ{?|N_8N:._O?3[3n?괲w\sP { g[ʽ5J  o$\,ڢa$n*UvPvO ]"T%c&vK8UQ-Kr)Œh{-.qf0/\),W`..j2jѯT~vcD2ohaR@ k$nB3T m )}أxa7pajX[ 'w,O} xP'We").T/}3=\#gpnQ2 ”f4-D/?XFԠ'PՉ|EˮYf$pVZq4@p Dlwe)CA4 O)z^dm쑤N`Z>&c}&'sEꞽ=xDEDX67nWK(abpƭ~ ){ ~ >yOkD<ۺW䲡 rx_o>,GrA +@5³<6j6ue,3~|mH|:bh4I];(i&%-&I@/68t. y"KKA NRzJwU*kjZQ֟_(X)"& §$GIeյ8F L`zʤ:A[ɲ% d) ހe,.>umHĠJޓt [;PؓDLbq"(|/nAXzsI}9XRDT::#|}F#=d-R{e1l;nU k1P[VKnppu΅sDA&xM]w!K$BSC K]fEGm{| FbADh򌕃A]06388y0\/VPdT. w堪&Z6*sN~P@^'(J_*^a-q[čs|ĈR% !z3T*A)JrѨ4pSzVK,k!m2TJ2ɲ<+A{do+ܜILmUֶgPN k1Y.^,qcջ@QZ(3> 5SxU&H%/ NR -Lj?߼ xvHt7D`5Y'wILcS\G;}Zi'ҋbb!1 aw -,M%N΁I_-Jŵ!W+T O|%-g+bF;eGj/y$f̗ibUP[+NXاe+uK)tP)-^g~;͢u#!;lJXX*9%־z 26,ۍX=Kz^U5ͫ\$:/|罾M+ɼ GcQn $iMxWb¬۫˶LAIwXzDi~R(l'Su/,RkzIsGlK X3dy}f>)оe6)w]1u[E.l#@l֔6$dsޑ\{cAaj2'f]>Y9e\*נ"K*Si"LT@oadMV(Vzy,''2K;ct=t&7돂&؇B1(W}A+O-VjQrE5Jƕ*'Z׳ھ^!zh c۴e\" iOn:kڠYM_`gzG :ĔXPu*yW *hXǁd!ۭ6+äq*GF% .,b$_IJMWRB?i mD'!62Y)*iP1HocoIrul9a5OmA kCk+UU#)@{ifAJYYF&Vq2ی]A9B=9x7`d[ngC[^( =Mt-mn% l щS7vQ6ba-wԍHjI>ǜ4z ,,۲kYH)ҬXoM٢T.%{o4L ߗ.dY \M VT.8 , l,n+%AnS][1Q;gi[Y=Vi\)kGUƬ{ ݷKXlHtC0eY0s]+y6{-`AqJ;hl~lҀ;J)͋aK!5D*nf e_k@7u"HJƏe[z2f(-SB$Λ(fTjj^\(a3RNiSnLxˬ M=ETԀ^{Mg3HޑnJ(Oi[a "ΆZvAbnXC%uTBy|mc(@0u|ɗ rRaO+L>ഷ3޺)rDyo7i FޖEͦ+II -\,hHF@ ʳq+Ss62Ȧ6GcHOjҤDD7\Jps=,Tf6 wx$I,(E%0fQum4I1T*#KuY#ڍ>$GyT[pJ@_D`t--Q (?0j]JjS1%ڲmqv/NHQcA]vIܶ,!܅eVʫ;LQڗslNޕ]c#Բ8!:f/G-xHEQjM)`84r|c6N*Bwsl*9^,}ySۢ]%K_-Ҷˬ/%2KŬkZ꫁rm!C`=۽ c>>&c>> ]Ҳ`(vy@LAOkM+JڥٮϺT˨ 0EYP Y ed>Y,t- hQŹS]Dt'V,k2QG*!ZjaFTZa'AHߵ4Y"fUĈUєPN %YWY4k$DDpv!RNYTGª)QFʝT\tNbE%aȅ4fH`+*0jy#ע WyquumWYâe5Qn{ZMҮR$Z$-7'gw14KUX0?5o}XoR4H 6 άt U:mm] iGF Ag_~yXвiQF#x3b11-@gr+߷oo?иv?q˿[z vTGf AZߪ'" O=Raq4IRe۩(P1XpS5RԺ l)R>{/w;E_$biGn皮1Q )XZ,UQWlQ")kƛͽo MT\ϥʤQGOC %nGur%pBX.D$B+Ip#[jA>kNtA,m#QU?t]m5;]6Q=a} vA^.By@sp%Ɖ6+a6dZy YUXRCDUUllM=)[3I4P&.G[8Yv[CG$6X(ʚ -Kڥj P>}0Jy[kz](@/aOt$jBvTU-tu6mm\}$ҁܟߜ>*~;bo*3kTQK 6pY?4H"C_6daI*Yt>(SS&HxE%*P*IMpD ]~P֕ާ>j֣ MHү):e/qXd_E:1MGGd+Dj_&riBFRO!6 sDKU OǺ-f[;kOhjշ ~FL5{/\wr_ƌr/\mo._)auZrNQs՟9hA&DLjl.N2a`#iUaT:d?8kfh4*RiRF[/rpQ@X4 Vд?l&lJ[gvV +q_n=;]fQd zz>0:ayCqߺO;U\0@4j,[E>&]wWEvln]c=S"ln/\WiE]k`l bi uiJnS|N}ﴥyAYawT.MVXSwܙ1U4XhIDATb 1,(&D۸˱ڗF 5 R콡q8@;=b$்tTlw٢'c`18`{R9.}- ޢ"z{BW8;\ {7~ug`[~?gϝhT.&RNG3i#YRO%֫Q Rf)KmýSM}nLJQ^$k1dmb,V:qBg7z61cвZ{>[h({ˑd;.lJrI}nA|Yt>[;emb;4!\Û˧9/-/R_5o pƍ^tUf+5Jdu] /('e|5tMQY[vcv|,kK&XƠNxy_KcST27nѪ0G6!dHR rDJEcbRVuxKlīG;[0~nĄ [SrMR0R{K͔J{Q`tgOV#Sz|p2s^ xZ"HZTf1(КVxмzbup".Ό̥[a~{#X֬=MN:jj=IŠ3rRlr56;΀ۇu4>n_qZq$ qGp!>;;vץG1WVk0̺mKFkӏ#n &O8NZ" ;ڕs/y @w࣍ͽw{Μ9r3eECLSٶg `F$ɓ(9[CHBP6ݤ)f"u># QYtriQh9M"_.<8ޓDZRE[ChY&Yv5bPvZ pQ܈oj”F6DAq?ٴd`Xf,W A!0d' LjA='ח.+9t] A7n7(80<߆G?^:r+:x.:+t:]ۈ"2'F4 -BO1F`>w-WJ]XPxƩW(heekw(k JָmYj`z n%9kepxMWO;;g [j̛KĔª#^اD tx<="Byj#u8_~-/R٢XKT/G8u/qMDx-7<0OFJи S-m։kնb.r`!k4"&cSd?QQq;s3dDlq{C0햱/a ?8o/zxa),?ڜo_?[\O] 4;nЛq~n\ D@fcR>w7e\JIS xbsq0p\Ͽkks_ ^ &wEtϱ#'KA=rj>Y8Ɨ?  ,.2'C]#ID|(QT0v#TUXfY*q&Dd1>LD@`DOvkY?%)IN"bT`P8mmdas?c#=c,@ ׭T»0rX ҈rgʞl\fb%yЬMǤ*Z9_t`]rIG&K5/ I#.@>ָ!&\bڸ6oDۈM2H"$m)*bFKTV\JQʾR[40Zea^T~J!-tF'O&_y|c͗tIv};g!x0 \?UsG{.vMQqNGOm>1 ;@q:rߘ2OC+^,q#;YK<ط-,Oxb݃nVe`Yfe`)esX[^.ȯ `qB(dJhge`Yfe`ښ{e>Npi8wʕsWzoo}de`Yfe`ezљ7e Wr[>1 7, 20,_S­|'aV/]fnk&f20, 2+[<~˳oy_7~Yfe`Y_5X3g^zq\kw4 kg.]g˾{(- 20, 20 v*^ߜ9_] n$o=S[qK3hmnYfe`OKp}]U;  ֵ̄k>:t?ݚ[?I%0, 20, l/7.?\μ0%?C\L*~WdiH03, 20, S[?[Z{UgC{O|s^؜2f-"3t>YyYfe`Y p|cksϳO}<ljwH ~HJ 20, 2  }>,l_Xy瞻5 ?OXZ?d[s[9rB 20, e޼W7ҥSkK{챽{ַoo}+lm~~nk~m|pn+x X)YaYfe`3lK~cpv>wN86>->Q_XXos֡|}I~[aٚY20, 20F0mnm,&[痶. G"Is4 L304Y}`0xh8:}x<ݝ-_yW|6x` >=3FF= q݂JT L304 LVFl ;ԛ~ ~wp27x`\Ύup.fʀ WS; L304 Lp6@K3lÓo_1\ώ'N&zVj?4 L30ӛh?޹WMnxmu6pu?WSif`i{FoW@ >38iV9hif`LzgĆ̀o7i u'x``Z)4 L304 `FR߽paovI Ƌ닋+ <tuj?4 L304 ~gwygׇHWܺ/ |>34 L304]20|_-e)Ulo~?9=Ol0ܑk>4 L304i8?k|)MEGu _oN;M304 L30@,=zusmNn~߃tM?4 L30@ ܀~~mݯM /7'S4 L30430knoT yRScDoOřvh t4 L304 \Z[[u2vK7n|^ﻠ_β633f>Ɇ;.e Zd 4gƝʖs)MCT7Y)Ywg p50#V= HPQ (4S$ 6G⨟O{ɼ ARMcvrr ;:n3txtve$ΔJQ"6;k+!+}<^qS:_ty0.:ϗ{!Eb oãեftcHͬ S=0[lKL Q1iܒ(YRI4UԲMO q]t|~z)[AWR2{ĭ[d:mYd!fO )L8蕆xIXSm8@ %Hŷ[k#M0Ɉ^F|tF)С6݃o}G`fӈ휞ݻ;0995|&Q0eǢRi yA TkM3YzTV?[̯>B? /Ƿ4_EJY&\& T٫):&Y^\`/=s zk޺6 ~S\ZG_q/ɍ{)cqG2w(ےt|toʐRrb:y=Gt:hEg޸g]csG4Bp58_&g܂iRR9 5;ʄ4Y5*o'ϘuKyإl~Z|}Z u΄ ʡI=_[T>)gL&._s*֚ lWTPyy[q*8Ŋa7Yc#pN9QYygn )w?yC=5Z%9/o|j gF/[` ,-, YrX$GtA\2{y/r@S)䝜dT RW/rY&b7ń3cKbOyz--$<.(g,k<0z5+ ԧD.$2w6}{:Y-h|=PݯȰRPvnCkcYm =D ˗Kْ5&GtpkVH (ɤ!ag@L\0SD۔%鵱8[F<Č`O#e`L]]t=Rx}e^yX1xf87YJ9/Qc%xc!vPYb|sZ'>[uXBIm_L,#qA`#3EOvK J]޸ϨEq6xW/C,xA_#Q){17&7jR=7V=|/]hpoD='Ҍ_\F{-B ~XK I۹z,Jkkl½1m; m@@!j Or2oN",a?Q^pa&183m˓DW{SA^1QA`Sɭ^ic7D |} <7v]ͭ'>m.Fs+|;섄&@ {̘y|K+jx‘VA4Kf;4N/˜`մm"%ҾBycЋ2zVYq~=}xㅙ ;aa8KY^6 4ojʻƙ˘u&Ut\v}Y0`X}գ~tytJ}-3ue|sge\ p 8W8*4!X&iޡHEn5ZlRiq®n=Y,J~tI+ GnQ.pOڅꔄI zyc[:Ur(e1l+i>먜|c3=>: C6 U_G|gӠNN >FnE\G9#k@iWD1̕#dO4vPs} 66$t3z R`"N R*ě>c Wlc*5ػq`jSMž+fRNR ARk pHb/ q1ZE8\ql kZȟ.4_o+s-iN³uumЁgT2+ )e,!/V %Gb?fMEr-迏h]6ҶPٴ>4cn*%\ Q?D`݅K Fk-N xew륄/ojEY3m:t0jʴegܼq,[% Jy8-Ag0h>YLcܝioK8MYyiF #ؼk)1tzӡP?HGǨۊ.ә3X%L%诗h)v ۛ&0X`P32ZBLߎDz:Ѫ <^}M49C޳fE ܝoDrSӑS.A}+USLY{Fbs8!*bx Y2X$7ʴ5 8h8B a#c1$ESRO6YlU<=ȪNYZhZ#n[[G%KP!Wi¬p0f?a^˘&Lg `ΊxJ_:ɝZ$L?O,e۹̼?l9O ȾeEZx갎yE3NP/0iH NB>Ґ~B[b LU g4 )TalTݲhLd rV-WM6FbZ;SGc%ҙ4Jsbkvrj!AZ3#6kc:#ybnt~JO)+b^ dqm 61!JY&sOO Ǝ1?jV*ۚ/]qE.-OKׂ+T7 gOIc kzQXU울1O#USoݙP_A9Ӹl |mZpGD=Ʉ!s4R;Na2utzʎ;N8p Gd#8E٥E]X[pOfC6e3)zEӄ0uqgI%#]Z%(rXnW`ͮs&j0D}R[]e3r@r[2ZWm՚{VeՆdS~||Cvzt,a=pqǙi33_\d &0Aa38)&* {mf5M[LfY i{)꯹vr8trD$ėk_9 C.RD %&&}N1-a:5gCR}nb ږHJc͢yJx֎ N%H$_xL1˞ܹB3'pG7p~]|^TPh@ IY_GxӸq#y["1jbj[-uX*:OȌ+Hڕ&}Ml  R*Q2 ؅߿{n;*L<|!Lx~=o .mK +pC7Ђq+3a8t^Yt%Rqx&Q(@[tZi\fj{4SC|-E,o~<4y|zMjL`=|a LBނ }>>dsssln(<h2cJ6~I@ІP$^pmۚ-1{٫.jLi%5kc!f #`%YDٱ8mSHE7bo'd:Q[;w|&MbӓOŕev~[G iV+*]_C[{UdB=dVIT#m&)Sv3[mli=867ּ~b7gpm>~vwv c ̓;[ZZb7.ˈޞ*6 g7UngKZ@SBV7\V 8yJaO: ]}2@Y5]E'T$~NNVfMU=O|IY‹vv.<~G so+g~ML&+җT?g~K0fP[OLsdɊ/ޤ9"*F?h~*=M(Euv+Ć%Ԩ{bKs#'-2 R 9@ Im=⵼&fZ/^>~>q}MRō:ҡK@_;#lSQJ Yftan{mvOz"؆ nm .EJ$l ?'j08Ewi V9<4zmЯQ͞O.%P;xfPG;?u("ʎHD3CL|NOgg|t;l~sVrt&keT'ܪ!)}b]C>$N< Y*k)0hK4J|2[5/4`ef{g(BrvJUq,ʒE/C{]vt?qOS?-.̲y"a}xS8b ^X^ֶ98dΟgEʨL6a_r +nbI]. O̮]uROuVȜҒrO)h]esbGPJ5i"ˬ%h2 lyi̱9v266,;#a+pE`=xlwm#x _y;إe]}ZS)W&:S'e5†+oTUʵmpڤhqj;NY^&1xOƐvIt0EGۺԴn*:UnqOn{`PwGx?33dW. {8rٌ Й~;oc_ c?`Ğo ,/.{h㘤ʻKFu[+Y7be9ӹ}5M>Oni\:3s$nX))L_ƀݚ0UʫBz[fݩ418c'w߂#hwf>x}Ex-ncvF\Rx ?#wE?]g'gsv`wut]^ؗ>!p8u-Dd`麑Uu$/ŸR F%Vɞn~'k@\EJj>3`L黗p~j-jIq0$Λ`QqeOϲO|/+U1L[_9p'on?f8s{x3ŋlmnOSwvߥ0U1)8MS=T57`_8!8DFkKwI]?kAKܑ8"l5D{ &iE3K P4K:I}Ek. ~Ώ>B6eme}74[m<k}ϭnco ʟf?xk{ _C.'Ln-~VXȒlrr|%jO_G~-!V')Q_p#FHKu՗LXBOv;d'' ps_>3#u{:{*W{o=|_2ؗOhF U  ]mUaFyi);TQ+)BH/P\]Ҽ}]ƸmK )QK2_38unޅ=/.Oz>Uwe%xS :XfG?fos > ی̙'Ǔ{#>6O_;N')QM1W)o[,}ވS ;VQ|pVH<&zU~*V\kw۽o^ǟa?xoHӆ6^[ao~}ـ@׶,6G|)kwp;:y$6 }c( tF38wܺ"݄~%f+n)/&l{h kBrw{w9GC8}l14[O{>6lDx["NbO~"\'o~}O`ڼ-_~ʘOt;p$qpp?=vd!{4Z{^ fT'$08d`}q怭c6INKܵטk.%kF'+L5y1tHI@ b2[ ([W"q4]t[ $Z|ʘx{g`V`P,g^J1hbV:8lN3b A/j%AoKlRrV:m8ݘcLc C+E+lsGb33fjc|tID6ЖHkz予*{U8L1 P^P׽w٭4QS:&JTv.q{#RǔnZ\!)ڂi;Uͤ:RuhiK-$e` >S'68J2Yl찻o~s3s? O^+1%w4wW>\4HNh C4n`, ^x'Ⱦ>:~g5>D8^5b λ8'M|fH߆9 jٚ,@̧qDpzZ9~G ,\G$e@@gS e"àjw/@  #e)+wzy">ԻV|7ϱNۋ;Y@3 p#zFKR ergH2b'unW+3s[haH)ڶ~3ػ0 }o+,c~.D^%?lHĕs9\XK+9MPB gEխB|Ok)J"334 D5Hhh2Z}~ͷ~࿾~>Tmp۾ƾM-,,qi Cpxs=H~$>Za`*9x@=$i۠51okePcټJPjnC\|wh0(_lԢM-(8"nZ&hBS7,SŴj^IWTH18-U6Hk7RՀQGi8o~MNV:]Ze_`Mg2 xſw/h#$sx?@=StM *08n{W=xfE8<{3j,{N<G3atO} )[Ia1tZL[՚iUjYbЩZgP,Vdw";nC1˧8r8)o[d_f/tt?=E9+ +'+#B$.S7O}B,6_}y\ 4y'=-Y&F1+Lz1!;YъmRn3$A+[.G !*l&c@9ы=F5V�)c q~C"U oy '2@%NGIm)y}FpFd؟I0~_`+,wĎ5o5buy/ݽ)Lb:q#0F׹3(& 5ADMjTyI-M.=qK.ZrU~[۰шUCYu`cZ Կrp£ӵK~Vm⋆ƽ` 1E 5U`gW4xG"2%i`;?/} /?luYvn;']93Tb)0dFƲ@.VPxhr*7. M}|b=Pi&7h=Cf\kT!yNU\kwNٝ?yő+qmo_~#Z(%i&Dԥ~'n8uϼV)ΥiSTZnt"Nv;˅%x}vnݹחv6~pw;'8#0{+J36h`4f^7TL])p@ū̵U6/l<)0#,^ ]\񒊆@#e7;8n MP4<@]U h>.剓wJ|v1zJV3imR*%e C*O-<iskKkd\y坣%n]f?L~Q. %©kVm'%:aȧ~2c2r ./.ˋfaXg!}\𝄫>y H,Ķ|; P K( 4I 5FYPcis6?3Xϑַ&m0RcgxY^^Z!M=EKݥ":4vHw&ܠUsko.mtm~ Gl;ު}` @BhW!is&xO3} )e' r׍ܐ ߇''2pC~:Y}8X,AC~"6 )Lcm' bIذΠZM+MFqiCԸõ&h>ß>62&vw*yn1AeU0RUNeZwޣ}x?_e?+0/~ӑ?u;Fu`5,h{v1 @a[$/]P1M51;4eۤkj½_>0J7w\::%sD^@e|Mëcf#',y 4 m dljaĴݤR_C/ʿI#fZsU3lA5p!\xc7~xO]xߟ־ȆR'8^?N-nEm2@#h;ڿsĶ2oIϿ =ن~uCuُkտ"JZ6G7 8-?64ECM[ۍU%:>9GY  ʤ8-kg2@LTWOdRmQ& ɛ#W 5ӱY}_ȿьN}d2Ѭi,Ps~-J7.]KS7-"!b/ck.`lo6Uc )^\S97RhZWcnx@0!D\΢7FնD[>?h W'K :,?Lv#}a K WG@)YB8ݯ?n$UO?ST7v#,\n4B@ L<gí_q~U_m8Zs㦤WqM ï\/^ñ $5QQU *]1*)y.pk|{PҳlnV״w|Ď w}5vV !6ʅ$Z㩴/8mYc?394,"\127 @GJÌG# @h"KwX2?[wǗ,8K8;Ch?z-UIg w-Y @\yp1:Ϛ!Uޫܺ.t|ho xc36%ڤFMc.>Y/ Ӎ[w+F`zGi0A9j)%Z;fJEpX yAnF}ܿM$sW?[[-{G9U/ƞJdL/l@IDATqC0ʨVɱM" 1Šx+ǫ+B1DTfGwfq5} Ȯ@O&魀|k)3HSHJ׊ >NBj{)FNC QB}K~&pZGDXd$5gu(J?ԫKݼ0>N{xv;(v47 ?*K"o< \ Nk  o+5[+wa_W,lka&?3B{Ysї]èYJ_K5+)3wot-ܸz/fgG2Hɻ 0?;` tYA.e8 T]rIJQӵ65ܼijS_nMcv[D JW7<#:: SB; soZ)6@PneRl־3~o_cQ/~x@2D5P4zp/InĆ 9@klizX6SlV۴2|p_ʀ}KRJo Nz07jNN0d}-BE>6i 6'E3$ GO^ ܋VX) FwYQ%v34(чxldpݸ~"/]a?Sv xvm{EZ<y9 ӭ.[ZmMiBҬ]IfmxyT>ܵBVRm#,m%)4܎kw !{PQBCD̕:, l6ZAaqu۳DrDʺ?*P/ixkH<궘!a̰/³l?Ä3LDvB;HZͬ @-FZ]T|]W#? jNkZ#<ùeu攎 `߹n9Ѧ뱏Wו;#|yx>W]'8$[N b$&aY$"ۀ>r]=kYqo8J|Є+ 4aw;ĀOv RI\N&$$Zti3:(am!-DTO`zj^^e<i+&|c~` ~HTxNk;F/#R;dsΣl뀯ڤõvB]T'2qb3{h@/vv0~}*$!fkT< -P5$~*2K/@4/@QR@/U&heTHWw:iF˚/!J.ۅke~n/x:`YKI'-r#C_r#!M9;d(7ϋ!;6 }'_|-c>{3o8m sΉ__ )JB5rT|?g§|N!]]Ņb^ؤpLOhA1Q|P䧛=d'~u]a\]Xq5ϙkD>ahԅgGkI`mdB'~I_#uTO~)𮵥F !O4j~ x.,W..<pq`8RWψW@>9AbBbk 75%RPu{4Tۤ4H6uZ \e} UWRPitDW_wy5׉!<~-Q,bl,;pf L hs 7ڤb)A;@rT` 1RY׬aE>",hB!r|p6Mx@2UbV1ح`f(-W,'eZ;t})×!`mK6~2dv nqM7D݅4e|qsÿiܰmw#W:9 4XNFs!@4p9p>NLV 4"uF>Ogپ̀k\Md뱅-̳w-WW>O F>j59{0WY2o?o2O rfMesD}~b kJnȌ-,- vm풟A*]%?[ Z?~$w׻vHᣆ 1NsQeZ(DBSuwg&WcY_]`?+Tp<&9Gu\Y*gFOkg- JugQ+2@*֔4[-HU1q:JH)U5*\>۶Ҩ8u^q_yv"BWBdl}6w nҡMo nIFRIZI[@SW:^S``VuKuMGPT] M+'(DAp9AU팶vA_ßzJӉ%p?LpQ&"nqlY'Ǧc4nVn3,+pӆU:-[%H&? Lb$:l1Z:" (@%7a%kHY21]Ȫ~\ۤV.wt ^7 r<GP,g/@"*7wQTe^_G[iPJt(k$C#TVІ3H"cfTlzy/ʏa7lq_M5V~vlofYb dP\d1}KoGݺ̬D@] :?MŦFeA xB~bSX_n!Ʌ ݣ+p 3r[7H{6"q%i-IdmT E`Q%m煶f%JZ, ul'-*>^(^&NKm@20am*љN\M8OFwޮ⟹Yw᱿wQfk3B.kj .d >F{bU EOJ00yh`-&@#N,;&sYKLqV7Ӷ|yL`ZZpijaha(~P\7Xc_:l$mTmA~[RjVZsm"S?W>%U҅n܎ᇁfBJP"calۊ&R\GE];pEa bG[(MYыH vgY0-ʇYI#QG6S|/f10.\w.t6f>n |RFpW4>Gq#<@$e-<72'8PI7-3yQPrIԣZP2ޏ3*ۢ$R:eG&21T] ;|[m92w!G;>i2@n1 pts88*@iF]הߚcOj5:(F3- ҂߶ :L1na vTiBɒ9!D.~.F[ ͅRiReV F5X1"]g@ȍҵ fmRUPAТ\c(gwwyćoG]Uvm-Nq[ Hm-ĩ#My'w,V`qFA&T~Z[bٳDhmb-os(t&`f Q|Gye#ӛcS4:&:ws|ե\&\JE٣c'$ȗ"<c|ȵ txp=ʋo59NWRyUHjڼI n;m}U K=.+c ]6]% 䴮"^9<9m!txr {rQI72ZZlwP/0#mĸutfa֒[NYjX-{t`hT5}g4-VQg @,dU-*k,ϐΤ1\i|Ĥ6^^ oq^aG0HQ87ᵀB{ JXA9X%6 [~XxhɞnHUQ&`^!"]kMLh(`6(k6'_g]jt(P/Bbd~14E RYjZ!!loKJYN:)K7>R:=Ob<< T Lʒ#*ъ_d%/|Kl}y&K6an؉bZbVB؂y60~F%;JZKf+Uz;ZZmS^J47N]QB Z*2]tдz=^ߓkH ǝxE!$P# Ӊ6142 o{l$0i h_G4PC7C0.&JV8M8!pݪ(jdM H8՟Th ". ]@;.Bԗۓ'Nέ̳ٙ<COF@̡WoiA,kcTj8}MWhw[kZZS4*Ñ""Esb:5kp4%n$>-@QZ~Ͳc \ZBW~Ԓk9#DF|| Y}rMWq/Rv.xS\ަr(~hq]xTjoՔ-i^v+\ƹ_ybC0?Jx88VW0@%9Q\!l@&I"?'/s [ `rz8;dz"V.f||閉\0qU%Ά[Y [Ѻ}KWiMa4,D6xT(LoO껚GhwPe.*2@Op}TOkMہg;cmoZ/v յS:A4h;vZUy2ǩr|%?TuO (N1c+ ⚏̝;W>\a7tAxb6Ҍb;)5B!D SgNeȯ@4! 1^AZR0/eU=1h;lyD\]~grKIW<2r#Wx@`AFMݼgi>W\ãfѿg hk|A@>Ljv5N:Dȁ y(A9iqRt/ ( )6^t'-PIj\s<+3Ϋ_ ߧ}. Zꂲ9vD;;5W E6q--uƆŗHOvmHΜ= +,3R剓)k<<=f^g7^}i=4Kxv%,pTLmm(d;ux?)p3$JnmH`:?3)9Ax .ʷWnXojcx/Ye@3D#8L(Q,'`XL[lj٦kW]}Iq*CO!+z$Tt U3I|6ޜlCfwffFޯl/-zd>.A如tLW\ nu u)Du)#M_YfbA=.%n%F|RH!X[m'Y#,BTkj%!? X-BM6=Ε*mG_Z"$ :uQTZa0} vn ʖv{ps:jf?@Bu JkҘuլ7g*$:p#lwh&*2C;ulDž&]JZ7@7-Fj1j݀F[UnHN &ȞˆHx_,r 77TVUtv IUMGB+xYdS`VY| R!]Ա{²!Jrq F\񍥀N{~jsqzP܁j,/9x[[r~Nm [כCAX 2㍓,"_8zżqF"*TU|Ð-ӆبdtBtD3$(˻E9: m$EEF ,\d1I<.KIq@Gg. \\[@ (5wNd²Gl׏v ޝ3?N\{:|fX 16P-ԞoJ=4@:bE,J[r r9RkM\?yLkCd@Z!rࣟg<3ѭ5prr憮GN]꺝jmJXlen5'*9`u+fQ0guJi@}yL̤{v>w]}[|hf汵!BN-m/^;= s is6JC ]!l Ӭ[[8jnзUѕ<Η>lFUҪjZ#5oG=Ux[ךJ]9gK q0w6XDA T![K){9E/LER#ctRNp.1,G~&>dm&nv`AIl +wq3W[?ޑ[ Fj'Nҷ[APACVuGU5>?Em*<٣O]YUhxSv!Uܟu3Kk{ro 7 * o*$n7}Ra_ʮF]6?ȂwO]y) P?M?cgs!gȥ =^-YGlW1f]1&8+UAܧ$o (`'F&Yf}tpbSZBVk [T{,Fc9ORZ*8B'93P/Wh/^;뤾vδzJog[Zxη<|ru8JE~= o4Od1/yㄝ7ti1ߧQ_%~Sb 3췹gr,w<~/_wv_NJUT,~Em9LjQ7|qZiU|t}W5Tu(Jх!b I84!?c0ظ H&<bqRS|T8'dЛ`_İ+($eOJ"/*nr@$@RbsGAݮ W/qw mHǮFR gmu@Y#aM0Wj :n%rfX.hl@%DQ~ R#ٍ]x<\I\_X6E:v%0頇Gtm9p؉$qt)@:HE$Ƹڰ₠ .ڼhBK<{ ]FwMp,W$SG]Vuíq¼F&ʸt*Usr #2t8OխJ( :#@JX5$\J/M@^wAwXy&@ީD_(;p&hNO]+~Ϣbr\ lKhAX.K5db#|LJ>M6pڂp9 Мƕs9p'i@# @DX;*#KD`xvAoFO=6әw^G;_Zk0H\*mͤc {z {?b-tOimm$r( 4psOqX \"J}u$( 6'M)R&oHh\ k:^7 9 ݌;Jq]vt?m @Ӥ7=:Wa2|OĊZ`k~?X J\>Ĩ+i WWki= Ep~[d cBЦ 'lN#-R+}ߋ^roMyRtHFS!" VZH 9F갦&E5*ʤ"|-*s@vs%]nJAFΘpA mκ#pN'Qܷv,>HT~`MNc+,`۞V7]%wsq߆BT3%zj:A/܄hIJLufY͈jq4U%68 Epo5Aad,<IHyjIPe!U$iL.bWf1,x&4C\ /5.EXt+ڃz"ܱX{܉kkr͗f y]qgZx/5/5s#ԇ–^0AE5q=5=ŀ>/h]OΎCM6G%˚m7DH=~>41.hWuUءUDfYTU*F-+TU!fY7fLv9 !x`z$꧇?{q`0$ HH )qdQ:gb?>Jk*R$H &7z 0@37lJ=b?SYgem0¥藙֩*jʱW(#%;ا aJӫ_<.U:% xP"2]=)`qnB-& tP 2]b_}b€ %1(yIL IXTDUׇ"ݛ-My YWoHCS\zѦڒ~ȮUI˻rEwʁ7WO[c@\ke2t^6:aph.'9o/@Fbҩ0G~7@:"8}$oԮ0 +UEI~(%m d@ $LAP\Z9Kt_Jt8xoµ?Gi~oG!Ά*g9J_G`ë|!J*?4@ lwTc/`=E]q_G"H / z7Z {ݫ! 8 x ZhGp*c0rK&]&2*Q%_f0$E'LT>p.~*Q?X☕KԏhXQx찏.B^'^R UQRxH ֲǍ>!{8EAl#cך?mڧ7(sdɔa$EY^Z6 >>i}Y_d_b@~18O|~Գ\"e|IbQvxߝaim6,kY(mw"_8X[춺!6SדS-dNb kEU e ˭̎8k&Zt.)~ze2)])yG3AǞP-*"]QR?;u;-gxk@8&Qe~[X&LiCN:@|.*eu;!yRCըM&I|Ih< 0p[ABy"9 eHE:$ڒXW{:-gJ*0O"侓+zS=<++W8o)$ )KO&nbg`Ȭ0 ژ]gqv|C< ;&Ž ` ~oO٢ӻcIT)Ă ,>l~jee@ȱ̷)>8RG-{烐1(H'cN saSR v& Om:oe UEEX&?%K;tS,QԪXlh*zQHT]fp^BqLkk^l+$6?i0+߫ BftKc9#u46P7Hsr bYKq|\?~gv( gnväG_.;'%:`Jъ>y *yu _Z)$(g{*%oWVR8|W p}zK0Kϗ&N۫{jkmg틈W^T*~iFQ*aխkkl :5a_[-Xj@"؅`}ot\ߵ0O'tqm)5.^kM =0tr6P[ }_Hʌ~ q2u^cPE9T(ĪDE!tL;9t b ab\T`nܯ*}2-/B ׫FZJ쿎]& *UN\VDl<>5xQF{teMz֛>7@R+g=MEuqI.5nx+^'mOk3JЋe^`)[ Ѕ `G?p'Τ SlL]6&fslҟWg".[t ) GpǸq@߫F #K7b5ZZwǴDꮾ^JP!B0n*WznYmP O68~;z,m]:5 t,qRb9#.D6L@\1 @Q),UqB!>߅v> tL:B">P Eq6[Ohx( ?֗le3ni .[CL~E®Ĩj(ӻi٩kt̺\bXʜ%DR PHeB$~IiMm_gjv9rlxt5:I5OLL;ص2˸%H4XMX+4Rlh=I-./{0ƚ; s_pK氘DF.!F|8J)mNVBgn\hy_]1?fewы6c_)8)3|0=iwq 7(t@%ƒ\%| 3anۃ`uD-Ƿ4,ۺt2 'ܯ%c PJe(ĀNt`=C]F>P)eb)PfZڒzxYͱ07:_pNv^[<imݸZ#2`) 4/Te'OQ6'wq= ۭGvUynj8"3=N^<]3rb&}6h q7ugƷ*H{ \'~twϽvо~}#=J?O]e%E4gO'D|T!UgRhfiւ%ZX?"G鏞@sda?t"JfjB`G&W,v:+͎pi>DWƞz^aJDEBz02kD|PTHZ"Sc@/!@v6kb >3`u*G:^27^9IT[o. ;=pn\LH"/U+q@_q]pUbDuMk<=^:3H&hv1NwCK'>5ztg$Y Zsk-a{RY`+Rk< Nev?~P&W钷1Spi} ^R(J@D u@3/:yb֜}4!uSjݠ fc}d|!E}PBتn+2HOfB=lKE,g&,DYԥ-۴ 0`KICRS?yI|6PI"5/dR@A G:*, $tGx&$Gwu9=>tmZf'{oJdyƐ C$}i/TLYE=bfF$ܔ380#hK9?WpUI*\Al#qe6^`;'B /TΙs萾 xkbRB*x׷9A*o>=>*/y9ދ*ufєGN ,*}Y4%@+yx"2T8$~)ao {mj '2yP&D$>%^oB=!"wmCZjp`w'/譡˫@_xq>KYԗj9x }R{LWz`p<8|\_kB-F3thDиѷ@zQ*9Z @TJ Ulb K/~YoX&i7MyjӴuܥa!t-98}#>6׌BNaU@ئ$MNr!A[(D#sWG}RgRr-dH)mE 5 ^`h۾hاGn(_xjv e\˗|AJbHȺH(MvPAl QK #t3Bz̉LŪti/GOWEy%JOM+ݿَ,%kvߗr*2!hl%2Pim&~QMö,/:Nl ܧ޷uK8K$~~+WÇfs! ֑mzpuc% JWM*T}}:WUr8AE's?hFį1e/NlQM ud`Wy;bTXK^2(7X~Dhb*a@c gtVT~&HSl+v# v Nပ"Qޙ4u>iav¼q|*`߬nU+9lɠjvEU3QѨJQ(SG(CƅxhyŠwAbnGJ u!w : esTIAڦBz#t3G69a:v Y}'%p5ST<%!ιk:_Z?U]59qnf_{Y)y'#k.-mZw$ Q]YUE769d]C) "W"+,R>UN T7/i@˺:o :m+zAvVyBD"u<=5qeu&\{fzBP $^R,=1r⁒f'zW/gکCmr6zQ"OD`(19n}XvxnICx/ǏÊMmJ;2|ɒs=IXKױ6iT}7~@k2-ʎg WFH.w[qDAj\=w)OzNY-GE%XaJT&%6}zq%9\kBwHP,Js1B.:'K:nL.1ttmYs ڀ'~3aҥ>inf¼%ߥSoR욵=w+z4>3Z CBCWQA-HZ%, ӘMUꪢց jDOM2m]& 09`I(p~vFYyh6ݳ.,o IE#>ؙ7r]$]$TEvmľ%{Z,L- BT-iRJtlNڥ }'E$;8@\xIKgXb<{h/cIpt%jdN)&DTUt)g]"_T񐦂o꺵r,445-b_[Cj[~CLmri [2=Umkk`'9\01r~.֒+b,Re@LA-qH쩼> ̷} 0m~frDgɝ!֋JM\?r30Yc1- MA۔5hp0jH&̙!OdJҠTR%Q?thBcPւ#^>{DcGj,*]w(P[jAbsKWY:04pyAkxK}GfѰp)rVY/}eJ ǚlNudm=9 2<˴/҉_2,[v gn|\|`ݭwMvQa;*`OxRZfD@]RU@[lg`XZ]CQx(i`?c5}P59Qu|J;xd2O]9q:I)#3}ǔ:ezX}i` 8I, r~plqr7"/T8-6qjW@n")=KG2iY{tPvrk7~W_YǏ~__tlme|`@O']o--"X8jBN[Fq]j]hxe )|q,t=hOFtW~,\nv[}2Hhn~nL$GV1SԜWN5u_/uvpDt"*[涐8\;̡p2!"୍$WTJ,xrGt3͍NGs/9g>xhN~VՁ3k񾠽пBuMK1rPvUut^`۠(tEFO YԬG.LaB[nXWJz"=ܦyh0l{&Ka =npG ԭ9/\r7GӲIQ{]zjqEl|c_$>p'Ʉt<)Pۑ^ W$툤·L.ͱM)<1= ~6C#m+fipގ{ ͓\eAPzKHi2fAtܩ:C Ql-ʊ- A5)4m,qrM <ưufئxޑG,+'8CP9qfeS DH<@@Cs@" ÅO;Cz' %/pYPN!wt7Y".[)_R#tC`¹ͯ]kֈn߾kvqزjl%IUY*h#}eWtְK<1vck5| kppӿ6Z_:V5"`{Y(W9ԩqeEK.OHqܹULӯs }}‹FFè` UcjbgvФpEr! $k<$.e#yS+i9s!$rIb:ƊUbBhm<Ą_xyס}f}7n{iлZF,2r0rR1m˰u[uQ#X&)} {T 2N!jX`y̟57E9zjbwcOF `~17e>/B!u5LηAxEXkʡMaiܦi%A5iٌO޾ spp`{hV=?*IU8KWgG@aW 5=$&Y&wTY,$ey_Fj hs3rE"C5 f鳔2';}D7>}$N}MDm:KP,bL\03 I5ꢼ((Ҥc >Fmw|wmdɥ JP`8QԐ@ǵg(6vB`a4]XeC'q; Mpn,18qm u=xjVAcCC*yc ]qHr1 ?zEd g۲/Ab6MT犍;k6!ҷ~ ,p&Vܣ֕L#Yts_¥W|uOy5uyMC^2U&4Vj1nQXd=eJ{ r,ZYN߹kx Pٿ\&!O6=[;R~ ˇʉ僶 7xlG4j[L!^uXWp`7WE*7\(dqj<P\K]814'dىˉ;M}I{2M~.Z8n>\Ё8(% ƪ"#S $ݪ/'G*o)mx$p.4Pi}# ~s_|LRsDgнlLxтq HK1 m+&#h:q8|aRT;Bhq]bwľ[,Q\b`35b,$Drbv%oq.}o_K۫AT,{嘉../LPE(5ꘊ.1(mVEI\v>СG$R#ϒRƁyS`"ak.E9_?6;Wқ*$4:][Yfn̑ϱWz>YA/x 2ATU f`Tg fƴi=֙37X&܋~Ae/6zvJ(n vU7sΊ~D_J}z7@=H xzɝApufm5/GFt&M'x,Tbd}eMu) 6fS >$Tj4;fg.o}Uz2Fw;ҿ<|x떹U²@Dۉ[OB',Jj)jLLY/br;a u_ p|!֗j /q (S{׀ MfhSv3fY4E=n, 6:i,iu!<"Ǩr!F9̜tn4I_Y6U_5 +tC_@ʀ!ybZR:'4A+F%2泣uM pubxu4*ET!\Xn+x\ SVFXK꓀`E_zDm0C7 ʚ͛&Bp?8V4% IXW" yaK(I.M|(WB.yY,b$բIˆQx{w!v;ۮvN. *0auL̀?_dvtzؼ@ o"i!4P Лc` b/3?) n<|~"@AEI(Z1c7 G9@c )z?w!_9i{,Mqi}csa94?tr4X$Pvap&{U 'ǗH)rĊ25)׽/( $H(_o= >1tC5 ު@¬7nW]ʕ8?|`$q]8!X94xc khnȷ X=FD@ыQOyϿrB rB& py@;([PYW g,[G~C9&&8ׯ2[3ˋc;)`oolo2Ox6zn:O]~Lq< OTʆAJMnN 5Pܲ@fE=br(C0;JAP1LPmv?m ~C$^q~edA^މ鉅xy@`C.`W׏n  f, \Al*~DJI:xN*1\u*Bc :e)[E/G_O2q*nyM Ƞ/ysis^4h>r;_f ;k惏onlm}"Rz8-83 lb8*yi)VN1ܢ.RGBqu0U p,D87hPutsv$4v7)1Q,@]6̌YZ7;;@lrHYpru،kg StOwEǼpHA7$HE%,Kma e-Ͱ\s k^̷MBGzly"vXFD0TlM}}rЄq4]5Z) lmUFgi<cS 3w7]dT3dYAQ^gǥRJ岦S7)2ie*3j8yX+$ J!q-asL6#{bk'W[b{@YU]ݬssX\3}4رJȭ tBҺ@*XmW̴ X8HЍl@u.& $=VXhH*Ē?Tz@>s:VL0rM0=gZo.Xd"= v+6Kw؀r%RO와hЍ|yܲY7{Fx JL/2[[fkwlҙ]zK&4Ҟtk٪s&iJ'*CG!g]u/ޒ ySPXNls$ā Oƅ Auu]pX襆QpuaC-EIYwYgm)]AL2*?.eΚGO&UWVw &^:ћ7ɀ[Stk6n*Lը {RѷEJ)NX )vGC (l@,*LӢ #V"ѕنp931Ssϙw&cdp)ml5._6ء|\OЫ7  U]<H,w+-mu 9m]Gxh0G|VgFiAn:ˤmWa+uwo~ @/%uEq%!%e.qUË6&"H`@h.ujT`&_KE>%dˊ)vT*%2a.9k?~T uq:M; t&@-ylho {&肦G[2ToWwA` !ٱ>htXI8,+,ʏ]#x0g 2IK_TBx\,{^\P!p-P'$ۢoO7}!nloߡ;n#]?ik`I凄GtYf49L=Hd͉E1m"s;D{򏳧.8sY0AitJ=\ \V⦬.KŦ)ܲu0]4 +x@CejxQkًYͿp2fZU\y]dzA &t)`]>l0Ĉ#T IT`d#N[0,9~-my|A!J 0+/F 69NJ1᣶@x25RbUx@ =|7֤ya,LyK7_/_GOӜl&ـpރtN% O񨧼qB߶5xsnܥ/U4!d6hs ęLٟ52ϱ`p3K]ki+Fzr>$yhRdW=AyA-5kFb⛰89|ܺ{kd Kͩe3k=Θ "mϊĀ3&c%A4x o/D^!@rZR褬?Jon dyg+bn.=ݨ&.ynvr鴹xloc=8hݬc>u@pԝv=*|J/"'[8T|5[ƬN\M钛JK@RQgŬWM/]pYUz9z:u"œ9]@q//eF9ΓLQ!S\٢HH0\ G >3*r-{Ŷ@FPRLgE.@g'R8k9働)>"w(ҙS_hyyU3˰Fsgk'}jltO 6C/nmۿ;ؗ!e6[欟 ܴ DZ wGs.Bl1gCŎ"ဈIWxi :񎀛+=ɞ.iRdn_/@ UE ~7(*Wj2c~zRvT,B.P @Dhexï}c Xt yH5e.[$Q_c?kfs?O{[w>23S‚%M7EK2}YZ/Ɇ]dD/~~:iUXj U5`p)-!ljGWwv}HUڡsTg$#$=80t~B HZ}) Z0ua$A@H %x~i u\2X[<1t?fFoō<:w C$h坭Xp #IUjW,U[6')%(c[_:s8q2 Q>2?pXzWZz |}s3~(Ǥ@њ"me+Nq{[*OJP/KlJ} ~"v.X'o?1?{LJH&/,WC Ϙ9z7J=*qSsxOÔQPj4(aݙ3lD1Me6U A #"*L$ ?acz,pT0 xo^@EgKǫIseenmV38Ւ 'L)uR KbDqqp+ef\SJ#Uc}/u+#`U^}%*j3 z?!pgg<ٟ4/[(Σ*LѩS'fstF`~n<|/Zg*0^4D_I[7*$*Fq5zid˕<0 HppH޸ gvcpVX !IDz,zCWxU~~wO0|&w& BBUYGRrʥ, 'HʂX/bzqu b?`d -r9spQ'딣Nm7^>i%S~v!uÏ4X=/yKYصB^TѺPUBKpΊҫIt=@![D\?8)V9y<դV93m,b.ڿn^~, veg^8qlV=})۪MTm#n <іi g >#HG Y(*ڀ#V>oGxy"|l7>ޛ25!(%#py3ݷ. g,wӷFsɧ ~g'wi>cW~iZ{x=+%guʅ`|Ǝܺn+'|-pqa}tNPIIn9z }lhoh4 )_+5 *ri\݌q@,D;衺(J)*jZUSIp[* OnPǑB!%z^t ݏ@p&>D/XZI^;4`ѺQYZ0KtLmdCnCȎWN t+CZ-YzC Cί 40yz^ǵA&83Aw6q)'`jHI^YySB }dI=?X_y"<):*]2Ee(Q";AVyj,^J)p44 HI|>:A7F-A K' w92H,M챮dB1])"hxMG4[%u释iݸ``S\lLѠD4#JqX]&ʼn$Jg+6V{&xrGȠ&]͝)FB~lЅQ* t~|3w޸`޸v֜XZ1=Jv'EOalw6D'id,H}~@A>뙼Kas]hvNrlAQ"hE Mܰ9=_688l #"n~@:t@AwIzf7~rz\z!|tk< Ђxvml_ԈOG:v6^ƤޫOhf$XTՋ/ѥMs-HG4ڗSOws?2' ?y[znQh sRh5j _?^ǃ3yy`.涤ʗ7p*&,ņi*qEx `Kҧ~%<&<s7`Й/3gGm'g{_8!{+u_<4X=Ԡ- zSLStvjky4pcFy&8}척;~_~3 /]Z$pbУDP_yg>媹yiMzZOzJm?5_z zE]T@;h,N^ ljdt1z8Ƒ]5JcZl#;v̷aVW@2Cͯ <">4)C[ ,' C};8lЮEe؍@,15@ z+ŔgǾE&)jx>K įM{"Lh;ǝ%6?Wx`rzŧ??_:;Mϟ):ߛ2fx0|6}޷l>4aO.mYTOSxAS t͔˓f,m 3O s\4=ZTgo?94[?"妵OgPb{sfJg+ #f626HjyL~Kwd,0Z 28 \ͽ@IDATAa}Fﯼef{7ǟ 5:.@7oO?^``vgbS+W)}tbDu>>IX@I:M PhO6 JaP-!o&M6.D(jdĩmO_=鍂o/`כ)giP6g55lݶ[82ph3݊Iwn$/`R΄Пij3o_5uz)ÍC7?~l~h{أ~ty嗻](lQ֎FXt ?p7&Ǝ!T4ױc%a(+/pEU izi'tS}V9gLfeu/ I :O 8 9w%S۾ $\j5REī-TRjS3eҩ!k o QXrh6KH'jD4D,q9TS~ĝ` rw1a 6E+1 {h^0CYb1j 9 P\4 HUVY5l)"I<ޟ{I(hsqĖC{f"b(0ITEMz&N.^'no O*6U|!L*RA,$]AT4i<'\Ańsk8 acJ h#GKvy"0e~=knϘ]UxjNgfƇ f̻hn+h_ ̍ pb,[\9voڢQHGRO@@Xt{@:Y P\.bqr T(AN/M[->p+rNĻL ;+k+N81 s3(br+3t6&[fG2 Uىqnlq :rDD)I3dٞ^UXꝽ tXQ\bwƠN~v:ejeE2x"[ꀣy94;tv`֬Mѫ$ gp:4cyysfxh<6tKݑ'$uo+jb3Լpv@@5Z%R5 w$抒"(")#_VW|ʒ{SSE.ez*辰0ybhF`ڛ1}*t=Fg.V>>=3gN̘ׯ6gϝ4Y F|m&xTky{mu J<+ bAHi44 gv}7`i~)4p))}DhHCihRZ:J*`eڲWw/gܙ Ťĕr3 TFt5m$UB_\*EWuT%-JamYRFz{3Q7p w1mZ3tf &E|{^:7o_=cn0(߈]u33CDYn;AWgO^M9IZˡ}\ 9T8!x! |Y/A=aWNS'OM=Iәn#8'dhGSng5 ψ F9D!Y&l U"2_YX1XV 5rYē|#t{p){>k  z?Th] vh'ܟ3g4?2Egcy>=wbq|Cs. -zQ.x&itC܍uP+5H!.Z%݃ uS,ޝ= KdxM8 n¹t?$൝s2u_T1 tZnJcm4Tޕ]$Dn`uUamYbzpS76;]J."fS)Fr9AR02Q-a:AšZTDgV9Ŷ`u8pv`xx0cn̙/[oЄ.`L ;|^TS/^>c~G#.lnoqJ빶jQ,XOnYWc5CL C ]tB)|4ֆWxNKhFy/yGڱqM<}U|Jg 9]BYc!Ya{1aשO_x$h^tٖd.V#[ URm5`Zl(iwt65c~tg#Sĕe]I4 Pݼuح>!UrU1t7QD2E4<d Tx-C_ӌʵMvY{\'r7`Pl9[M-81p`!x@x?'%a5I&E. 2dE^1> D[9|  KfyзR3Sg-f Yg=qGFJE)J~"P'2f9,/|of~9}fGm8mNwYѼjɃf.ҙ8e\_И9q֣a4g9޼0M4G*}U܎A&S$R*[[ Wk43p: /Tum%1-]"plz F%Nʌց8?=uh2A^DqIAF6'K(&52tj -6?;WY9˗?7k]mKso gbK)Y8*ƶ=c1F{I`[u!51tIT=>j_L@udf`.Dduc &~s`4ѡ2=y/Ο0׏9z0*pP#ID[F] QP% LztB qdy|G{r mSH#KjSb|v(o٩c4 2(]E Y '*?y8ݴkf!j%s*>"rexٺ-_+=|.ϳs뜳ȗ?P\Z}JY Ni>VB)^$0$O ib0$@We"8+m\3jN6 l;X݀uQM6nWuYھ-ф0\wJ`+)\MWd yHy#%ٰ>rFBO(K7½*FʗlEۜw,nqan8!3;ce߸Ϩޣm{,ElXiJܽ J$dzt]C+H )9wd!Yi4 VֲOiCtb7rEUAV4 *a0+߮|`?WT`qɘ^2fN=Q[M^_f xdC>:eXNyUHΦ.n͙9\PZ[?_?諅a@0 x1^q?l'eP{& tgj0F(d=&}~N)Q>`akQT[ /Ut (-Jh_[ҘVC- 务5K^X,~8z 2Z^Кd|$|lA9D&8? E6O?U8Q21e)%K2\̊]fCfL@hm'/._h(ZRns[Jm$emIɨ$;5 6p GI$(o/7n$} >Xس>0 J]U0 X}p/ɛn;h~=>(?CN_¢ZmV$eP{A#QlX5n`?)W` b%E.~Jꃈ$ El8vljia|_6.\4ר;3'{85l~8 kXp=m._oiS0ܶS;ޞR0u9M ^pIU׮HʹM7Jn:!2!VgJP隅[XׄSCٖܱ+u uhO-Y'u8n7Q0 ℆D(CGпsV/~&sxi>.O6p2xcJDhCZDy['),fj),8&L“\xpI(p;6+*:g 9TTAc9>洵}`~n&r)@Wn[+d֊<8iNV"+J/E2,8|_/w4+v gj^kH^Uhp u*k;#eq"ò:t ): ̉٢ Bkq;7>۱$䡤eBjdmMz'ls,6{ ֦d'Ll,k**,1;_i:ЖwLg`@V2;UҀ4|tkQ4iu=qAkLOmkUM%;͗4Nْt&`r ?HǨ բᨬԋ 78x< q)/p[+31#Q2e"Iq@louOo}-47*wNWwڃO95ĬF\,\Tg gֵDZzP4:0 cX9;}:^)G& .[ ?A/LSW;epH /y  12GXUUE]Ӑ:Ay10 lKL.rbz1}T>W7^ׇyS&>hu+-|Iف2(8Qbm ԋ}F8]#OzmAmmӋf=?ǩgcLjzkFf'Pkf>n:bҴgOT̴S4T#9I\Jp8ܨkz'(TXAc@6f5Җ\Uj^4!§yK+QaA|#<p~sOȋ%hi{{=o?ul{أfǓL,"66&Y  `'vuԄhPiH{NfSqSvP'WJSa$8>~K?{ӿ6}.3U~@.a]"nXjl%~hSGhWM5홿X&auA#z\ϤH|}[d=Q3 HmeS&6y!֣Jio<(j@$LΣ6NaVm: 4k-+k('L 8J35⭀_J{g;3+kog݅.161c 䩗]#witxɓv ˜c^0žf%; flc{7,B!Y n_ǒ!I?{ߘݷp]Nt%A?d_Ȁ@uv@ XOl1v f>oDWm/קCix~Hѓkd@Fiqo|m_ X7;PGbLqkh 60U}MUOǵDڤ$= ("e2Z"VJV,]a/)@ aI1ۊRO^NՒ-vIOC^Lvyv8?pm:Bô7_to]^zC90H9n1ZFcc;{@--;a&l9Acs#<,ڼL}@HխisŀJx cMf@L2fg_{}zpWanݝͿϾ;iƃ.V }Ϝk-X( 8fV_I8 b.*s _66ȣİGZT:Cc5,S(yrTEo19W2ŹU fmnӠ8trv—*ye BDX#/>%>9L7qe;fnMQh9;QA҉uò~3.<z$fzOA &yƬ6ng|f럟]|xw'?[eG<0ÏԽ&7 g...}܇̹\0Z_ʼn%PpZ<^qn V.CpVT]e^YNHzfN mw/ԆMMOzpP{'ogFr*?x =̀-¹S~|OƑSԔD,=f:i1pRQ)kr I/-CDat?`ubdlT`iB͝0 y PNYe/\s }S%?_D; )ן=1=t' ^鏻ЍYlcRkKosgsbBjXM~EGqM28#|ڣgj:0\?B5,?< mo㘃T?#&o.WF?]{BW/>{M~Z}ηYAnܰ3Ǐx<$ױ u 70DXm(W¹<Ww@ԖE3[immvZEvTgXO`]u2x^оW냿(wi׺s ő//^z#睆u9}Joib0?Ⱥ\}4ۆ쵌Mjіȫbj6y>%'c[OUu'R$6#+D؆ :vPc0swmɨ er[dVw?>+W|у\[ysΏi~$4lq !&C]|{ YlZ4\j c 5Mw p1b1 Z@|l||7c[?FFVcmGbRuC2[ZK [-ZpԝtL2(b ->1,"wən.%.حe601Ol.50f 5ȱP['@55Lך!,sW~M:ӃӏLwD1?῟7dѲm4~]kP íw?0ռp9vXX%:D(2qіb;gF3qF8*mѮ;tZp~<ܑ/O_ 8jǯ~z %80 1W쭵]tn;/Cq )̉pkINNcօh#eEc3yvύ3LmJ1?NmkVi|@VA-(ZͻV`^5x^.FOha@AYD!ꪦ̋g}ɏ].5;x[ָ'sc!9 t\O<ة}9euǑfcU]ª-AК"kɭ*CJԣ zp;qנSq˕ײڵmтoG-{n_NiM:*cϹ P+ޜҒzp׳8@v+5&uZ} ?t"\Sg)B'jChqJT[c5VZymcPQptdظ#~Ί/݂[l= ":q\#Lڕ^ܕ/P~&v٪V=eZVǖzwBV<(84kMZ[1GhnP}FIٱƠ"z :yK ӂJbe05}1c ᘁ#5&9̘M3L/MzS+g<_7*ߢuHo*ꅾ6jgG'44uOga{6|;Վ+%S̙7hpY4C6*%ڝ 3YSќ(VUDƖ"I]@1ѐҷ+FEV?ݷ,Z-UV=??gY&OaHJFo۱Y$AdGh&^H<8}VWKg瞺cPx;s4ZwLմ4>˪f?%V91]GvIuN#jK1UQxUC8`@-g"=gv&@zQm7 <1#t'X̋zb4Z0i]G y|)W7zO= ` O9ca8=k&%n6۲GCTbڰND-Zۜى׾hm0Kv!%}H;is˓aiwV+ \/ ct/`}۷wߒmǹ&w^~RRcvpuU}FjipS"~GYt}8}C;.B|"]V(W|=@4vk$Ce1,u){{5C[zEҫ/=<Üz1.9Z8f!^*3O+5VgwqܥmSGȍwZҙH$X ͱ~;aؠzF1vꌸ=᫋N.D +b1p7ɤ'Up2[x 5( `kOOZc# E0`_6ڧ3BNVOߝ~nOW_|<G]KYf9R#/MT(;-R~3y ~]AKѶ]ڡ>deE(GAŊ"E*;NV[C6ڳN 럅N۶O թʅbGyiء: Sӝw)N7qޗUX N.u~Z⁎'pս,," !9ƴN*&+{қ%Ւfk]@ AquGbBf h+}ńcn?v}Uu&,Kx7?ugO6 Dŏ;1iO=_=-{h* T1:!՝2lFpp%7Y+&GcF~bڪ+$w팴gM)±%%S),n-x+"mֶrutdtD"`J@gPyjyW=v>j+SbU#/ Ar5qw?%Y:#>yh@Jӊ$G;{דl8 MAt㋌M][Te8.d6пW8P2f@|bc Ν~_>Ķ+눵Ciowi.y 8JU*xwr .JMA5oGބ8* >#;A \qOLk0bOiEqGt.GvѶ$F2~J(8\w6-Ăe6hU 5 wx7a}`Kb_&x%6G~_xk 1F c7j!fҜLtt1Ldk&oWd05@$4Fn eplP Nr,$6ZXe8TH$-M:yZ~Q"`>.-zíY~4)kt _1vCfZQ`m'R1;lNԑO-9֚iT[TmN. Whc-aOZ f_^+uގ JCD-h߭yMmKsGf޵\1;n\Ove$|]\Ɩ pEץ\pحf7j1!,&fuh!̰h0.Hsrzִxc+ '^JT@RW4ȷ~O=]9;?{{~F5 ׺`GKv_ȘtMg<7+cCk4YBiO[l2覓bձ hM/fPw* +lVMӞwe"@/8հ㎀nI0S3ӫ/<=|ƺ}1}?ݺ^t }+b>x\ls xrI0iO{Z(67l.mLrZDDkj.y?R rm 0Tfg\:Vڕy+S-ꒀ#9N jfs1ZT+ KEz}@5p,0Յ$]ۂ iMت@IDAT~k.=bhfY 5Cb5pM[n4zl8=JS'k"`\otU Q4%#h~ܖQކ@!\2!.U 1V-K*L kD$uxL ldіsgO vqcOpLm ?z`kB7_}f@_=jf<ߐȣ{վJ8Wy |. a>g(/7t$ܘa*r_#\'*dۀ[t)]Gw(W+j[$` l,ujg%#N[T=J Uvv$c$jofZ#۽+'-`])o־ZFȊ˘uZiv 9bV:NQfcX jfrҩÏU9ž# 7P@Yv @HNJq(n5&Le$`/ љG7~vκeF_o5Վ#OK8Fpq.fPŞш}Zs4TsA6c&>΅clˤ*.ɠ-}ikC- fms8UB@ B܉.9֭٠4/:[IEF.>p8;ts{. 鹧G77uDvwq@u,m܊KbǩiŧմNo--S1\-VwvXDT#fkwMǬgi .d>jĒSx d?^|鳿1]zw5ݕOl->|r Ac-w:"Coi2mb,5.h3ބkwgAQ]CU@RvE ~*2RҺöeB\WF^2/}+_<Ĥ7>8Ypշ'}`kgjk'96Qwm_{In>~wi&&lj~|!@$O8ֆI;wbQ[Xu@ظ `}؄$۪??.pp@N%>#@giX2 u-s1cwodVH3϶ҹ+}e ڕ[VqB#oW],%Er,)v}}|F?ơ@WT}UvUum'v9j64 WԽMiݎ8l=o[21LlUjp!a4 |dD8L`JfmeQix#mUy ]fKާhClN҅33Q㬑qon}# , ;Z7L?=5F2Pn|?- b^.AV‰\uq &wcnb4u>h$x|C!|򨴷Ixf^Z/@l#~NKt-_v7rmv ~ g鈗-Pm.Ȱ)sQU-QuOKbsmuBT|b[R9[e.Z!=UX<OwSU+!؇1 E KB"1\0ǃSWp1JN||P%*+#MsϟfK$' d)u\M=)e,ÔUtU.k]%'O8$ÂX Աs\:kc1q5gu>y'Oy? H5 Jslw'y؆ir"Him Lt=ܧ~F ^QJ,$"QQ;E6-mARZ6':U4+k!7[4,G1Q2-@$٥.͋Z S~UX_Te˗{sܦ>*z?+0?|aC0Ό Y0Au]Dz" 7EieK꣦@+] UOj$@( V |yU<">ۜ C=8\ү >~qw)FO|A{ ZwX&W[\u<|H $3'wuFЍ1xn#Djݟd_$R% DlӗEtopKAV P-n$52EԭRG96u,"㦍6Q:v_axpiۃO?y2~~Ӥ6FfO8$ ^jH~rJ|6:Q]xc yhDI,u1-c3QBc!f ҂|EBz`h~vpme_cӧ_tyU0bYw?>[tX֢e_u$q'8v9VL{G_\#Uغy$mSzNdvRۊCSd=uDSm19,Ky }]9:QmC|\:8aY)$Iyayʶo+\xݼ2_]Xncs_؈f'}ewjU6Fcp :Yĸ]e&jkUnjijqXbireEߋC0oYPA`lI[kfP|WG>'yA[vT?z{wj/} e^>h}C?STܕkf.65@~M'RfעΥ|V咕rSMTzl4BC=8V$9穣P/* .{'CҲJʹ_ɼ 8ì AR1Vk¶ v_| %@diŊ\yn>c@7Fw.|(A}VWl@.U?}SȢ;%.t%ud '(w7d剢Il,>`~ax#vPNO6#@8W,3JD3a_||+fsrp'|fl)6)%̖D.$P5;eݤ 5TRvf6&i /RϾ>|kKNqY&몉2!zy.\ijNPti ^5tmI@03=l<Tʨem*AR%#[Vv4{  `Wq9[ߓr_{HV=_(>jZ[8xd@VL`;5ikTiؗC9FѾ!+9@GdF?983 WQ";[)H!xC" 5 B@̙&jXS?>)ӯմ  DQq4{/ \7%Xf3 A"dgLN'W/mߕ,m;j]9+~";\܋ͥ0ri;={/w]ၩG$4D&v wj*c<ICw|=DSlAu>; xc~;WO``٭z45oQ/ułסw7Nn/L`+u? ^ܶ?N7Bhql<Џc˃D>{أ|Lо/@J[GqGh/6x٢Y/tbஈ-ΈKC9eLKQ1L۴/ d;߸tCv9ĸ4`3R)l _ymtR]6# Ws6n(A v{d-ł7:XJzc-gC -j 3fѳbMgື9u3SW!)!it~}O 'rNjau,FGs&[av N5&hjXw'3{\@HSZouRAq.KUOqȳ11<1|)mϱQ:(/O֖u3R[+}Hx"+Gk^"X +oCde mI^%'OĨS'd۸ZJl)b]QTơ_?$^BђUBolbΟ??}WIupb[۟À^9/c[b'u^:GZ{w*$iIj V'-'DӘ4 6%:$[=COx*m ֶcYtZp@s{ȌQ{Icnί= m;+)az%q{S;4[6F)&]D-:|+l#CT{xk4gCUiBVq"րhtچT~/9;4@~0)[#gh?Ο֐Xh ށҔHIk㨹@ipޯxˆWiPڭq4?.o<--|,+ӏ,1>&Ѿ29u ̟ NaLwwFQ pӊ;6P)EʊA]ey B|}n1} 8 8<0k]FMbXAا%/FoC Q(ܭo#s^3[caMCկyx w}ڟ͛]jyW.f,Kw%էwc퍑(^(Ny#gEЗiVDN5krQFg*7y-]WY`:v b|`fb?-~׭?W?t^{tX16.t+*܁)v*B֮ ˁ4AbՋxE9u%q9N?P|LLU!+(QhSrT25ӏy{~fom/bRI= k&}EcF׫ꯃq6 -@!lMw"\ n`yPgg3bj.s5=?dnesf˽/3 ,) ;N ֟c0.z9Ɲơ/Z+[D%p9XwDY纀4z1ͽumW&6ta07wI-) Xe;"Ѳ*&;yYy8n\7zm4慕7#ƢסaR=*/sn?uS3%˭;/_ΦyPriOs sm[ehNH&)hf15…P'-@sX+0剞fۨA:*@C 0:@97m\a^\/L.l84|oV*|/@m̻#\p^!h++l*#ԓsGaMJn;W2Rhm5b#(d3ؐZBk[Xۂ 4:e-BMx1>csjP?KBf-~6* фѼ GrOK+ј kZ4B(Pfb=lG{p`hMun۱,ڝ2d̫(z-#ey+'n`!vqS;J.Jcڹ%nᆻxZr^I02hrSwª;HP:9J SHzӍxP:F/= ӟ?l pU>Hٮ*c8[ qn8Xdv:639itp:=yKAcĺŸTFs\K i= эo5=z ZP:sl8Ll1Ch}q^ze:}nblX_o|~:҅rk@8T7\չ[-v+{< W{NKbϑY3&D[զLvQq3*didq暩$Uj,'4"Tbɭҟv<~l":zwÁtw$փrKu t|epCQ0&%z&R+!8q,-K$zİD[ HAj3Ƶ<_ Goo <+w#s$:O8~:JFdfs`޿[+]0T3rG݁U)\CA jo?j.m!ZhȢ:P>l#USTȦd6O͜lWX&sB[A)/*4Zh=,p&@[D4؝'_&I60&.•K6X. 9l( 6Q#KUQax`2T21R5L۾Wx6 뷼jLiNBcm:Q4g謤$m^t\+ٳL1Q\ztd`VKx %]6XC^>3יpu(p[XfKnCW;eBE7XyigsFihƸIj A0Ȥh&͕U$nΚ},8LQQQ aNJ5t5=صx r,z<ꍧ.¼lIo2 ¼~Yp ۭ=\ޗv]b85H'(-2.Z*Ihq:pu`<[xG@;#~G$1^AbP{ٗhc%MAv ǁ%LCt)BP鑳gK;+̿n˺n4}|sXΏ#`ԌT1uNcho|-d+uKeJ}4Y6zgfu-he^kXOfߖP@HuxcsHFHm&ʉ# ~~Ǣ6>|:4:8p-Y,z-7fXp0RDjE814^T,@d$[x\)5?||e#bM) E*GZa.uw#1ǀʅ-棤3l0}CY8巒flUƟ#cぱl b 2H∪="F`'3!K|ˠNokWR$%}$,ߗOR2Saqb;rUWp~k^đϝHkEjwW $ qafP6ƯtjNJZ(VUg]GFQ:X] B1bsFeKi,~WīE=-ˇ֖ Bbn[Db-Kl/LmhQ'_ʋ i=1uUiz M[٣oy}$Hj9${ё5xkbG|GGܼu6ߵw8܅=\0a֫#9a#%K{{Q`U9)yV!x\E+*AE ^$믃 MD_DDO*V́3ZϦK<88Ym@) zt|/~GӇw=sV&s1d,,#s-|n͡n5w nѰMpKp,vBbޤfk4mzXD4XAaqrި4^z>MpmvH݂[&Rf&rtqI T=XRyqz1bl*@^9;>aZ/wxqw僧Ner.!*Q,H:fI 䮡sL'^,KHj t'L;&~XXj~)ڱV7 ! ]wH0͒_~H`λN񍟯\?4f^7` ^TĪ(I5 blU h5RHr .xB %LxtiXʨolph*WAJX ❥i|QPWAֲ!RJ7簅0rGl>]P­~A~ܔ _HWҺM+cy6|?jù'*΂ mɵa%yD3`{AE؆TB1D..P 72VѩBZQjvEtՒJP*uPU|0 Bأ7r ?W=^dphC x~C%TAgMAP3-m$ nlҮ^xOBmd=ӲDIJGN*r\-cy~KXՓu9V>ؚ<Z̠qlM]~e>+Dĭ+y]B&h}fcSgE^(TVN^{z1!卷ޗ& Mܰ, y*e/@wr$` SҴxh(%N}9ȥDm$$jaN/Zk܆kI7|"Ƒ-˵͸IgHE۩gւwcض< )gK olG>>JvN} bD'TNvIE};ǀ'n1₀o  Yxe /͑AyF@֡ '*HIYz fBK9) D(M4~t\$徼~Ξ.ȲXLo q50~_g~@:WQ?0F%!J'.DI9 dHoـrt6iVtx|BC@0(?j}cL[k{g~xS)Ak5v\DX1CRڙC +DmNrW$Dmd({M쯺 1ֽ1%1/N͈~hh6PB#J|T۰^r00-#;i^aSBCfK-bןc5Z<@C8&^d;}̓O;aջ,?+kqc6x4*ݐtwfsD69R1 걼BB14 j~C} =K)[b-s+aKN47:|FhWi[ۀ4.#\telDѩ"MFuSyP\cPSӬ֪si7@.D]nZs% 0XMNQyd Ǔb:2T죷 늳kH]^hƗ=oGkRhP<8pW*4(45vn#C9LqA/NB0xgJ]roψz pIOÂWb,y]eQ#hmu6 *Lwխg~}@:1+Jii5De}) Nv.GGh-\;q)"m7ǘ:2#DZn8OT9m Ź5Ӆ+#Ou"T_P"F[hU\9[ǜb+8on3SaQc/Tq+bafk@M,kgԁ*gFyr%STWW3+V>J6x{cm_J50\(eR}(؞L5.S_U/^z5|4E3mmV#4 /b$PeHx=^ 4D:8hBNpq\}-6y'a+D<kY__!IY!Ԛ2`(ի@6>Oޞo\A>m?_ttG֡5Ԅwelk*XmꪫݚQlF0ɼ\;c`b %(ǔ`+RvCWhXJN#{zD' IXᙝ.&xЈ=(gڢ+)&6/跣 {FYb j̹́q\=1U]ط NY'epbm5 {w+dDiQR46 ׸$vxf\45"[%c'Ʃ'D_'ZQPr*WI4F2 ɡ~hV|U/0~<*uwr#~@Hˏjr3|{"*R!RZ=ҶmYvҹ1c.F@/ܵu5*N:,5/?DRiiIǎ "F5fԘ1KE h{F45tԣfI&}G&9v36ݺ7c v<6N ݮѰYh%ytZ4j@31D*THmsm`huGm:)81"^7#jeEZA#\P%kKzG> _׹j9eP(>|-3O?CMy0=|pݶj:- '}H}i thSt.wj#o=*B S|nkb[ăA{´aA8o0'!(bp@kW͗ R?*JhQq H]:&!dΛCdjq RnqhU;W40޻{<vm-ܩR}ֱ ƂどӍ{@g1>. YM؀\F=Zlhȡ$.hNX"brnaKg*~ B6t] TZRa5 h!g_~ɫtzω/qkc6`-JiU{[};UzX<P/xڍEL.c4=ݛm6J(pP(0B ~_$ӫ$LGvpnb.<@ ߂.BҖ~B}gƝF_ʲOxgl@>Zq..!3|n@k1޸aݾԘd'9.lEA,LZl,JzjJ-􅿱)4ekL/ZTE&jmՕhcZf#F#gz,M>x( &<x1k3oq𠱌!]@`h˜映yknli5kNX@P Na/\c 0j:9_)]p b&g۞]\d$Dl5 gN՗]VA;u"a;"`(pM! \-}z A}y!cTwoC6?V?=\Ȩ;P󕅐Z=&b1rn`g? &*n&'X,0KԒ"ݠX <0ŀd.*>EQn [V6XqX"x! &11z/M@J™"ZږU})9xX~&|!S,W樵D-WɛWQeqvR[\C[ Zв>Q|6Z>v.H {CD1um\፡fmyP5crpvnـ3#F=>B61<A lmf0{w=<@^o{Ξ>c%_փMn:b(K:O'>L_}[.L:A kЦ!Gw,(ϻF\wcClf_ p!@h4 YOf>լk6KZ. *ƈ+WS(V\ ZPyp]<7&m`C%' txq'w)v PuYE( K૲H~lc2%bdž+!h?lz>vӗͭ -b~jFcf' 6䦋Ubz걏˫oݾson:=@z@IDAT vqj6H֡3rvP ЧzPo]{k=Pb>n~NpT+r()x-AMw! bn4mdJns繦eEk ƼxBKH2hMyaGhr:F+9rzy+1+$Р{H,zf @^H5 [omސu/sPץe8òcN;^aZֿWQc\/ j([\ ʍA :򖳠dRsb5XcPEQǬJ, Gv:[0gК/k_6y3qkfQOsTj&ILj;V?%利;>TGp[[ ~J> j}qВ*4!UXD߲GS*KLZ;z:Z زMVSe"[LSׁUYdA\ەX1 PldBNdlS'ou+1WL̓]kbIqfhYghn}x3w<䂨f&Ǚ=1d;iӲ7;DA"^L*9ȋb%yUJǫbЃwSg;CqbU_ina!fQųBuC:l 4ӧ^DBW7wonjsrFA'qt!w},ĕ_( ΃ھ!a*NfDuTӴOKuG53gmlF#'vǭ:^>%Ȥس-b=-USYMT\}z Лûsm䱔K C?ow-bKzK"oycb]0ƶ>fq̊oF_Z3w\ '8$;a ^0-oiIfG7 /L`T<ѣFQ9t!`1Вv! O!Aמxe.k2 />t3y lUΝ׾i4͑ޗfM' ~ _{kڲgBêbjtivmQNzuΛ,âGwM1U?Lnq5-.I 9X\yyҝheMhm̎n܁Cޠ]=zttk%5rTNؓ7ߪͽ2.n}^>#gB`n_:+EEVւ *eؾ R+ pwk2ߌD ?0N9%&aUDjm>DAam-"lN+Sv_hKl#k4phAC؎0UQhgƁrƋ&LD Ҝ_4xp}sz{3.N92Aul_Zu$0Tc/JG .pԛ;/MEY-t],5T9 ti,Iڶ@\  m3/0ι+O_U^4zst`Ʊ5vd oz[-'c9;^@7O`| $`{r<qBj 9UW)rh9)c9pKf_(>c*Ǒ,BN& ?JFzeNuL'((-H  k͍6glx.ʞRŬGSR;?' Odkg\Mtt/@%惵h4?GoA(D/8|&K ? x_޲zN~5R+魣찃vWaKs~ F?hWd>fPpnE1ir72xMLWH(6:3|Zkcu3Vʖ w˃UŒn 2> I6!in$t1|(/,َ w|}"fY[9໣c*Jy!Tg&- +%mf ud@k". 3$UQ׈8pAT\TJpm]m%JG^`Qw%Gf,J%!]U͹PU^_j 80բK#MF#eo,1XՖb9Z(;׭\] {JZ [^Ì2ŋh..Y;?4:4.q <u-v}(veBD͈ɘa. Gr1wh1X3`:Zk>N-NH$GV#brfrTPց4Ֆc)_%m(@؅`Wp p\QM!;t墜,wݛmQy`t d}a_)Jk]p1Dmܦ`S>Y29vU 0qAhm(+au'I09sȀ%nCn%P+mw uD6aA cOsgއ\En}Ck N%͂i;[o{} `ju^ %wo(7g PL jQ]*Xo; ɈڸEZl4Nj&DӢmn7vZsWbn'9 Џ?=,?w ݛ>q\W}_ .(Z[iw|i㘉qcwK(ӖEKEQ b#sϹK̪zyZ2Փ:hK(U,d4g^*۷l-=8n>9yZe%=}uMUSvTGKL@3Sq(`W/R֪' rϛaQa>IlJ?h~+H׻*) i s;t@,p0 8'U{cո!ORZEj΢XQun\] 0OO_-!@*7r6n<<>nH=$݇+R=sB:.-Q.s>uERMIgjEhbw?_:@v§b^Rpxz)qĀ9 LIM|G]ΡBxF1v x)5mvO4ݱFz&\U[vC\"gIJ4h<",6u!s}zj"6Ep9K=/ ˜SrC*(#(I1_u^9w|vrz_3cj[1XO xuYЗ聶{ښ-QZ&:rxr `Ne3m_?4[Bwh2xU,#8H \,1W[<20xu>]^ԼH*ȝh c@@>-]faӆUx Sb BKpE8谷D'|i(m$G愤V&[푇5؆s&:>nmҋsYv5>n[k\G.Utx%!F)K7x,EM&J B-hca5"l ٩ZKU5:7%xbv'/-f>]%eJ|`4tlytu/r?օd1t8L s.1گ9FƄ^:L8CJB}3^ EFRPZDr 1=5Xs@I<&QIDo5ipKMW::W۬sF`'}l@ƍ[˿C-ybkԞ]ێ4Q=Dc.uzuODHtڰM@:$>DZG *Wtj9CEV#:jynZ1\$ qt Մ&NS:!_3@C#>?K蝳O]7_N>cbߞ]f<5bOț2ǎX8#'Gm+`2HX̲kb  v W-O -0X|y^9{~/ >ו_X]V1;e.^8ۖ)קl:ِ:L$*a8Z]0:P'͠(3VAm1,*XKJ*h@V ~_qN'd@mXLhހ$ jf9Sj;6#:ɆN*Wn͹uvwwnD:׫k~~HJeU9|G$NOm!GvDh<\ aJW,B%|2 &p1 W4iiU'+Dz.RX1'Ac+3T6SJ?WD|Z$ƣn²rڍ[K9O˫Kj$mʎeDGӕm/#Kĸxi/F.[uX4N:YԎ({:̶VYa Keuj˹ ZUK뮟WR3  &%h~ 8<F|7iyl雏ʵ)؏VrrJjȦ>ʋf צqNTl9@njV.PaP =VQRjkܜ  ÀA4zd^7Ͽ7 #*>yD@l$F-M@4?:ݥSi'垥7gDk+oˤ5 ?~v0=r t|D7ҪlcG/v>ES,֦x%"ZHɧ cY#{k:K6Cԏ%M(ξo7-Td/ Ә_n,x~Q]&/ ӫ>/?rԺO}T@7K1-@5=6^n :B$aP$чdzZkѤU6~w:’uO0Z SX?8' $GrSM 羨n=z6T~g̒׀C? MɲyR};dq[U5HؙwEp$qRD7q?j K!6BT,].}9 Pf~F:+&'w2t2e?ApuvFw2n0hKIR957 U7ݽ]?X`<9m[<%]וB'gNY>n]ܗ5ʑӗz6t RYJЃ mWCB [őByz%F.A)ZVoaLf() K>0!8 0բ8Uc,?$'ylo˅ΕN-ǁfHfuk:bzw0\ 8Ѣ6?6_)d2F=Z~CV5 ܪmB̗,N`&:%P1 NKN$܂clŎ&h-199ԣ\2]=%Mf;<|1C,e۹ʦ˽37ʵ˝;ʻIbGf˃k Ѳ\L=w`ASTVVE9W/)()kTrlOY4D = )# Y{Pͱ0 se緸%&Hrx r ȁ:39hػgo9va^[;~U[a5*.Z&M3PUH »eZ,M{yH۩ \ 4A.&1.߮:Nm/}8tA W&.\h2X uDOO cnw٠QfEr+Jͻt I{x,p}"6 "9v\kЕoOemq}aFeLm@\f*(sDm>b}/[:DzI54!.n$jkA nSc~FnX1]K.Yiʦf4,c CoNģ.ձJN~o?>` ob:@e2/B\-jp9tSVT}#۠%\ql& {*[nԝ{'!vڒ'({=0"`x!B$6QVxĢZ?hmZtfz ^Pz,QrM 9ݟk OJ=_qS?ף4"-D1!!TS{T[5ڌ@yɖ{^ !^n3j *J>=ՁlMqݼ`t56n!>VFVyGo,Lʚ3y'} SN,o-ye\FcHt5*`1WP驪%Cy%з6t,Ѹ%WH'硥ӡ?)L<kZd#I#yӀ5v>b Bwt&J XSFvCSwcO.Qie+do0MX@9 7{kBL_!Xk:P֪]Ou7]:?s!^?CEpG04uf,߄ 88lXvmkx:tu~{Fpqu耧=ȼQvr3hZQGT0EAP..n^+EkN ZN< Xвcrl R= @s& Kk;褾Ge eLҍ._j1.B@6< KNf|:o¶yPY4N!&KӀ*k%hd`ɊL5*PsepWA|B ?^L)MGR/ںqlw!4M%+ py_R=8Y6>>ps9bTdUO4\畓F:aò׻!'+|Aw1NIk]fe˕@7}'!(T֙4.zLHSO3\l,V>#p#`7_N,B&CNϱWVP7O5G%9ޚvj m x?C{:Aֽ_UB`& *mv&U.(Tر}G9vt37jE$) FDa]>7 UdD|aT:wkEnx g[|Ocq`8X=8˧@ 4=5^{تRB_^;W>EqG15hVLL˗6Ys+7=;ź:pp>$pCW ́:0K8:DKh{I~RQ!yɋ0p!$rV\D0ɔ[rI'91`B<CjnG"\ÂDKuB4ȳDk P8\R+|7.U%8 j1 B W|QQ>Y]O{BHy ̈́)vK0Nts%p{uh#i|µ! mĂC1փ'Կ$ZFt@ѣ2J~p lǠw_c'Ci~ X)ekf_rٞo9: L@ :pYB!im0OUeQFv<ىCDnz=-1 N!\/VZ\^`,81~@5rzEQU)ڋYuhr)V-ȷD~r WvˀY rvFFoYtZ4&[F2 Q@YV떫3.75X4*OTù18ڑq,aHu0U5aȒ瘍aE/ex /#{ x/M_61ө_Gxyv&:%~otnqr'*7?+wD9xw_;yz5g2wPF6:<1PɀM$hI>V4.a(H\E0!+~Y0X[}r@%^qf:d G:uUKTbgRP"5/V7 ̤":sh!I"Dvt]e޽9/{ ϣ,ض>]M%Q[_+#N󑢚rG|F-B53!ur Zo R7LJ1<.$M%hz^2Ξ#zELS49\'wP.{5XglHSoAJĊWd7!M7Z}{?+[/%Wg|C*7Eǒ"2[;q^w 8 :?<\OH6 b* bO 5V|=T hZj8 k+^O@1YD')}J'&^'.=SB3 do[J wイoO"p,~($XU7Ic'l a@;K,v5NjZƶuZU󲀽2OHiF |&=m͡4M%Iߎ*BS}g @N=6 Op5mi 0 ltV~ QfHN˿Ts:Ђ;w=F++ ]`':Ov4TW+ -8h , `䃹(q4İIRyCxҁ,srXHR: J\&9RƮ0-UaEV,ĩkpiI/ QB ֺdY=2 Pli`~jmdo~uftrrmɏ _;K4JO9_r'JG }TY5i29yLLûK?xv䁎s8wlIu rK"qΝrTO \q>#>m_vӲw[ZAjd\!zlmQVkM '_.n *1)z+bU9d\l (PʔmG|XGi&Ϗ& YCˎšbU?f,̱zjٓ5UOX+̾W?9s9{m>pđC }l}quQ%o]4[yÙ::GE#.]pTj떌0}PB_$\O@85v9*Ƞd Neq]4$BAj[{2"Qg@GۊkGIkleVफ़׀;~7T޾qK.W˅mHJ:O wJlј5z:հ1LϬ;nۋØ9FUeѨR#sﵘ89=@ܨ_CCWҌݜҌME@m6ߕB%<j KT- `iLA#*Z-7yݾs '[ͣ͌7h^9Ai6AC¿)&bpC$Nh[ /OtG p w;?Vn>#B!Rf! $ڣ ޴]qިAN#-Tc]%ܝNLkjR}4 o e=[U.|v|GG ݿ(mu:CF?M-ޟ_nYI$2T_9WόGW~P)*|H^D8)#tTbL)HPqH/zM99)ƀOIOD_$h?EogB->ylr.h^Ǔ^;GVj=\^1;kDY{hm+Kq& o(cޕ2i up-n-{(ܞڪb9 EK)G|m ;"8Kцq4I`ֻ5`+|ɼxi2~+Z~bW.T^Ӈ_7j1 #l0Z`KCYWq3/ [%/_L>-ڀ&XRj|2љY,q\N O5mHJVCB%]/Ї z(yW̳uﲗg8uM]D43+k1M~>iqD損9UH-G^"pK3Lg[pzGέ~dN[3< @/( !7ĠUAN%Inrk?s&C< "[t<P:;Rkb'h {EQq"PQS,Tz66غ<*O-]hn1|qPk:ft!/=+nܾ]>=u<[{eyީֈ!.*cwK-6kz T*3ۘ=F*Idv ˣPe١V4 l+(j_'g>+)Hoepdx{?N=_&]l~{npFzױymCEpʤ R4_ LBsD@qȟ|YGs#g,n,yLp@.`Ƌ4 -lV-e, Iw/뼤 y@R rhўm}L5]<0EA#d1Scn ,GNL$ft G~ O}ֶy?lw-h>[п?CMYxD/{u6ƽγ527@/4!d'L ۞x;(y2Uu&م:qӗ.)0 ;< .]g>:]>Ix9[ѕ$Q+=` 0 + W!\mݤ*>nj\+tM :4a["tV FoF.6)m*GXҲ<#;2qq>vl'k{cU)g/\3{'%;k~($#&Eɷ7,-ʱ < $p !1t>B x0srtQ4 o3eG?_W{W_AvE~h#^`f)yp MPxֈXY & ʂd{;!P{ /@@/> b0 GL>be%OJ JuLP3Y`g\EcRU\G}pT|b R/F֫:c]Bˁ޻wCfY"8YyrH+lڢ1o]@46(Ԉ7ϝpFfMM!ݔI#] 3qF& ]@IDATPVf< zیs s6C[`4(/CufqxN)~q5%<:4 C O([X4R@?B*V>#a@D~)O )7 CAOYP&(O|u(~ e4":luص{d#Xqry /DLOG_k*{:ڎ8ַϐ7gzwu# #?!/|<9kYp}ɧD{b2`u 2,;.8 a'v`g:TX̔?*"'?V04tW$VJ7P%#~jB1!J CS%._;.}d_X70iG^1:bIb&-DYY*B,/3OCB3 K5X*m\l`7*h+ up B=Wpsz3)ӺK?$ f'yAŢql`D͚Q1ۋT颒Ά^YUfyСMG*+J B3'圼d[3 ҠHzjis"RHFg|èB7X⋶D#ǎccsNyʯN}w-5o5{U)wuI[tXk.;kLN8|ZSX5ZL2sPwlvB&ii8ؘ m¦?Lv>3Zy64:ߐ8 uzbR.":t:P.ޫ׺D6'xe2Zg" |LWZE02bhۏ AʍI  )y$/.IY%2R#H3#(Ul jb\/|Ëc+8@si$Tj%}@De٠bNKK/ )_ھsrrz)䤈I`{׉'7L#*XZqu!g_Q`cDI#k< yߝ=_5*wqmy ќƗ$U椐NB*QvJ2wraT%2j H)e oq{gCq~+_~otT"ot|~L4SԒ98?*zB)DyMU5d38 g8rGAuOy- EI|VP&f#v{|bX Wcbdu R]R=c2\;NwmK~hh -P6h:%z*$c)'n<7n*7Di_zN$$+}rZ<@EڽPJY٪K{rũ  Ҭ. 2Zf8>JA[2Zdop8w;19XRW2^wF2>5Bk;bVVP7wdG,ޕ-,]7gٺU= ݐ|sąË̏[ƅ3SZ5UV-cp!POQ}0 2\1jTko(BCDR2fr cDN}V7׸pCwQʓZJv?h0r.[CuH_e'/-ʯEm[okr:p|,v(E캁v-:ՋrL]K DL0D :vxA-$]ؑz1Pxt;JC X L@RlinG*KĕH k}Sl"kQNJ2[hqQE_f(i-JDlPEY**z&_k6')ׯV(cb HH>7`2g8 0š]3<Cε8#vuqst\];HP cz 6}fمGЩ aӁқ};ˌjD:x=O[L@sswpeh;?+{Nு 7;'+O0zrЛ)2AgAX#|EN&;X0gyN6#|܄G\-*taIQך~Ћiu8Bn j݊ $ ,1*S4i1)U&+DQ-vC^XM-j+cWTN}pr-‡wࣀ>o5Վm6K seS.L=6{S mP_607HK[뼦8m!@%9NKm?]kv/M]?pgLii15CܼRiup_E-пk>qV>9x<*_FȾ2t7a-qz?t5T1> 0J}O>Wk]( ⊎z-=p"5-+ 07㠣`ZT-TkƟe`ǀռ4NA#y;QI*&Xs_ղY;T7iu@'7`XPn(u$pvT_h_D'Xq마̈znR'ZuYڭ(LS*u`Sl"*h6sڿw_yV 6-Jn+Rn'H÷L6(7mA4J8X۬ El:4B:M)Z!ɆN&ẋ 2r5;e}I@4!4-&"L0Y5]2Q4ٰ2a^=]])x9q bO!_=_bq0%47/e'$*u!d݂FgSN 3kl1V5:rV7}&<ڞD_.0V_W ^DYݶC/]yˮNw4/s dN䀅;1M`F0Sʟ}0KP А#<@D/ښ,GuTX"f>!GLf1z7apJ$XqF.`Y,3":ţo?y+_Z6=rΊ`>{vٳٷrҠwNR/PCRCEo+vGaY> P9jCrS]DeZ&&x}^#}_=1JU}<ψMoPv\:ֽMGX~_?; }邿\Nr_;bUwr1k ғ7J| /qzv$'CP|C>Ī$XQ38.3٢n q}:70R4B &:π,IMcڀGAtјԵR2׳FBvY,P]lv6P5bqP{X~G uvy+(`ȟZr[S[  # ڲ^Ȅli[ok ؇e8M<2P2-5JU1AFƾh45NP %h[lL1OdqB9ʶYg?Zz|gˑG#đch rt.7{k W.zJ.7/;܉ё9vE 8jGܸIu^t^qنa\pЦpD2D5%V\"BF0u߾.G[ja"w*c [^X m9Cz"7"L YUU8KluȆ[7ncA}z\UYx߶{򒠛{vM}5ivVi10'hy&!ԜjCJ]pd,g\ N̼lQq.Ak L}i6|}P/L_$˸rn9/˱7$oomKo>;|;r$2 ]7\o&k]%i]]~]:,rI tqcqȡOр3 W^ExIƺ HDRkn6caQ}uV"`Q &1qkɱU6Ј6$KqVfHv6@8F6cz 5kӏb=Gp .])'~ng˭Z| c8Iu:%MQ-ֳ5aXi4zQKt̞}^ Lt4A* sژP3Lvcg<ݍDQ],!!B u h4@seP3ĉ3 ʝ 0S =nћ>{  zXj};ҍJ8 YސY39*Q?qK~ HhcQK#'vQP5y-.sn?{|zG=t<魌Dܩu6i\u[?KUǦV\Է/\:KuSԠ02VSS_:ӁSkЮS9 k :U М1k)I"Jjx(ۉ0㫏o۠c51fOm] <}iݻwGo[=}wL, w-_fQ-p|k/ \a aH3ä`;w:Is? ciRW $qM9~U_:Ρy0uSsj^"K:m,Lv@YU12UN/Ps+4r*/b#~t)|w4h 50&Ѿ_(>,Sl wbo9kMv|g/_0oTyFyne| .+n%' OMkaDp2dal)k o".^sh? K~r9|ղ|˟Ar_g/v͏(:p|_-;wxP!]1`_ֻ W6i&urZa!!3YD<>:%--pÁ 3AlS`'5tX%%?PD)oUYMiv\sv8-qd_q[>Q{۰;foaGp(>=>sϗQb^9ʛSˍ;}p /}xQr0i&0K4s5AVuEIj2VV1ܛFE&&Όe53d@U4`ץMdu<q=,[?`T}~g[)?< 7 >5뵋-r^DDzQ]d`U0 vL.;p[tb@|q)11}w8y/LL.o= ` ™s$`t 5lAUh\ ⃈+b]TA) 8ӟJch]&h`hxӣے6Q;(wmstByoڶҫ < xj4jŜ3隨02W:ޏeE].hM-~\yvzipuye|7z]po"o{|xiKG*Ю f[Yt֏g៺2tc%tQǨN O!1^M9n5vx-{ {HL :.O*azZ9rgo+}+Q7˙e~̓Yvʯ~vyOt>v&䩙K/S,]/%K;O9xM^Sg楊EcZ`k] CTK vr@8w$R 䉪>P]݊Mwa!%[8XwXdbS3'j1D;N4&  9T [_V o^yFjIGUS}9 zWQ;*xw^׆@E$pK@5.Pqh"Bd&vȶk3on;)j"l6|,Yv_CG}R0䓖I0lQ1)p">I#UJG8 _ u =8?>8[|zB:@nuO/EHaJ[@650Wn6eöL0Ч"XKص: }_]rYI/T^xvOo^'D_rZovYG:U4jno| xAny5Yc!z>p̐6l,z2_)E5#]hhZ!)˷ȿkw6]Z\x~*x?sEvx ү1^j<0-ʦ݀kplPH 2ewhuX/_!5󊌱/xٙ9 ]~_@ .zPhQ2s tPǞ8YHC0&^Sw~֩V@6ثI,,~Mj>W4.J!O&`]N47/;_IL^޽R\3ooI@6K:Փ8lNpSvRN̠{Veeŗm-2fx0@"nu;@/l,.SzGeυF?wo?_·/wv.mLj^?mkb18>bVn%ZYd t'ڿNkrD@꣍%I8x׍݄ ؂lk.T`p&F1 OTWDOIO|GAEk$U͌ה/H&ED4'2Ә-NQV,Oh.XdZgAc_ziGsNyWO߻UeH|dmeAp腾=^L=" CugX?j$MZS>q)f5MMe]X7*H⁽e#F%^.ϼQܹ2Y]5\.O~Q\]xm<| ?_ keTl$1BL-I쪺1ٹ*v>Nm 8J 5^}*{4x[RU Q-*z|FXXb\@wb'$ s,q9xF{% m1iF?~04KWU*E%AmQպIlJi}jḘrf|;-^o/~ܸ-[h#oo˓|?ʉ=;.Rii`JFd]핆11ٞe/S?$q0 nF5>ß} myw_X߿x\vs]pݻ|kɝ < l@ƒk945TЯj(U'Eˇg> l*C<p_ؿ[*[Q У[Vd &gt!}WD2;/D X )&0W|EWē |se:FE>aј DA9TIM; Nw4\P@[Jɭqy#WNWlΈĝetXR`g| ^1r:/^ߵmi7AcV8˟>|w[!VZS>?}'|ev Zү=:ԽsrsL $Y^]qI??XeH>چB:`UF d1ZZ(&CEaÖ`6 D_3ֱ:h#B%g0yѲՃG7tqxJRV[&6[r .ߒԸ/R~rU(;Ǯe204ˏX@tK@qwU;j jP '9c!O;~b]ŦGE{dh'DD>>xgEi04g3Pe"z΋l'}i|pJ[$2+@|PsPQ=s6ԂUk&sB,w#H!/<Y,I^L`_(E;'1y_ o}x\@]rʵ]mv'7>[! ޛ$rNQ#eGTJ]`D٨տ|3GdS+@'eG'mDs%$ϕJCy'}vV4w=㟽_nno^6@;urrh8pƺ]o=[~f2bw%砯G*=xBۡ ،chJX"CN2`jH&5Gb fȟV ^szs"V]t)߈I)QFCfDPxBN5m'JU)M2 }*)rA[ڟў뾽l۱Q“~o7`0Gw*Е%K۹U)@*U@J~5ǯh&ƪMKYR4m.):!NJSɤPt뽌[|\>RyL^38V.\U_Www=s5#{u73y}먭җ_owkp^l/'~:pxHĩdWD< w6iEޚ5Bt~w ,AhkDHHA!{.aW|iC@ y64r2r4bs -_qm\*X6lSh&گ ?.D#kg3نTʾ={?(ܹ0yzyWsO? ܷ!z9NyҮr.:%$x Q֮C͈1 ϶{[r8XGh?hGMɿ3yeryj/LY74o޾W~|6/#?\s>osnDkS˾.|N5(L2N'U]8i2贁DlD!ݻ#Bq`` R2p; * @d_J9S|,4Y$'o#Q/uKqLR!KeEKp ֿx5 62#yAxCHɥ$Rԑ =n,|BWt o06ٰvБU^+S7ow^{>Z,ߢ%G.-^U>)L(-~rileDU!63^UR7fai 0BPܕ}iy |?p|P^|&=T/y#+_ ؠG(x$.ń@vRq0?vQx܁z'=}8 N5)˞a@r Zw0 P(&CKqric "S4+>QʜA"<0?ݯܼ}k2Ɋ^ F;w}[~@/: F=[_+gnno ̃˩:Z[I[%}8W@KZp+]7۲ ޛY\WbkU{WUW 6 V$á3d3f#C1I$ӇL64$AcFȆ 4zC襺++++Reh{= px)2ݸVn] _?%mo _pd~`Ӹ?Cۣj}pG 1:NW_JfVǣ;ڔ2O:rnIG\55TC6E;!*լȠSf@^~$@{`#ۓv,}@VMs6<@~0PVF#P">x౐Kl>$9e-rfF&`@:LEn%[xqD|iƪ[Gi`mLeM*#A;aܚ旾~O}쨸 pc᏾Xx^\YɼޛՕ2)!)uJb_sTqj߅Rjg4UU6|xP-p111t­Y(s\狂N )>{&kW]=E|G:D;-Gtݱmw"/9s$""mcu'L Jѻ4bg uUiTYb*1Jpz" #N}[8ڦPm\0^H`!jI۫ketC/D̞[8qDrqxsp4JF''D/boYyW{E3*" z 5Ms||<<3YVgO ;O~ӅWgk{սV\=lTeց3Wt82Y/'bƵi[>b7 3 My3]}/yIm^߽~]^?rXx'":bЖec0``畎@P ryִ nNePuiIqTa4r-) d[7I&;+'9:6;qd%v|G AC;|h/1> ͓70AǬٝ7Gm] k6Ӣ樍'8e-}p~'АGpڮ S!ZTTX)@եcTP~9ܸ~? TG_',Zvvm ϶|vj8ÇxYr\ge+I hȒR 3XVوXǮ͎(ne;$[aFٰr؃icok-?;ũpmu[ ~p<_Ni8kz'i~2P$L* /BmGfnOM6 GQYW6ơC ٹۚ<t.F65 fB' ,1ʕoșe;AÓdjb8u@97 )ܝ>Fg0A CSE2h: 3'c,R ~z839&_/A sO1~t}<}jo\\p5li9Wn4_d\بM "UTK8-rx_䌣_Á˸CK~P߻uMA/6$` ;So-Zv+mҚL$j'I:h: DuA''(r%%j-/3C ơ"~x4(xrl32h0ش5 s9 F!d@џ 1Q+iLc%//9bNc|rW[d1GQ}<īOm&rHzS*MR{wFJ^S/hoÛo(M'Zۿ;4NMCLT*:4ʝД[@IDAT芽6 8FOeN|=~){bVFP o?;z~ vzD~ZQ&g{ST&^rP'@iWE-+p"SXs"@.2! 8j:֓. ^)oԝȊ1N1P0>IV=>jӫ;(yC0@3_(b5\61ئ{'$}ʇB-I\eN>;HJP~7Bj=3$kFbFۥJQEޓo}oSXsp?φ?]o;ɵg\ 7M͕pqc 130[%TlŘ$Kݟ8eQO;9Bػ^ wm\Bŭ kI;i ]?pܵ|uTVVO|&<ݠve%;I 6m϶N9#-iL[` 򍴲@dD*jd6BO5dF6R4# %rA%G~(AE.pvb.J2 :n}!¤>+,`e>c;8nUmJ `V8bpTb]~$q;Q"0 WPsirlC"Ò"}\d څ). 4o"B/}[_p1~k?|->}4}4<;$Ͱ=˘,̟fmT([I+^(Qi׷K6fo{3y.GXKޭaa߻VP޳coCI:Ë >WGw KgX;MkXq2]dae֪0'HLgGm-, tKf[j.T!8TaЖRlnlhHN:qv6 :9"Vx9PzA\B<P72,d, w}b&0v[~|[̅ӥ<_u*MClj2!K~bX|3s"@'u󰱹߸~™:wM)5|uVx"OvP[H2rfd$jvk*,+_nO+War*?>|ƃ;~b^t=sy>]8do 9+^[ wκ3qSOCx_tQG?'AI 219ts5f;]kB.9)RwNa̰Py]0i"@>5kyu:˰(F2m1 ghhAY3m W_$o&$v 6O(" lyRTSoG*iL# zc&ưD\hYbKnI4=!&kk _ƫ n3 {ّ?eo^i uT  ^|.\zBLz <{_=+a$%d13l1xa|ȃ~z7(VѰH7 V9l1:i!&>`[ MԂ RTQ`Z@a8$Yi c}+-h@xIVvUE#ibY|<;𹲸pP*P: rX#@Dր<"!!AR(c^ .@6$`B%N /HY=t% ZkcY9 @c]y)2 秀> N `pC]SIRrUQ `veO߇wOzkonN@rs_Gτ.Ef'"O~tf~t28}!~'"2so?Q0然.{AeKy1ivmحC4i Il"@Mc2 iSImJ:'_Q$!A˱1gKч?ᧁ 4fNBAr4,`ɐIЊҠ 㩈H#X0."8?;e"e~QM:E8%Mb42s+@xh͐R+㬉M[R> F8\wCGOuJwm$߇f?1|Ͽ|Mk]w%=g ?|/r@ɮŎ^v@u]ؐ-q[S2L9A qT,]l2'׋XXt"w] t/&U4!Ԩ[u`js`PS]cS8Jd jbFbyQ-U\WFek†.z3$ыNʥtG krnDIf8k/l@f Q2g"حgl8x|M hw6۵7ϟ7_8={pWhkҕk7{o'>=#;[p&ƿr<;$x z[aH{qoG~84v/҉dt:Y{똀Z2HnK dЍYZ"Ne_4\*qrAObUmc(Z? YQ$5``tc9rFq*x./2cEsa:4 x|Ƙ /~Au*; vj̅|?I@Yal| Ͽ|*w/?|hoػ̏RKçwos3{W;'/V嵼_OxtC!}xϐ~Ζ垕KK +9vp(wJ`1k:tJ4vE8!I:dd`4-$+r% :?"b!(^P#үx|`ϬpS zArY $ H(Ȧ1a4c$l/ByY0kl74DI_*44W3&ӑvKJ'q=9_)5Js??W^ TX*|ËԿáx{O͖ |t&+SǗWߺ|/.0?bd^XKM 20`7_SX}0H3J)HTMw"^6-ќ2N"j)(yf:F@TyN$@>R"r1ā$4Üqh\ &2I (8M.?IzP^pIIM̓lqƢd_n>պrte6@+++4r>B@\RhzSLqߑv׿yWΞ=;?ȿUy,_j }wÃ_+ុWÇ#4 ynoml…Kkr-}|MݤSj:£>Çe*%ѕ8싂gNR X8CGcvhQTM6 }\FL𷝵!5RtPVY/Ž4@7i\WWF"2fؽ#X'n9;Ȥ^u*m=$*Ttz31B ; `L"`'Kr %='wH\ UY9)1- d;tℨccG&:=|%U@H" +>[!諼ߵw\ +{ýw ޽W.< [By۩Z&v k7tPru= 0ȯBΗ,'N܍:9~H؃xKԆ$,ü6w{001C6qvnr:~a&Ӡo0~/)[Ve#j| c(:8$@\E9&Spz~6؝yDq' qp ҨC[:y#$<{Y-4\]PNrd) eSKsETܿ;ŪbqQPG\Zr&Q+244"O./?lopl!6n3W\\\~R[Ƴ ۃ{Pbⰲ>_[XN҅sqO mavίЯozk7oysο8Oo[SVUGäml@!n2^Ә5hB}t%rcqx2vz(C?J*GK2pSt!Ke[ן;ڢHl>ؕu* .Kɉ.1~U8z@]*cR#N>ۄʧkɹ"P̙w0msCѬIh,Qꪖ%&ImTjoL 9݀G 0 x՗Ù3ޱgkB^_WlIB5 {\8ؿˠEN߫o}Ulixc1o.S3^K^yvNv]#@|hkN w[Z)_5'ӊGHDkVKh!˴TL\6)+",q\bE& P-'БТ">bSLrP:9\[*Ɵ'/O1o vťpJWz M"hS X5(Z5GkՒ=i (;U80 9raxw_z!\vMp/WM7_b]pC(p^d\ca䥨dqOIe':#8?)MMRG(j1r,V*=IMF1i,P'ԤC|cg(0`zKu2'oEJIz(ku+)o{iFIbVb19!)+ ԯ.Eթ쵴%&֧Ad/[a8ٺk+d^kZg1_)uҨı'Gpe; %ՠ >\W1x=Vx`ON'ˑj7 0ZqC;7`<Lʯa)MZm_BS7RX ۨeS:$q(EV "`*ZU\ͧ~(VJ<6 MHbU @ ƺt_l,qbѥ &VUV]# L,r\ B=(T1,%#^'KäuFFe\"Nڗǘd!q&!HPT5pxF?o)lU/Q_պ q'Gs/NeNuR[Tۿv 09f.2XKNT5ZD5#Gk ?ۿW [/f_ H_Cكmx"Yc6);h?Vh ȋsYѩ_G.۱ %:iNl%v*9ٻFėKvnO9S-wKn"AaOosM覜 ~\z8kb@߄%|CPkOyF>/c֚\g?QHɟ,,\^pi &nh]GtbRF 珷Q9Prٚv5 TrUSR3__³ᯟqI<^~Nlo/MGз|۵Z^}I8c;-tJ2-enɹsHcii=1әnIr(>Skl6=A6?? ;ܩc 6<>"`0oTtza$} F Nko%A\!1P vZ׼pJ3)dñb`=9UK1S#];e*9@9,m5 ٛo="@1'X+AslzsC;a7o~-Kɬ)=ᑇ'}\=a}<ʣ .kֳF7C}+mŮ2'Lxr'ұ;mns! @5iP|l3Wt\p wW MYbt^m8IYK *$#5$D ԗhe mU7 8Х40Q4̠ ީwr^LhwISw"8sH9 )bvFeE-QK PT8-򫪰gC ofx7O͆W_>!yOBYc%f"IQxBevIuG w(\D`E2l5iZzVsW~, ijT'.9(<c/e+ I[$q RH5ǁ|zl*sUށ<a Wc:*i4랭ئ d[g_r%*zH^Zqv`0,C}bz߮I)Mn_Ǐ<_ǃg›ƗYg~'z { & |ifx=gdӇ1M- f@yZOig`߀)m&ՉUb)tj1 <@ E;-Mu5/8A\](urE]XWycְ&&\\^)u 1%"n1HӶNL.\Styƞ؞-rԾUE\i1 I@GQ 2^%[@z ex띷~33c=GϪGqhh/ v۟M]ܪӶO8 @Gcub؝{۫&xϿJ/Y4wj$2cwlv) MʪD'U, CUDGh*f~09.Jccz2 KX'/eFA+bKcdB\V~mQ+TG;1b伶jQõokL!14&4HTx\ןrxs_g?>~铘[ƶ+l}3gypw{],ـuZZu=)ˤS%u&@$M)\j{jcatlж;2/әxFt`o&'UtiXoac9 HX|W`箧CEw~%z0gOZJ8VWQ}(iD1Y&,4D~E5nQYwnhw$4 %s]RGg+QHlKsEMP+ѕm2$]BӲuZ]U=[^++Gz/:_[ 0|p4~zЍAǐw?%^^AnC wi۝DA]%'*elM]VxPņ݆˚<ؘxeOj ̰ EVeR4+zl]8[P2P=qͫ}F `"9Fd?v &iJm)h!ۄbK >KҴ?}R-BBX5uْ!)&,D$m*fI.I=<3ぃ{?|N_8N:._O?3[3n?괲w\sP { g[ʽ5J  o$\,ڢa$n*UvPvO ]"T%c&vK8UQ-Kr)Œh{-.qf0/\),W`..j2jѯT~vcD2ohaR@ k$nB3T m )}أxa7pajX[ 'w,O} xP'We").T/}3=\#gpnQ2 ”f4-D/?XFԠ'PՉ|EˮYf$pVZq4@p Dlwe)CA4 O)z^dm쑤N`Z>&c}&'sEꞽ=xDEDX67nWK(abpƭ~ ){ ~ >yOkD<ۺW䲡 rx_o>,GrA +@5³<6j6ue,3~|mH|:bh4I];(i&%-&I@/68t. y"KKA NRzJwU*kjZQ֟_(X)"& §$GIeյ8F L`zʤ:A[ɲ% d) ހe,.>umHĠJޓt [;PؓDLbq"(|/nAXzsI}9XRDT::#|}F#=d-R{e1l;nU k1P[VKnppu΅sDA&xM]w!K$BSC K]fEGm{| FbADh򌕃A]06388y0\/VPdT. w堪&Z6*sN~P@^'(J_*^a-q[čs|ĈR% !z3T*A)JrѨ4pSzVK,k!m2TJ2ɲ<+A{do+ܜILmUֶgPN k1Y.^,qcջ@QZ(3> 5SxU&H%/ NR -Lj?߼ xvHt7D`5Y'wILcS\G;}Zi'ҋbb!1 aw -,M%N΁I_-Jŵ!W+T O|%-g+bF;eGj/y$f̗ibUP[+NXاe+uK)tP)-^g~;͢u#!;lJXX*9%־z 26,ۍX=Kz^U5ͫ\$:/|罾M+ɼ GcQn $iMxWb¬۫˶LAIwXzDi~R(l'Su/,RkzIsGlK X3dy}f>)оe6)w]1u[E.l#@l֔6$dsޑ\{cAaj2'f]>Y9e\*נ"K*Si"LT@oadMV(Vzy,''2K;ct=t&7돂&؇B1(W}A+O-VjQrE5Jƕ*'Z׳ھ^!zh c۴e\" iOn:kڠYM_`gzG :ĔXPu*yW *hXǁd!ۭ6+äq*GF% .,b$_IJMWRB?i mD'!62Y)*iP1HocoIrul9a5OmA kCk+UU#)@{ifAJYYF&Vq2ی]A9B=9x7`d[ngC[^( =Mt-mn% l щS7vQ6ba-wԍHjI>ǜ4z ,,۲kYH)ҬXoM٢T.%{o4L ߗ.dY \M VT.8 , l,n+%AnS][1Q;gi[Y=Vi\)kGUƬ{ ݷKXlHtC0eY0s]+y6{-`AqJ;hl~lҀ;J)͋aK!5D*nf e_k@7u"HJƏe[z2f(-SB$Λ(fTjj^\(a3RNiSnLxˬ M=ETԀ^{Mg3HޑnJ(Oi[a "ΆZvAbnXC%uTBy|mc(@0u|ɗ rRaO+L>ഷ3޺)rDyo7i FޖEͦ+II -\,hHF@ ʳq+Ss62Ȧ6GcHOjҤDD7\Jps=,Tf6 wx$I,(E%0fQum4I1T*#KuY#ڍ>$GyT[pJ@_D`t--Q (?0j]JjS1%ڲmqv/NHQcA]vIܶ,!܅eVʫ;LQڗslNޕ]c#Բ8!:f/G-xHEQjM)`84r|c6N*Bwsl*9^,}ySۢ]%K_-Ҷˬ/%2KŬkZ꫁rm!C`=۽ c>>&c>> ]Ҳ`(vy@LAOkM+JڥٮϺT˨ 0EYP Y ed>Y,t- hQŹS]Dt'V,k2QG*!ZjaFTZa'AHߵ4Y"fUĈUєPN %YWY4k$DDpv!RNYTGª)QFʝT\tNbE%aȅ4fH`+*0jy#ע WyquumWYâe5Qn{ZMҮR$Z$-7'gw14KUX0?5o}XoR4H 6 άt U:mm] iGF Ag_~yXвiQF#x3b11-@gr+߷oo?иv?q˿[z vTGf AZߪ'" O=Raq4IRe۩(P1XpS5RԺ l)R>{/w;E_$biGn皮1Q )XZ,UQWlQ")kƛͽo MT\ϥʤQGOC %nGur%pBX.D$B+Ip#[jA>kNtA,m#QU?t]m5;]6Q=a} vA^.By@sp%Ɖ6+a6dZy YUXRCDUUllM=)[3I4P&.G[8Yv[CG$6X(ʚ -Kڥj P>}0Jy[kz](@/aOt$jBvTU-tu6mm\}$ҁܟߜ>*~;bo*3kTQK 6pY?4H"C_6daI*Yt>(SS&HxE%*P*IMpD ]~P֕ާ>j֣ MHү):e/qXd_E:1MGGd+Dj_&riBFRO!6 sDKU OǺ-f[;kOhjշ ~FL5{/\wr_ƌr/\mo._)auZrNQs՟9hA&DLjl.N2a`#iUaT:d?8kfh4*RiRF[/rpQ@X4 Vд?l&lJ[gvV +q_n=;]fQd zz>0:ayCqߺO;U\0@4j,[E>&]wWEvln]c=S"ln/\WiE]k`l bi uiJnS|N}ﴥyAYawT.MVXSwܙ1U4XhIDATb 1,(&D۸˱ڗF 5 R콡q8@;=b$்tTlw٢'c`18`{R9.}- ޢ"z{BW8;\ {7~ug`[~?gϝhT.&RNG3i#YRO%֫Q Rf)KmýSM}nLJQ^$k1dmb,V:qBg7z61cвZ{>[h({ˑd;.lJrI}nA|Yt>[;emb;4!\Û˧9/-/R_5o pƍ^tUf+5Jdu] /('e|5tMQY[vcv|,kK&XƠNxy_KcST27nѪ0G6!dHR rDJEcbRVuxKlīG;[0~nĄ [SrMR0R{K͔J{Q`tgOV#Sz|p2s^ xZ"HZTf1(КVxмzbup".Ό̥[a~{#X֬=MN:jj=IŠ3rRlr56;΀ۇu4>n_qZq$ qGp!>;;vץG1WVk0̺mKFkӏ#n &O8NZ" ;ڕs/y @w࣍ͽw{Μ9r3eECLSٶg `F$ɓ(9[CHBP6ݤ)f"u># QYtriQh9M"_.<8ޓDZRE[ChY&Yv5bPvZ pQ܈oj”F6DAq?ٴd`Xf,W A!0d' LjA='ח.+9t] A7n7(80<߆G?^:r+:x.:+t:]ۈ"2'F4 -BO1F`>w-WJ]XPxƩW(heekw(k JָmYj`z n%9kepxMWO;;g [j̛KĔª#^اD tx<="Byj#u8_~-/R٢XKT/G8u/qMDx-7<0OFJи S-m։kնb.r`!k4"&cSd?QQq;s3dDlq{C0햱/a ?8o/zxa),?ڜo_?[\O] 4;nЛq~n\ D@fcR>w7e\JIS xbsq0p\Ͽkks_ ^ &wEtϱ#'KA=rj>Y8Ɨ?  ,.2'C]#ID|(QT0v#TUXfY*q&Dd1>LD@`DOvkY?%)IN"bT`P8mmdas?c#=c,@ ׭T»0rX ҈rgʞl\fb%yЬMǤ*Z9_t`]rIG&K5/ I#.@>ָ!&\bڸ6oDۈM2H"$m)*bFKTV\JQʾR[40Zea^T~J!-tF'O&_y|c͗tIv};g!x0 \?UsG{.vMQqNGOm>1 ;@q:rߘ2OC+^,q#;YK<ط-,Oxb݃nVe`Yfe`)esX[^.ȯ `qB(dJhge`Yfe`ښ{e>Npi8wʕsWzoo}de`Yfe`ezљ7e Wr[>1 7, 20,_S­|'aV/]fnk&f20, 2+[<~˳oy_7~Yfe`Y_5X3g^zq\kw4 kg.]g˾{(- 20, 20 v*^ߜ9_] n$o=S[qK3hmnYfe`OKp}]U;  ֵ̄k>:t?ݚ[?I%0, 20, l/7.?\μ0%?C\L*~WdiH03, 20, S[?[Z{UgC{O|s^؜2f-"3t>YyYfe`Y p|cksϳO}<ljwH ~HJ 20, 2  }>,l_Xy瞻5 ?OXZ?d[s[9rB 20, e޼W7ҥSkK{챽{ַoo}+lm~~nk~m|pn+x X)YaYfe`3lK~cpv>wN86>->Q_XXos֡|}I~[aٚY20, 20F0mnm,&[痶.==;<<>@ACDFGHIJJK">EECCA=$!4;<>@ACEGHIJKLL"?FEDC7R}y}b.;;<>?ADFGIJKLMM"@GFF=w]2--Mw;;==?BDFHIKLMNN"AIGGrQ210-,g/2;<=@BEGIKLMNNO#BJI9#x75410/LT(: ;=@BEGJLMNO#DLKsB876532@o:;=?BEHKMMOOPP#FNK;;:9765:};:=?BEHKMNOPPQ#HQJ?=<;:867<:>=;96t#1;<:T#!(*'$&8PQRS#Va`^P}CCB@?><;DwnVTbmd4MSS$Yedb^AHDBA?>==?HUbjot{[#TT$]lige`&1IDBA@?@AFLU]fpx OU$cqpnlhf.=wEDCBABDHOW`hrz?>V$hwusqolhpUFEDDEGLRYbjt}V1W$n~}ywtqnM.yEFFGHKOU]emu~a+Y$s~zvspxMHHJLNRX_fnv~^-Z$x}xv8NgJKLNPUZahov~D9[$~z^$JMNPSW]chpv~T\${ j`NPRUZ_dipv}E+_^%WUQTW\`eipwB_:%?"cSW\_cm#%a`a`%FW_'Eb%~i01Rcdc%{xsnjjiigfeed%c%?~xuqnjgda_^\[YXXWWVVJ !"! !"# 1;::997765456789;<=? @A@6;;:998665 6678:;<=>?@A!7<<;::7$!150678:;<>>?AABB!8==<;2Ku{wX+55688:;=>?@ABBC"8>7lyz{n56678:<=?@ABBCC":@??eyuvwxx.05679:<>@ABCCDD";AA4$pqrstt{L'6679:=?@ACCDDE"=DCgnlmnopptb 6679;=?ABCDEEF"?FD fghijlmoq7679;=?ACDDEFF"AICdeffghx8779;=?ACDEF"DLE`abbddz$.78:<>@BCDEFGG"HPM[\\]^_`ait2;;<>@BCEFGGH#KTS02yXYYZ\]_aoz$"((&$&4GGHH#NXWUJxUVWY[]`dl~bPOX`Z1EII#R\[ZU=~VTWY[_choyR#KJ#Va`^]X&0zUUW[_choxɓ!GK#[geda_]-:rTWZ_cipzʹ;9M$`mligdb_ e^W[_djr{οN/N$esrpmjgdH-uX\`emt}W*O$jyxvspmifn]^bgovT+P$p}yvsol7Hn_djqxɴ?5R$u}xuqW$}aflrzʼnLS${~zpvr!cnhnt{@)UT$zvR mpv}?!UVV%zx<#vw~{#%XWXW%yvCQzW&@ZY%}wtb/!0LZ[Z%zvrohdaa``^]\[%Z%>~yupmifb_]ZXVTSRQQPPOOE7./)/.6765434562.-KWVUTTRPOOMLLKLMNOOQQSST1/-QXWVUTRRPONLLKLLNOPQRSTTUU4--RYXWUTP8/B5FKKJKLMNOPQRSTUUV4--SZYWWK.G`gbO0@KJKLMNOPQRSTUVV4--TZYYQ-Yx{[/KLMOPRSTUVVW4--T\ZZ.Uzs7DJKKLNOQRSTUVVW4--V^\M2p#yH;JKKLNOQSTUVVWW5--X__3Vz{{}{{}xS4JLNOQSTUVW4--Zb_+ixyxv\0KJKLNORSUVVWWX5--\e_.mvvutb-LJJLNORSUVVWXX5--_h`-mssrqi3BKKLNPRTUVWXXY5--bli+hpoonm]*FMNOQRTVVWXYY5--epoF:jmljll`2,5:;979FXYZZ5--itsq/GihilnjbSKKNRO:/VZ[5--nywvq1@ggfghjnryK6\\5--r~{xs;8effgimry~1X]6--w{xC>feefgjns{Ѩ>L^6--~~z5Veefhlpu~ҺHB_6--`7feginrxM=a7--3[gilpuzѾK>b7--NFhjnrw~ϩAId7--r2hmptxr0_e7--6VmrvzB=hg8--j-1fsw}tB4hii8--S2gu`19lj8--Z+KdjlhaO4-Tonm<9--}D1.-.3Eapqpoonn9--þ}yxwwtrrqpo$8.-W}yuronligfeedcdY/--+,-,-ic10fPNG  IHDR+sRGB@IDATx,Ivv}k~^{zzU!9,Q$ X7ڐ 2/a`/lK@҂(R3d/{ᄏ[9YUYDfFDFf꾯2#N;8 f`f`f`f`f`f`f`f`f`f`f`f`f`f`f`f`f`f`f`f`f`f`f`fÀGMxloMar 7{ŇaY`f`f`fpOZU|g>\wʡT/փ^{8͞x:{^g -.!xFWG|88of`f`f` 8lyp$,ww(>O3›uh|O06糱wny5um ;L3 03 03 0@k/Fc~#~ |~:>us/Mz 7{a{/= { ZydYf`f`f`10cymF3fml@_]mϼW2꼀Dt6VlS}?l`f`f`&10qwm\7O?_=?Mp߽zw~ uWq~SF 03 03 0̀e)htt"#}ۻޯNgzkl. ?f`f`f`b (w/p'vƁ}'4VSރz /O{+=]gn51Vf`f`fh&8#S|{8W3G0?y@߸5wʛy7*@:cdf`f`f=hA {?z},nxWQN xᅪ4>tf`f`f`1å߂tt~|8?@m&ow>)0}#~̈f`f`f`vړ_pzc@['DF_O!y 03 03 0F3 u~o?}Xq~}oښ#?xCm$y.U2caf`f`fް>>[ﷺ3^#%MvݛWUHf`f`f`e`COhZ%b'~k;>?0~+08TIff`f`f`:xW>KIwgޗ?}3u=${Fom|?C7ÿ!:gM3 03 03 0,_Z>l;GMV?L:'j81f`f`f` ֻofAk=t?ڀDL82 Pw뻇{:[h_W%.3, 03 03 03PK<0}koVMܾ͇aߟy1q߭e1hf`f`f`f 7^ڃO..ȭ&GJ&wwpJpEf`f`f` wZ pkg[Ã󋋧_h5")>~7_}?_>df`f`f0h;^;sS+lO ?SV*?a23 03 03 1@/#`њTuίC9Eܭع83 03 03 03hvvYOp.`9Ku?? ?N'X3 03 03 03<\p{q`;k{E osr:?t7p?h@ 3 03 03 0̀vpƿ]_m!<вo6~4g:k V 03 03 034<]VoA㛲nh{g-V x1y03 03 03 0@:^eˤxomuK. 23 03 03 0shA'oA??.C {{ru=CePrYf`f`f`f`?/~>+ @ wޅiĻ@u13 03 03 0̀.|Hz㳓/?)V?_k|Jq(0. 03 03 03 0ރꋳ"tJ*ΞbEMkB:WZ8iw=2g ɖP(%PJϫ6+"&I%VNcEN`݉jjdS[Ԅ| Bf(4|nhh?u @/zBՖ'I2MKì-֚t:l at3/Z,٘mχ_ "^ix{uuׯ /Ec"BvpOvwNM=J3ZI 3ZPOB7xRQ^My q؊%#)i"4Q&ZTn]-zfQyCtՠJ68(`ptU[ ]~BN+(]fF.cT.aC5eʮ)[$ION\4S7 5o n&OnQ 5||/޹οws]:ӯa'AA6[0n/gȥ`l3!ͱp*^2 hP%T 3=XJ#I]LoPXaݫR+)dc hMZWX1zpY5NQ7Sdv\'5 r+[('PҲ:Z(|Q'A|\%KZt10ecM&Cs52H)Q+,trNy+{4n*!UX2efOlƺa8Q T*DnF*F0ŕ WAb \6gc7!w/`/G go'Wo 9{-w,lဿ[-`k0N1L5rTS,!3UhREjʩZ.4 AM2Qv(fKB:yӭ汓$kBeJ 4vKE,LAH.B_vҹ+]V=@JHCdDmHBZ<Թ ^ %05'\0EJsծM-&_r "Rr;3_*Nҕl /F#\4vou&`Οk?к>|?aw7wA:.v۰n݃;agk^@tn5&W! 6]=Z憫Z&9f." &_2B׎WY+kڨ DI (X Rceaؖ6b&XOÈPSS$FRdeU\K gqIdKeiѡ#EL$#[u8qD+ ql N;xD4tb6}oٓ+Us 8<|o8f~SCnp.otZ̬5EtgLt2g @ȯ2 _wۏ;ő-JW E'I1׭^ B~RCԗSeNᤕQXYEly+ TfH99**W$!Ѓ"&H]E'\*Or/Z:\G2M˲~&tihd*AaBr& q!9G]>9:ϧ9Ya vz:["[*8>;'b3^O}~)JWK.6ob }6nkOu=gvF@>@FT|m d8 YUJ&HVxxZfH+ʌ\Q,@.eIhS@^J߲`yJ@B)+Q;iRL^BsU U)U</[Ձ\`kOjihʎdb#OMp'g!j-|Իp Wwq)Л[%w=./?\X ;w=~ /~ rҮQLw?9viT^+vȦ]ٱbrȣ8xؗ\t9 ,j :OKhBm2+`ň;hsѥ -{2KA[,OLgJeʯXOYw>I&MnU:cBVF&$SBP֭ ^# 0qAɭJ2\t1.At3nh.{4û^\|!`t:|خ(tfxCå;m|_xk0O%7LETDQ4N܉fk o3rߖt-KPT jYy5-#q׈FFbI) ;W\A$<2edmW8yz4Vtx~gpyuDdvyWZ] K_/ ~~=ſ[ATMfF0n1cE7_[FΩ4x,5u%KA[,Ocȗ*pz%9KXƃsőӢRq]JwNTuѣo%CYFvNlgΖpR٘CNq%~#k'IkpW{{=;{z23/|7Y\VRV;ݿw9 tKeCT?^E X"[-[[:s]`q] 020͚Wƕjc;{n]PPRR_q7xS3SyWRd*Ϣ?-pC*VRMR6pӧIoYB捶g. uvA^9/xZ>Iׅfzf|?^sf{;VaHQ^Qi'4wбB#!dC$6?^O-6zୗ 9\^pmk\gEspi~iP •1@oFkUY K*,y~\! p$W$q w2sp_ {x-ߟÇow?9WO;8n|Jkr>B/ b܌ _>!,9u#)ɺYl]*ص&EfxmV;2”F:Kcdq E')C9g%@h^/OwUN}K ^,'ٜߨ96p1`F*ɖTN^,vQ6i]FxON!5 i JEU50wȉem x! gNYw"`UFwq֮T-4P4lB7ナb2u[K3;3VfT`2`ꩴ!$B Ħ(8IeuT< ;>*pfW}*o.zw r3m H~~ŝ EGTq4F~/đY&2r։"zZ5h_6s~}fÒK6\ qzDuuuo%culz٪Sh칿 GcR>v2һ`g{83/n0$paY<呵ZG5ǓܮsJ[ }]`"&,A?:kq]zJ6Lڰ!LV`6O@|Z {fz;K\jouok+O iib^3g0Nl"["lFJiNGQ+#9"Ec0R$&vyo],-5(h7EqǴލԸ{B w4Jf=] }\n[t"jRxv>UD]ђ zK.!Bddy[(Γ؊{%p]Kd7'a5ǁr#N)&I7.s.VfT!Z=z‰7=mG?[W =ć^;܃Io@q8Rh*1;}vKTnP0K&BAUҊa29i:6%}\EʨHER&þ"E:gMo(z3(B}tO:ɇa8vq6՝z}~F.XqˣYZfуVʆ d¶f@dr^T敗mq}=%iNu&Ddbh W.AvEf5 㔆17̽u(lLo7C٢@>>u`4pk*d@`~^WXq"Heȯf7n$26WBZbnFQWv=6y`qMRHMBZO1e?]/C|~? "ƦweiJoM,40wp`U߹0^#&9uT+vU0Ƅ WodVy@y$1W;U5i .5Yo)yL掶TsJ95  A%*h 9@*ݝPf[$-sѣGNΞ0mZ,8dbb73!fiW*%W}٘H"[J?[,6N٣4jogZ@bRnjRͥf&b0Ľ5f8Ҙ7m3d|Ffil@O |otgם lvѣwT>N=8U24w-nMJkZĝ8㜞ԣŎlEfnɖxLR57]эϕ4+p]RbLB}Aak^w4^i:@kF޾+sup)E݃^ Ub&442΄&yʀ ȊUC4u\53p]Rlb b `bGůhQ)S rz#@Iݬ&X\ӌw'7dNs>xN,'" G5Ƴҧ5*sbŠǬ#ntlJIze&3zV5kk^DEBE(bAHdx|%cF`z?r1\^gO3`N@UP\Ql{<4ʓ8Ofʦ\UFHM]kر-oR.ճkX\-p/xj&PV_dsB M}@LR+J4O" *ŕ13 Nۼ1 `6qO'si݁n 4ie[ڿj\Qb}uT &50,"tXʹ08,ϣ\ I>6=G[_YhŞU{eԻ|>oG`)^Sݮ`Eld|z<6ق-w@%0ɀP8KDT}le6mU@1:*ƞ 7Bo k#4Q)UݸKrʐ2\ɮp\(%3c͚"+<#ŢĪ[ L.J«#=gSNS9V`<>@2W`r࿃ygnLl9MѦ2 Ljk̇K}y:HCu L5'gPiL΂7 :vmŊ7QZYϢbhɅe|<8ma'+pdN`FwDA4{m@m]g@SD [QrڀPxܴ+$u^Y$5Ѧ7o09by1quGoAˁt Ù- ^gduZ,<0H|hVMXUݬHΛŅ Dw GƸ畯Gҡ#5H^Lj^>@kԾeC" /l9mr`Jcwuiރwa W?mW(7 ?rï! Ȃx]_Ok@lpd 4q@vEcY)xg\F(k uN/vzư.|pL5{M3p>3P= ư>=D [``7cU_X y&jƪ _7 dU,[Ƣ \9( ktAoK&ߖiSddʎmeGHy?2u M'8O2 5&jY^Rժ+#vUYFʊpS"D ]\#RZ+D%3rSOS*8I3RcjxJDmEz|i, ]=lX(%,½E7T inՆOWcQ߹)ӄ5LZ.n`#QSWD+=G[Z4 ˫[oy}ӔOZ.VJ$CV.ΓjәCUv=DmyB !s8x^KH2,_3VԞw/m3 * BhE4eU.#NĐ(NҏMPī X)[LS,JWT楌k5紑+ԧ6Ć ǖ̪hD3gV e'2Vj+_Tz%sjJH?MEEjSy9cK ,;,Yuњol,fgɛZRO2l&^XZdX6#-p %QMRS)U!D#ٍ<)[YYlΠ$1 *'^!TT'q+i(^4.ȀZ޸t@5zme2Dl["DfU9XeK-SpMͮr˕F$Q$Wt;9mկIyM͉\T!b$2bW]9Rz؋ҊOT; il-94}T2c!DI3PK)PF<4'ZsY-]k(;s)m)f{z {O[RY&W1E+nh۵VDeƬvs|է3~_׵U[KHJd#7rc\xX 4=5:'tJpsZU%wtzcݠNsћBnm&q~u)Ψכ|VP!Ncb*P씫r) CC[,)MǓǣLȀjBo,- Z/XӇ`,V* "@Lr6c`&ebUlpҐܠ?BC&(d?"\ 1k)@X1.e*SvL'٠vsu'j1W5'|he-@\*pR]sڕ7zƽ^!MG#9*moN LW,H* ɘűQ@w!~;=P_+(F\E 1aݰPu̼-ԑw f@tzR11IbwC$.aN) Kuª\~։ .um&5r5t%j%Ӊ_ҷ]XA\Ԇ"c\ӅKz+5t)JV⠔PJ^Ts ??1dy͛y.B% P˝FK!C  Y).W)r%\Z50jRe#:2v>9(M(KL)oENQEY.4:R|ҌśTf`cE']D֧ Wsc<:ke˯k,R OL#(+饝HN%WDN].'RYqjBrF9SH&?@r1T\+Y4ضB*1bCTSyCiN?t2?goΖit0,56RAn-Wvp2ע|P.z'A 4du`4 ܌`|u# ǻ,G'&zvu zq!x&S[t}U]\D\rٱ_'Xc@<&~ BtƝN]RŴ+\‘KX MJ vy2ub'r޸rc=|Nۉ\ZDUpRZ?ݯ0% euT ÈLchyC^[ءxtqr vnVؔF[b]̀-{U tYҼVsAi3"OT1lR6RI@IDATUz}k -;#7J鎇JΔ '1*'(4q ?L&0gg40)q? _H'q>.@+ ژFAMA;$Q&KchNJV+4}GMu9?9lf۲hj2l":\|ENmpY†6?CN=ivotG<Lf:5jBGI 0 Dx%}t2p>~gpV["܈ tqo%0IV h[ Z8qh-z_=>-[X8Ua|}ŧV`VWrM! 0WkxLPgE(dh($|oxl_1 E 5"B,)#DYzDܚ5ޢiuWeS|ln8fWAK::`k ! Z 6 Dh9@#&3xeVrW}ݹz>FP>LVa0I,ϒʔOe' ipm\r\ %_+%S5 0\<𚠣 K 8&:t^iT 26)& xp} !)+c +m6W' Q?~{{0)>&O\]/k"қMhǰՋ:3R] -Q@i<(C5*͍;QհIYn` qV\(LPȕ#.X)E}]|ٛ5SV-eT*൒2Bΰ 6py\<=  _bPa.klwFjړ:?]*8,ѓ%KXa_M7jܬ:e]z& Ea5jRm*=JXRPͨK-,۬zz6n+]U H_.3J5 +~FpϛOg`sRS p7 &n9t<J L7 l.Ч7]d.( ob1XKpu%fj^968a2KM>t*j@\nEU6_jL-Ӭ摢.g:Gcw \5 '4Iut;6 赻7 ԺW(Ρ`n{5[ܯv\)GB/(,G+u`Qjyو96Ңjq\S[QIj;B\cf2FHlĝ\JmnipW,XD%N>? qwNOa6;ĊUod@WkSU)e> \1eS)rYԜh/SrJq&URRQ!&+4rS gRHm-Jr$q$R|iOjlhw)Xユ3\969Ag;&xf:K|}F ۽.lV h+qi5I^dDӚv,iԾ+i@3eF騇R:J6BIRX@(TA~"4 P69G4XDcJ)R"F23~3><%UfW& #=>gOnLpS80 `Lq"c8C p-8 EZ: o[U]e$kAr|&mHu_Da,ǩĔOVv*|/ώF:67HMD6HMHw\>\ԙ9nyf.!9dRWg`E3Ec.`wysVSMߜ_ejΠno8iGc$Fˑ\q2IJa2KQ/Kc( -QL3Hu'cZA@2"?%O6cVGh,%9" s nN qI3 _>>`_Oou B$PK\^Ϡm`wJAܪM $#adtd1w"l %1dKGQ_#8RfBˏ0*-RMjNP+*y(_L@[c X3b'q?El9>vt OߍeM& pӾz{p'}h*>"/81S >ч=N8TgHsبۚYduĀѾ:ɀx3[SR.q\trS11dJ͍*?}X@DS$h5.)\憗䓋`ttwl MykX%2 zp*BsR`QnD9'9 4ZBJcZb|HxYN-@t=/mff_|S~zH$%[h2Д^- GZZG?8 G)M PR(DviXEK'Wp~4x&?̀s ,&&#|'po =-\#5[:I} _%6w<$vR̕אnF=I" ?:ְkVJ 't[@JL9ߡ94zӿb$N*%[FHBI!*!d{YntIo4_RWLQ.?y7ÍnhCQ>g40@T_C\0a]T%V1 UgBu[.)[k ET.tL:;q& BRDn䷲vet@Z+.*xZz)ecj)]1 ~& zhzu`>=F$XNn>yYuml&*w=8[C<1F54 _\@ $QbVjҥ bd JTPXR^j|%y`ehc$*'1e]q`'0ugor=ѰCFKiFkRBo 1e]N+\A9LUi(]mt*9'rI뚂5WKV͚Ԅ_+Ʉ@# Z7 bAhCp'Jk7Vf>(&$&9\T$W׽Fk(x3chƇ>{vZ?@|jtgmQ-O7\'5xՙpg1$,dupjґX m0بߐ 9i@Cj7D+,4 75tDT)L):%AS$Z-(9nO%OO`wyu8Y 3|gpn-$ۇ^m f~H#}A'YM"uldΒs }zܱFh"l?2vXk]$}6ɨ,5.\/"M/OX[ 4 pBo Kbˏx檵- m@uUyRujcbMeL1%)5ߴ8X˦jO~YuS2s~ 4` v A&Sa婘&W(_ih{8EQ޺ҸiQ$LXGg'pq~ Ѩu. 4 >pz=ށ=_:PU'T ߅>U` Pt^C- 8Frx$8_|ZNɀTҡf4@+vLf?5*T 4׍ާN嗠TPReQV&&_WRUI23z>m~*̱L$'/fgM. 5RJd5mDW0G`Snf3@^8770](01 ^[)ݺ[(1..MD:tOI1 :QBaMnV7/C1Y|bCsxo.Y̧y&^t9bibqdz徫Z Z 2GH"}qq}g80iS6 ̀B`:Ob5蛈.>&Zޢm,gLQފ{Uf4B,/-V¥ʷۢf (N(B:T $ӛ/a|s #ꏔNת3V2 V4324xAj?`mn)8g#HlrR $I^@LI0I,fMHr&|c|P[װSe-: Liy|}S*V]Pp2G_%7` 1݃~(M oK]Q 6y# LL+g_7)#u6 p]7'D[…s[Xo9Yk$Ud_y%suʂF57b T#Zpƅw3QCi(b[UFUTt@/"L..a GL\)6i A6l+60_AwБ }9E15m1‖Lq2r>pn0sQ9yKOh(~8{pܽ]{4ou!uBg 鱐1'<8=@z\D0|K6 q6.EMt5bSVSQSLg@ &kZ-)B(RB6V9Fs,s.vt'渊]w@`Iu^2&騹: /9\^Rqf{uQG?z^qx7o ]߁N ~3~$!'vф7فx+;~cgOqH|e9>Z07F>gW#g;Cg* lkJTH\xIْ|Gql)hʵ-c-ެh'dʅ SݸasW\1=!Et:%$)>p1S_@HtڰՃ!.Nwp6N"n!OOnp \q2#c|| W %>Npog65.hEpů 4ER/;?άh7YlUl<ɕnv,0?R'&j<5MQ≸xa9 =K~$-J+E%~ec::O\GYDSzr3':ѧNop.oJp¤"|z _]c|8B 6zJls~w0RCq5\-ٖO3S /%̰Gk*l1EY0']L,&jVQͺxoh#F^V˕NBU@ӊ):/`M{m|^;p?zxw[&t1[}&8ؿO>?=9n48GƸ ߤ==hps !ԉZ:/7kA%ާhUvuO;m0 +@(UCW{9_C7-Vn7۰ |:b?-ӤfO9 !>._\>>>>=Ggd- ؔNz\<}}|kG$|Y t4&zͳ` ].s 1ϋ8mׄ˿6$@D斨۰s`MUL1j@j,b[NPD[#իMPŘI4=2W3akS!gx|5×FO߹ or!B{4 B:.B?z/=7<Wc} ||~+ Qx4qut w`HLD7\kgP#װ=#g zmd-K 90':G;uDOѫ =ZfTf8Yn Q DLqW䘗\Ž,gj`XJ\!Tذ3UƸwhxno{;oޅ;}U|vjd1GߩOs-cxom^97(<*p&T{3w~;ĩY OwUU,54/i Y|ftB8W pL%J%IfEr$dEqV/I% ƁI,8=~(م7^/={N{`+HN6^l;mܝ!|.|S 8>ZlX9\h%`kw9 [nMq7SO&=ZǓ.YmJ<(6d6r>\Y%(d>נ1hkV]VP:U\UQZɿ9v3UQ~Ty/d}'#}N//`k~~ãvn.7(+'G⪀FRgJg p`Տk!):Fա RKޖѣ2ӭqIe-ic$2CstX1i (gVd1@ͫnLEbZb8֓-NN/Onflۆ!<o^Í^{i]W1in10axoa'>7< k& c;jjVݹq%D9(~s6s=RJD}UD/N&HrYA_/ҡ9@Z )k@#jѱ6Q˝9M@9aUhk]X[џUNhXMCry3<\OnNNqB9vKag 6{;W;mQv!5 #_?SIZ_-P1zw5VnA|8ÜcEX+M㯠-E/%@ tU.Xl6φy90Tqqiiɺiƻ<'vVCmlƜMp~sOΞqRi07޸ozp@ƁG1Ѽ$rh"`vkX.| _|9`<^v]ShW9p8 gv``t܊vdB499j&fFb$"@_zdY l@5e;G!4/YAz2VsdmҹB6fpM{: 8m՟v68h"wmxm˯?:1>pW NG} at~'^` [mE].2e "4*"V& e;Fʩ\ .-JrH ͽ:Y/0.*M\RSo0ȏ#lg"]`%xz .Na2MRd:=\'xa?46@Ӄ<р~r5`Ԁ8q}q_mm0[-mE\VsckbhXѝPMUżf'S'&r%-hJVXG36dxt l8ߛ;`16.fV\ Ey ٵ%LS8 >Im|{J8{{ {ټf2u0J[?{|Ic5f}?3\suv>ʑ{_jUTm3 )q,x L}iuF]";5b|0h14Ԕgń't?'.`6mkNwvk߂nàKv ՁM |)]$Y}GΓ_y =|+@hVJmZtۢ.`n\[kXRaJâÀzdKVL^mVQ[G:ܨ\8&6#&t0P^ _Op|sgGO?=5.;790E="-xó`1G|NAVH@b6L79Wۅb }Z2T/; EǒM[sJȖ0/@)8"$hc!bq 9Dbt3^>g'5mpwwa?5a] #/G߂ } i 7 )>pt~  Έ 3`oȪH3 _:iOJRxR֭,)J-\q9kS4vu 0Ϧ,ұf8s +?`ݭ~C a" ɀC}4N8:9MQkqFj]l{/!>P 4׃nR]jH΢#O 7OEjgqmDe?%(p.qIX:KDɄ$a'!ɛ賝3K!@5x5 4 i'uT tt}Ύ|ĜՃqHX~ltpsua=\ƣzn3%U߂k~| ? nNǠн{:]wk% uEuRN,uoo1/[eˎ:XM o2uodIq#Y9K1(,p7gwj-$=VU08M"w_P/z ^?xgw;j.߹5Z?Oy>4m H-|O?Oq2p:}Bk{ڽ ^jѱGuf"8j.k1WG.30*vF8B̒8iF@"[ շXmh]*<V4v2- MpqGԯ KEq w0?=Zmk5U!=뿿h{_}^W m|9!R5+s-u_Lou(y3\&orox=|?xg׎ T5\vUC?3@L4>Pl#ѪW `m\#q2.eX6 j&Wő> b]}CFb2SG@77gu4O;ϯ_7ߥ  ۍ-{06k6|Lho<rK_śh5̠]o&ZdG4A3O}BKeO?atA4 U)N < ]g6ht ئT'`^Fet3).e5<,,UA` JDx +6c \tͅbL/`"Dei" Z'8ӳ#^jHׁ~~>>lMƘ@.><>v1 䉛c6K@\j>iM]DR`8Y0-o $HɀN7 4jb`T[/9<}v7~>kJ`n>}DLZ4G $l˦)Jof\F\W͚TDVU$FT[ g+$ h LG*3I*K'v<|_Gs߸w=js4=٧;8?߇g8:^.fDRߴ Gd0 '8!p5[8M_@ha.mx'O`+hM>+N=}=|b喯ݜy@.R&Ң%,i쬼r)Cls \t_mU,zeѢŹN;5 (- /j1~R¿b%фsSϦM|L|\ۤ'~m48FǧGpv6ϰaUZ80k9C1.:g>|<L,eV96R\?= y>mʹb#41q&nU'x9~p[}gdrL /'Ø\Q?)z|J3+ᣭ~^zxg]Z>'x ´ܟ_|C-ZJB\"3pŢxstG xD`pus8p5{Q@.A~wSO-NU.Lӣs^w ZQVvsX>~zf5XZ;QV,?*w%~S"Ii,QX2V M9P6tܘV:ҧݮ&wVU;p xe 9^w6Wui}hPY>Gx|Kzn /QJhAic͜LNBL1K!\`p@6 /#$h`XeOo( [t/PC,G{z@ՒtNhփD) t׽)+I?ls~WF.蹄C/R O讞Y21;O/!ry> 5,_|~!l \?1.q@|BeSf{%,N|*P ax5<)1 pUjĒ IW_gpy5|v8 <Mޜ__ 6o75TMPғ?߭Re L>Yw;[륗)zA-?0tnE3ELs ,{i7]*A}kLrz4CYFDFb"=is00H p+x|ƗWB wg|g_ \Z^p%xO}YL}?<6#"\sD R.ǧ8rx-8\:\p^C}wqs'f}]tzyݭ=$@}>";Էsi gѪɅ/pB YNV惝ҎV2R,0Pe= tzz,d1o[yXGq)#Q P0MBS)^^pO$ ཯??.n:VgOnG-8I9*mj@o_7\GPygVeU>f{gdAdL6ҚVѨLWUyr$ N,"d ͖Ӌ. 8L iFD{=|cIy{a/ibb )Lc1d0mK/_{4uɎicVvSӏH$dFoy$3邋/rr..lIm2O}SVv\Cf}~樗4# z,K k]fAkP.Z=h$x/aF @0+. q=Xso` ήGl;;l',"ly6]j=I˄%GDḠY|9!L?DXd<4ŢdC_Q3O`9&sI8VZ֬F0$ԡMu4lcrd(G "YPF%8%W`ᔳ'~ `HKl/u--hfQ#ΩfFφ_fb^g'){=u<7bS. ;Y]Q\:_@.^~c\j3jO|DI bOF 38a[o0xmx{{lgF  lxw_6L^8Ǐ?φδW'8d_,myo$խ$,of'Gq/CvWm#x-`5To[:Wg?!k7|m7Y9KazXۧ%U5eJMɭ/U^0W4k5^.تsE߳IqVK6d+2Hwl؞&xO6nvNc}_w, ϫk;+؀ʼ0~b l!;AmRhrEpHp3O9JEwrwg}'}^C'f[ P;~8Y&_iϲIE@Yin 4ܕ8AOs8+9V,sK6V4QC& ">:N(75>)9,\L)1Y3v+d?:3go޽ ӛam])-< ,V,R?Q7yI[nϲ cܯS2pe/Ï᳁`.Cwa/~8|q =r ן͂ :SdT]46K-XrV>[UP,y{7ڀKVB~5Z(0mk,LH cuHh#6j:j)O뀼>hh5tuUk9KNGaDn?Ϡ)tq*i*8vk?Z`~T+ {rCuI$*eЍ|k>lm?Â#X Շw᳁]~2wl>!./᜾87K/gYq5z7yW&I]Ґ@_#Ai,dw-.nQ^!gKgMϴZ=҂Hp=5u# jL{SlJ%jaA@r'ԯ`%<_`n"R>@IDAT`n{mXV ۟=g;H?1X@-aOYSb``}CQ0ه|Bs`W~V_I,a:cx ^x7[sdfŀ. |)r-\zWY-|$ Aj(;ODZG k5Ƣ)]V5}a/WG|1[) @ ެاU??:W1ZLnfݤuO cxsbn/*ND!JJ*[ pP ւNB&:ӆ' ]h#M%6/lLPJ_*Az6Q?K6춭O<3x;F-6Fz[n95d?m/PAYx7C.x{~2~v}(SMGxgGfO5O k_x'H&'WXX =imR/go-0PW]e翑LE"̸3B?+m^+s?Cl'>xmib@+hVN,sPB^/,"i#<>oz\(xrۿ}1ÅaF0w?O^! [([D:FՀWբjKqq;b0 6 ]ʋl&n#gg @߷-?{|߼|vv8egcK[U´x-B7KEHv 7ʠZTp bk$,+_@9;a$൒6t^yM%|pg*ӿ>p[LSKͣ”*̌*>%+HdDq#ќ$\v3ȪQpq2eh~Fa Ŧ35<׵rQP1d%4_ ;_N\+zaC .Rޗr}܅Uwgߔ~jyxV%z+DŶ{whR!6J<X`^}M2%/u#v>v 0 ?[v=UNj&9~ JS)9G8vd8BX.4|}xW %mG"98 reF\",Y%[Y >;3 )(ܺc:“Tj%W-I@(|#}KKu1|j6ZLϦg?x'?Ϸq}b1dn*^f$չ2U4(YebVU%㐐Dž"QsD#B@0@8ɣcggl~txm|$6Gf3p{2'Mhe|-(Bg> +rnmuUv?OOYTl_0\Lϰo Lͦꑎmbӱ[+P͊H8  ?`MI~S X/F5qjƈ6l8-Ğ-0l A2%P.rnv܌=+$GC+eu<<"`2 %h\q)ްr<78\K Bu~>KlE7oc u5,קsH4+0nh h S:4' hױu$DM8_ S6nx}Ç}mwt®G0N<:Vvg D&jmFP#H+F<8HǓNIDRq໥'L hRojoP:p<͆CS)@vWF*Ʈ#/{? Sx?[Oذ'L'Ӿt)VuGwwG{‹A#LUh0@e*h%MvoONmm.pc{6u-s6O-^*ŭ [l;-*hi֤" I8a <($S@6c ݟTd›f:}yFJd0 #|@d8+{sXgڣMS7?~ ?rΩmKK DԧBӘEmM*y|,˧;&lVuE<V0/Ozk(^f-w٣#vdR#/T}Ͼ ۖT7'<'ˇ%I:?0ۏbAnDNM~>Sݻ<xeC6 675ɋYiy+|2bn}wAS[ `d>O𴕯PHg]dО Q}{#["Fgr '=2lmQ珏{ 4oUG"ؕ2:z~'loq8\W+X/1E!T H3VLE|gF'fpA1=)M3a|;TlAڪ)CdpH ֳcvy=g +b2 '>;1͘~#Kof*F^RR(QCsV@8 ljYF)e=3Ji,j!*RXIɰVPf*$z@?+Fi~CWb6A=dWDدvX;A? v>?pѿ5/~&߿ڔ?bo?+:.w t= :Y-`.iODo<1I49_S9L%IZ[P,[EFOe-Pk@1TjyHn$CX axu)>gmnbT'?وFDj&}yb :K}]Ht-`ޛ#n2T|g꟣\[[;r-&xx+VNᦌ2ـg䷸}B6j.p@_5E Wys l2N(^ͺ[oԜs-k8K(@;F^w\]+ |^8?^'1tߴ![^! M7-|rY/b`[O/~ft%vaOfW# ?ŗ˳\Ԧ?L4*PHNpn69zٞnRlq/nb~̴h#"}ZFsa@ [nm3w v4WxN/q@O xnj1ݟ'.xP$#غ*gᗅU^zLJ鈜Mz|\+*9ףCy_E}_ѽAyhAXl7Kvq}Ů8fu.ÕĻ ?OJ$Io'?bu"_+>j?\ٓ}vt˶ᵞ6ac\t/!:O<dYɘ_d(>|Ҟ!zr?.c/.G7 "hp:cW 7 Pp~1{4t 4,cѿ?_JA,q`W(lK4J<*X`NNdNZwqaϞ &lNگl Cr-g.7/ ol[G0;R1f.͑dSG"5ǃ8(u`NƜEKCYjc(RBٓ e;x5Go5qݬⷘtU{w>i(2UQ ejWa.;85Ɩ)d^w γ@2/A~7e"tM_ y_?_֖,m?/iv{RdF:}FSbN8'WK_6“>arlWӜiL'y#lMumљ JD|F'ۣ8QQ7Yx9.qcr|4zNdzb ٮnH]f6 oB펃h@ڱBH~@c/K#(/D( "̈́}$Iq>sD309=' {}4ஃWwr"2pBG↝9|7l oöl':iD)<U n)Fn%aʮ+ˁ7oSybi߶=! ^xŒv4vT\pawF.`.|6_=[Ito r\O|5䫸~Bw$%p#d s*E1F2v(+Bd$~i"BL箆kHT͕'6W R4)-l/׳фp6|]rڐ%*7ar4zY$ ?a \JE1s E|p)Z('ᢅ?Iᐈ4GIW^ Wmrvm ~):-`&rp?mO&K+8A:&Mz'c]r.x H?-)7"ȰA@"Lױnjv'2H5H{GVf iUkZ7JX>FWb^JkZ aT`{|S>B |7Y8j_E ;$T+2R% 7 3 aI p1s^ChϾj`c`b݉_-^b?eww`nFg1,:*ki]Ghv,%B F QwJ^0ev,p* cjP'WVԹc/.`Yv4>Ms>銏H&~x=v;?Nٕ1fTO57R,$ "zѠVsa[*#;`Q'D%`<0Jg`FE5K>7R~g܃嵖OqJMӞED'Z5V7F9>X@P-!TK@RvPQCYTTPySx>?{~hP꬇<~W# >7wPB3M2QNyqVFԅ =ڻY&ek%p,tסЮq- ϡWjjYNG6]iE3ֺmu4^CڊOW^*Pa(UH Ԅ?@^*A TId/V[tmS^jIw_IҦȒ$T̿w_S^sgtwz=eyKDeI1ϡCgR诓tt) 5HP[:ew_ש S/So`͏'Ri_Vlï47[AP$cXGuksE Wx vYGC,e* *j֪ˣ~(TSfW*FO4btt-X+Sk3P{W-YI)(8ªWGdWcw޻Ϟ?=w*$؏%ߏjfX ݄`v "IY.(- SŲZpO[ׇ?1jc?Y1_-v`!̴g |]3m:$l>o#ImZp V@Vb !Z9WLEjG$ "vG3_zB.ZEMXܛ>33 hd ̬J %*½v6fXjU~1;hA-L?F{+:VwVBm"غkxe-*YA.ER|Oiq+X` 3T.xp0dO?Q*pW[LkMV#`ssiIBOSc9Y 1):kXdjiLR- )000F=xڮTu/l"묄!A MѾVLi9eqa>nߟ}!7Tcl! by*)- ٣8m*Up ; }  ҊhAmEVrA]MWˋ㽖xZTO?I3m[Q H?j=fW_ДAJ7ɶV#k5`0Rվ<Tr٣ ˞t"`˝ _mFȰ/.=T *._tn`>m ݝy=8݅p :5{Lun |$ h([uG{KZ0)#[6eD8m.䉋cΑĽ!{e@%[3kE jm26(#-.R+A=S)[Rs"G?) I³&(Ɣ9v%'o"CtyMҦ7]ѢBC~nPDrЀZ,xPF؍W/ٲSmÏ7a%<Xw޿=e2V8ҷT7=䘣nA[Tض}W>fH1S"'(ΪJNu^㡩m}6ض4Lnn&Y5[q6d 15%v;ˉs(ПbMp;k%^29ERTrRlSk7u{ VNh/Q[|t'Yg|]jSOlzƹ FD.6OUĎW)y$K91k픏sV&5uo4j2X.H&U6LBU A Knl'G{l.la^8Sj1A: zfd4z)ԥylGm6-un&7D:Gl$bGD*yx[+yj"ʛ*O{p0H$JziKbfٿ lG_I (ǰo`!̐v^ l.rE[hvR[R! U/:D骔oV5pΤ|¯<}x  _}HQCGpD.Ί6=u8)BBזٱT\]+ Z (0v=%[ˁVBإPG(Tt'b/aN"Y)Y`$ll1c^D:)w߃UO]?bæҝ<p(X㶵jlJ 4&h!` '|"۸$7 NT9/%U!f5ySYM2JTW'cYfLE#Xs3c+rܚ6_Hj;1~j|ojYؤ BLƊPg.T  u:yN1.5CB[-x6dbMor:bpQ1w؇oaT&0 ?g&|m9|9ڌwNܑd6L˼Z[9&诊vD> -NBS9m ȼCۆerp. ktz<]fOES8+K (w AVmgfڌο]> b'Tش#3UmƆٚ"MU3<0ʠ_yσu ^E4Sn>({xi|ΧZG`̲^!Ik^]zZ]keHBŲxWS(/Ѣ=nFfu=䵈@ww*oUpګH;X 6o$m$J{YE+R$jƮcygixwgbjm>KxlMc6|8ZPflNu Ee:TT{_8(E Z S^ס1߱Aӷ/<} b]mVep5J$c]v['း9.M׆ּ4d~.;>lb_?NvO-8݃mÐ22"Dw2!",8EN&aP6f3A](OmP4܋S 9\T"}{EmΡO`@{*7л9 l4̪4Q.-5l ;i ='r>:4D0>VLU[jurХwUkLȚu{җ ak לG'J%Vu&}fj>a]>kS> ?;<:*/;Kx߼A=!z^R_@<_E|9k4 n[%R 9sX`J7SB΀aWF`ZkX+oU[əڮJ .eLZ/"Zbf$A{sRWM7_.NcaZ@I(DYYФٌW2}lG]Vhy.P`!ӻGvNxe~0BXNC`Oi4"2R5J9xE|Pfr8bj6T/E,!FjR!JThTRDRa({ͥEf@=Fuq9a).6 ٶ$`]2-`D-Xtt;N }/Q5dQ Kʔ ږDQ7yf"xe[ơySƏP录^ m2XMg_W/x.7nUQ7nK䀟x},Ùʙr1F0pSW{m?WRV#>wEOGEe˰$j m:s\ oyӓ5&!d/+x&3h⾘N-S+D$Ee Qtkgoxj PN;t>Yl?dܫ9(JiɭLYrL,G=RBi6WDΊ32"4"KuH3칧P%zvZ>w?x Zʻ|FI怃0P٤ì byc%XjJo` X>g-=Xaor%O5?2$B銃\G"+lxʛD-0VPtzEl G01E?(ˋ)7TQhଚ wp- iP \0albcl2WR,ד׮3[pr_2MK[E5Mgi:T.r_Gq;ЊTgzN+DF4KNCWoom'6QT%֠. j֭-oVb2f+dUɪTbG;٣cƳ1,M! b~vN-v4`[|ݐmXGd¦s.>,I(ժ[$e-rSϻcm"boNe(*gEr0z@(O 9#pK_ j~Ꟗ-LjעWdM:2ˢO%]{S>NEyx:G蠝S;8ٟ3HcjGXw ˪H|.87a,DȾA= V9RC +p6᱄Kuf+:Cސ Ʈ> D[,˶v27"6HwS>}t|\ r=K{bqȩ_zЗ{6W]{CIqԶ0l/{Zhnxzi##QQM-| ܖ |oo:|B&S'vu}=(1>#b6,*yzըJTFd$KԼ踣=űteWtk€@95M=_rPjۘ_x'{`mc1 5ddF 'ެxVF.*zEV廽d=Ge!sգ4Y)D\ aWsQZ3 T XT=rI+ċZO䧿ra"k[I m !$K]nVsK>ɖvig\O3ϟѾ䘹i!=ϟR 2TE9j&1 RzGr`}h1V#˔Y_C@I1/C< S7k3@ᱭ&9D$~{Eћv#WsX~Ɩ󠷮ectӇz7|r:;F7'b;H&HZJIlكAnS0"$EsrTic,nԙT&2/IϦLŏFu`.dǹ`LS<+]rk@\@v4l0o ƴ&og8)ԙ3XU9V4N-Qңv02BHuDSϖ)sӳIgA:7%Evz;-:dkJV$B[sNr5MsiM!ýbWחzC6#UqDt1M"Z&O@l^-]w`!!P6\du,cgY@IDATR=#rVwZ9uv_' ?{PD=G ql9bb]2!j\qGr+B-O[{)6}P YPS)!4|o X";LM'N[[w{λ/Yw@ﲈS]Q[$A$ڞk"J&R+HU]$+k wtnK-Iו7죣F/oKೢ,a`3͎JFl)N3xP (^*ަTpJ2~Զչc{Igf BK;'JضMC*#E ^l>a+Cv|8> Iħ=vKbUʠa+sRQ>0Kߢe1n7[?knJt[ϙlt:XPbaw骚nj8x ikz_mɰ!2Z@-F}k}m@}n 7FGN7cv(F^"@.l6uyvٿ36ӏ2IQPj^P{:@d{>:^(ud~%o>r7ϏhWtˎDf+fUH3'GOhk,3eU[0_BUﷀK/#.usN=:W&Cm!u<9\S^H}?VW`XV.,auMʔp&7s6~'= ؓ Z8p񯮎r}ϏNFcgvWMQL{Q$ek-bW1;xf,a*}:ߧfj[=;0u_N\Zf)K4qoNĉM=8n9 'q]ڽ5@ZE2NBHcq"8"@H=ac5~yyѫ?>E~ŢijçWa͛`TUSA~aV]"O{g#6IGUqLP^nRf#jr6DB6"/|<Fz<B}4%7WW= =Q{0_m+cm0L =\0 ԕ|@U=oS0e owtKxI 0.mDiF e{ٗK`5!cm,IgiW rŘ?V+<9sM ~O˰\{%zF@ Z_ ¯ M׵QHu.|+CN.X*ܳ( "ؓkLU3ά5u0bE$N]@Շ%fl1Psٓp_ 5%+6zTj.u!F/h_~jCy.)\`H T@{BA!ФP̢Zb DtB E0)mh<ǰG ]PL4Q%ͨ%獗=OE{yxrS? I̊aZM̀"t zI,a*Uq7=_QVnw/vTA¿E_ kfo[=zY2] \)FpA6qhTz@W1u\pJJԵ+@G EBVՁJMU<͡:y2ШIȈQ5ٙ ڗX&ͭ$RxH q7BQPاiE΃ Z@'r9eɼW=8ŹNN _a4 ֬ ;m1&hB.Clh!Z@eJ282EVЁ [rO)rlP DXp tKF6 rSMT<OatJJW00*z|b4[;mG'.4p8y7 4px(W8A%#BTە3EuGFڮ2Do-tګRS>:2/1VX"E`Ϡ*Nն\.EAPItڊ/'C9Fv.7ɶA*$k$E4؏#09 C5(դj\T%o+"0*dJ!i4e 8 ^I9z%CprDQ?8؟A;ߵm4 }:nzva%mc!JeŨZv*R{2jFFDM2VgmfV/-wLY}*\("د9OիW aC~.Ei fB+()]z} u+VDΘz8`sn2Z\:&??fhl>ד=u#;-vp ;ZK;XRX-}K5|f S" s<^ή k1E[g*m(3+(F ,4 T:=Z\Nt+S3]Uz]Dk@˺L8KH1%wMGՂ]FlOdOq}>&auS0ru8ȨJ5q\RpwI0Oput94޻_')[Ԇ6zrbe64JbY*C'8Rfe@*byEHROb NaIAl;Y7[".@؊ .l^Zϣ.22͐tiW9(eL)sb[r:-2ArvSÁ6`YJGW\ڳKw5%|P:}PH S'3Y N:F!pe/ )=.A‘wFO}ӻ#7Uǥ&NobE 4R]*sqQD k5TxԫွvtŶbG @% #d/(P^."R˜Bqi#1>t u;X`2B׍G@q|CNz&+L>*) IEEB ^ә̑39E~mx<&;9.(e4xhoFIņP3qHETÖ׎cҐd-s ݇s,(BWSV|pz$t ]xCQnJa_a\/4 ^w(C 7.,,f1P . Ԃ B R3(S MSWT 9.I,""YOT-XtEDiW@++4$XVN9L$ȈYZjkuJ}x&!|vdowMfKZqV+@$ yc¸*578u%LIH ۨ17F )^;kG86Z/'ZH]OZ)ﰦyS+*O1kH9*IrN5(AZjI*#j8Ja)'I\oxϧM&Yoݎa첡0#3bbO_>+Ę'H%T;H>(O%TSLܔOM6LeH[CpMh5@ʚ`ꊬmRr%r%K>ɀU\B Yj ]ܽi`j@Ч=g -&S*{tYj [77_̷ :r' @P߂(. $N{YF F|M~ > @K٢nDPafkشgm^zo^%_V5Tb`7 XSL [U-¶:~,:'`5հ֫)Z5 G!`A$_V!qltM8UG7 GOtAv6,: :Yxo4FDÁ%.&H4^@` RِƋS֜ar֪)!2wIYqe ݌;Smx<:a[:/n^-/AF"MHotj0u˳ pᩎiA-gLVmah]rmܲRj/ToWb(۞*?x/6jZ3 ($eA<3z@Bdb N+4do)^ VƤDz.55'|7ar~雎V&.'G7NZ6 =9MGRdD-DCŊ"A!f(zM el<*q52̠*j@K%VV BH@!pȯ YV=t Fsj0,[sWkP;=5"qAAbtKEgAXF:ǵ:H8` `D1X$hOyCe^85@rHdْ\OXnNF!5/ft T0, <ϻ{v>yE%<$3p;FYa1_h*:HX.d1!8 bd99+dMkI%Lx,L>`dfWG 0_SsXefEmNi)v+6pl[^'q8Hm0o +LӠd e" \k_;2{ ?X~UPy)ι$u"  # V]WfŮoy=zpxt`޼},dul(/ y{i:YG:)̖j[ps'ǍфTE :\bk"tŞ Q6Bh`Uy0Q[[_(557o7|6POzZ19t qf SN@btx_U/_jGB? P@@6\?n'eC"^5+T|ʝ@(SnJ1%UUEAm. Z~~l9kIUAc UCvP"0jCއC6ܱ^ ++0TG@6B Ndn-"Iqޱ;n;h[ xR}%܁q4فYEl0=!.tL_ (2INU&IR<.h>eg}42{ }ӞC3g"YGk-;qn3^Zg#K=rCUVoB^7&vO-xGxR] $,Cwe|Qw~a%4Q!}s䃓=vڤ/maG 깦+sEfv,:S3ЂKoB#tN!"%29VjV!ѝG`즢.DWwF.Sw `L_.:%cZ,J!d036^7Oq0ڸjˮhCkJR bj25Z6YQB)rAEtZ5hE Rf6 e<¥:^RPR&"Fg%w}ǟmzeIq=v_&r ~n w["HNtRtҹp.5=djz$&?vΛz|>QK[2ฅ4;TeYkuE+ᆺ/5G|a锍. 8Jج)jb~zr<C_զU$P2LUHV ZFQ"&J `!-9i%9t'oO7E;JìGյ&kHEB\v,R.NloWr_;lQڤ0ؼu^w'rR*JA!3b J[飻F$rn'ƈ.$]d_0ZA 4-,[2UE"yPPȮeGU*LY% |vWN_^ܨ@TiNS%fPNJx<[kxU H/]U*:dRqh @̣X8227b~HqeO\Kyu]*/~06a0@S&>[WH”oVj=E c871FiL '`?6[} Vb[f7bbR7\osQ$r&IoVbdܖW? 8L4՗l1mHF*!NA(늿r9gD3(}BN| z k)7lCv?tr.u?!e`mq8*P\럅׃A6cO{+ۋs`zz݈^X_=FMMȚC&y!^W,W}t|mf a}yl0fA? \ K'N[>hWĚBP߈'*X) *p)2#XU"PQ-r*r|nZݣnEח-)'ZH~c1]dܗ!|[=d8 冫Oy,q:xbzN`v @6q"ItHX#V,X`ǘ>ס+ң涷pXB1q%yhi`Nv|0dLJ_w uEl&5TDCc_^*~Z͸}V#\=jof=*@0'u{V'%6cs$o<!d}M9_nC{i:i3DoDpG- #ö?G죧C6Hgsxـ뻸 8:V* /V%&9[sF.*HOۛ!'e_rco:&T5 l?XJP~ tƖ~~ vHmR@VӾ l')>VM{ۤ71Q(Txy?39p+w"nmźˬK|uVdzcN TUi<zr?$ӱ׍]ʞZHa6L$hp G`5\6ÍYC eX7 vp6@Y=W# oH L ŝBe#CGlh bMڟ YFzYMQ*G!=UK?e]]tYX ؗ?ִ.Bxm%V!T <@ѩy1qZ7%e$Iʽ>!L.Cevi0BJÑIb*bi_:)twRxzw-a!@VE;rnmUG2/4nB%HwE\,BP$A dI>ܥArN:, w٨|q[GvTrSw*v!3wO?MW+IV˴8>{|v7)|7r^ou R+(YػPBxvJҳ<OOUq)'Ep4dT|0\|#=O>K8ʹ`YOv 8Dx gK:b +l5 j= d\";|L^;+zuLV }IZ%p8re**iKnz! !IY# zJ:Jcby5[qȃּuA'Fjr@Q.:$+1~4s6H67sBҁ^ O %(~.{Y3/a$ =Ebr #a!؆ Au!~#k\ Y]]9q|:0&LH3#zWTwJ#ٚLf22d3wW:'|/ DP*dS1qr|Km1И O{l, +vj¤V?s۷ox`/u7xs.HǞF8w|<_%5 7 N*'#$7 e`PČ}4qQ~QfF-Bb0ED,\%X2DCZJ8!BkM?ƒ &-}=tz&<]vy|D6ƵکEb痒=Z>:cǏ/|p _ 'y. uO|)b]ٹ*=)S Å58!lO^xј%كEnm:Dx #(Lh vbaG ңvt##)ś/~\^>DT}g=nT*8Q[-e^iO2f5Pc|]y܍֭Ւ4IbUPdi ^8-/DZ&1cQ_nKƽri'lp}}1c?᛺O~'a 0) wK{ItcҤN=4il5kw4q`>li1 (w:b 1 `8t@ +xvx(5\yڭM;qet>|eny83b&xܓY]-,n]Oij02/8:S%l֔O0l[ąERf=UQ'ĺ_ !}vD#xc{Kv}y~yL&Q۸B:1.bűw$sqؼƼtQ@0Z=Ɵ) ` v^qWG5yid}h5>U= xHt6';9/GQ,ԱX )sVI2+`$mNDٳD99Ynf7,ڸhܴN 33>+.]яMsLwۋf}>>nI+y7x'e3\NJ#:W-H]{$@PSk풿 ~ |!OYWƨ ~UIj`m<{8Ƿ7iBctNk /.{xER.;ה@܅#<WQĐV,[Y\k0~O?#1-Xѱ**/):&,>)կU)kxo~vl}>efcH̹/ Ƨp)O2| gQrb&&%%ݔ0k-"Lu1bfB^~T,9JB89.dYTN>Gy@O_rI6R<+m"hǦcBi[x]N&2tu~O՞M!/x|塄p[-kܵM{&K8/0gꬌIe9t‡(㐘OikG:3$gi%x5Ȗ!.lސD :vFY` >TDu'c9iވ,2$@ZNU_y9f{CLT/K`Ú[HOh2@߅7Ї"z$^Ih<Şg]o~}VpcsuN8@Jw߷xys|b##5bxQW^Й;#TeiOlqmA/E9PwtO.Y8NJ_3%DcZ6u3˾p=13rCc/ 'B}. W56\5{Bt]j V- 5R)x}%?(Tܽ'xehE^ɣ78)ĩ-~5˛b]wN^BK/FϵӋK0g>ǜ,/G?6St{/ Rl^N\{'8y22e2뾇ºzK-"8&)GTHNA%9D{a,;vKM`]_"JnwfC___6?|hVƥ̗/c^lKHBvKPoy(D/"BH59"rHB"xZsXm@ɹq3ß׊M E1;x^ p pW{sٍzfeީj0vDX2RJv *Nr) wgV07 ˱*Z17vaa|lq<CZtҭ Ƹs"r; n62'U뿔tDjxع8aWGUv@"H*b0jn -AP캵x&Dz#-"hށZbUefC#OA1 -Ȭ8p&]Vɗg'zxl}Mmm]׎\s_aYc3OXP EnŨU46U*\NIo{^2y](DZϏ# )]\iy3 փIArz΀TIlqw7?W忾1}uC$ɂ$c餙}=F:kζ6bLk[':ŏ5*43kLt0C&5bl9W>M}:) `i74*JzS/bN(¤k`zȾT2 !N )Pe Ol$KfHfM?&QMHKM턤_ |"sO)3?5[U 7r{s[3T @IDAT~%C s"OYk(l;; %V kCxxU{H:N9t2c%[NzoK)Ғ 0|ƚyмטH)E9­ToOntwKPĉ3ᾉ>ISb0P$ZHO ӷb U^fpG?{7lqi( sh!u^zw%,пo.?^~_&OOx ev PLX/ Ud6U?0dAlcY0㢡?Zϧ%eb:p#*Ry&zqC`^i|k89&{h\fvc#<^üA<جEd ԓlZ1r`, %/`T Cjc>,U:n$n/4 mYq '^]o/ wx'*~!-,·R'<2on$m Ҵ C(cd/Jg  _pR^u'MsbӒNMBtbTq/XKLzct4KD')3'*d*;d @9YL$ʀ?( UzsHkFx} e[pͼjt׌j7|}ǷDRKPsvI/ 2{C5&//{ =\PUn3dӫ-Aq?.ۍd81OU{ Wlmxoժ<D-M).]-f:#D ցulS YtDu5y+ s8$ eq}Sd4P"LN)[?ʸke~zܠZyn}y60 )}˻%Dj8dQ+@[UtM4JA%ajC98z f(N rPe1`ܦ"Yh<\C2y0;@MF6K1yΩbGXMj K*g-eWM6ń"rO˷,SzH6(3i%ugZkc# <}/,߽l>op=@ºM fTJ1z}:S% i [K-Dn\D }ƶg}-I[O)h4Vq!Bm.7"Zh#\+vϸչAJ?~7;$ Mk`?pKRL~7բN>X*^ agbsb :܂-<5˖~0훫ͺ6ȯ{O-l"ìn|*Yn(\?M) a9<87όKg':2 x7]]U'BeO։r QK6ڂWIE{K~=xLh҈(鋦z_Gb "hU0۹5^LWL]۩#v]鶙0vGdj\*#i2l}rN=o?y }lv<xV|m6O 7p|ú\{QkaDH|l>Zlt)ч #K'go#gteqX [ptf˺ >S~Ӭ8YtV\_6_~7@p^hU7Ri lan /Drrcj+Us'Bc,"Epw^<+7)Į }Rº *!rǗGG0Pg//w\T5Pg1~ >#p|*vP:c~p^>LZ7 g%(y (^Z{i_SeŵfÍ6߈ts ]+s 鉬1Y6\|KpyEZIKPUejSwΌ,e5W%'cM ;op0?}w^uO4pFHD8ͤš CΣ(")h)&x4#8WO@".yƇ^:r<C\|!tp+fΚ0`! x@~[oQ%.zBv-WcߎT2'ږ=G+2Z G峬R1>U[ 5AdUkx<*S>uW3q܎ܚ&?҈,Uu Pn4nֲ`*-H]1͠<PG_u"&"؎zkc3 ?A-n=As=۳9B4 lku&=t"1h{!E[:H6Q756߶oQ]d Λ*bư o?\7w*hU_^IK7\UUu^_J $JYw[A:2be ƜGXY{6d-٣OW2ΨLaedӒD&\B J8J5qQk,(JZ*8< }5ZſcsuU/ࡇ3>'\S0*[9k}.bpQ>ETpjq|J;UKP-tZp*%'HîV*BU-\ܗt 2ea' (?^l9dkrER1aMWaMHŸ=+ k=.4C-8)u3 l`b;N-'Kb3~m[ 2x-&~m*ӝBs 㬍 ȨH {6U&5韾o'xU +eR@?T6FW<hpbk&}&VCok5Ql+n,PC; .‰ ڃ"iQ;yA9#Rfؕ23>eVP]&W@>LB.=E-4~rqkE=69Ux9'}YntZ1/6# :ab*|zG3H]mN^u̎3}~{x Ŕ[/a?Coj+|N o[\RlI%V٧jGl+,nBݩCC 9cq$t2<'˃R4N/Wpjmv/aƴJѺ83T r׎[>"e%40B>i^q3;B]i;q3/-k_|=磙ZI_{RJ 3Epr-/-3Wm ?VA_n]\Dp'_ocARB$ηXY @OP9rh~\]0.-WjI2[|v } c@p & `\-QI ,acW˝Ьdʥ*8bM"(dF#%%F%/vq'xQ!fiӴi~4Hr;D;DJ ,fDW$FDáylT;`n۞y I@wxZr)T H\珆bL` ql@ϙW He^5 pC_^h*>ΛOj6JΉ b;8<؆]&lI5~\ 1,ǽ¡D0v\Wd{fb6 4( a1 L`zϓ9e k&%^Ψ< ˄'Ҡt~Q>:q'BJD#Q=p`^_g6>ZQbWmSH0RgTJ$C5Ag+ua>bl=H Sc*X6)GxWS oʼl;܂ &bhAn- %KU 9R8?jI#n{1bc"Uɍ*|ns"Bzlgq䜬p񮓖&졘CB$!3J{>4'&^?VzXjsXEIXn=D_~]W>R %Zrk@r8TcǛ$S:]%#q!!2^/"Q>zmKSJab'!R#uoo=pF-e6,^pRq“?E"9һF~;u NM`Ћk|{3-*̂4 |GF2fSG;BZ%+q>n`^;x2p,5O[S51G5CdJ3\= 4)Jm |\Ã~x tOީ's\Տiۊ-eI.ďQgZ ݐ[w#WL!`ѐ wVSq,ؿB$Y& 8d-#C!!yq30W@ͽO127s_"p!v \1iߚѩ=IӓYs{ 9&O r{!y_[;=ז&BZupe_X w!3g: `2'hungkBmb{{!~,}(7  {*GD2-8kމ;)&5j@ @OcVqdQKd֛S2r<7<-CˇU]1=n;2":򨛒}a~h[..}&Pwr*W}Tw4AҀK*KLO*'dw4bs }f@H*z/te}iԾ_xzj޼_2yhsW7$2ŒkLݕI%7Epk0ĶݯjE,Z|5GW8?AX?6 Ѳ_wtHWEAv;x53띄D/P"Hm*LJ5z5l)eC5~8c_ח()}.R9RJjލ]%7&zRbvR#O^<<4ojυw}餓v.Pim) Sm㊌.[.ƭGrF;_+$@\ c^P@FE2Gh[N\o|_xuq6u/Ě.LO4B'Bc.Iš/]C0rdRrFQ,Q[1t,,EPsv5CgЧQMLeL-9m}J_lτO\y/>xy#_s:I?.}6"xD]ɘ7"Dcw9ߊsiz*1>v[>tb3<eCrW1Sac7e2ؚq-pp`g||^!Ģ]V}_r)v6Ȗr)c2dMhLW:1O|;^de9-В(tbPnux^2n/o>4KWбm,-w.Uޘ\zKQ "@NR0f@L ե%U+RjzRgͥz뀤1^AI9g8[K 9q 5qO,k*w)\m.UNה5NSNW+/b5`k0\qlXč .ᛖ[ qMp~g͛u]OlC)W,P/%š\ 8%s2(!NF Hc6xj63D{ `@(!1IMWP/fQM8.'PgD7^c'<.6P~ RN0ZcIP%ۥ%֣:3u'잚"3PkxGR+YYlOF2pD>|I ;fAA(LG*s]Wpi9|l-K쉜AjiO*FAsu:Ն$9ISP4L'PYNc&XD2ā<7Qk(xATp=;6᫅WZD_1I(xm} 78 z}5|/a:gbHǧ.7Q6_95'bfFT8@uMG~ijY%o)|X.Tӷ"nAFo.3j} !  /6-Sm} #P.' uGc/O̯9iYu]8&q]?ϼU{ ZXLJ* ºob:UD=R ^$@/Gǀ y|,HD-紐Sc,;љR`z_H,p|m:yzo:H<9THb6#sZ)/I߷}x}|8z}Soarvź" ӹ˜9ɈeiVigFEi/Kb8c׮ՓhTeÓq @=k6R͆'ysOa/JpBasж{f |Oo۷W>p V=; !|8}AaR}.ik. rюemAhSw*C$ENF,cm{l;aSc`dK_gGp}>;"^ j78IM)Y5 :Ǝ CCf̈J]ny+7ff`2$pM.\%uR= #K⟤OqCN <j*KPqpKBHf?}pcϻ< b>}xd ?h]$]0pcV!9-lP-QQ^H"wܞNx2Rk?R"".kB]We>p!{? 1j/2BETh X,CI ' 3  Rf{"NHw^\79=9 [sl ٌB,GOS3=Iv@]n A* 5B ك(qúB?o_l jb̏PAS@i/'&#Yjx(\]mC(pzARuM*;C?I^a+`Wor(ppHZ^B1xg;5L22 :]w捋4KbLń5!2ߒ59T /O$J3X7+>gdv8dBr*X d +dN NLh9pmU\ŀJQaܹAU!Q2~!PHAvͲT*4 Y~vnW&\E8(;ojo.ryri "+^!rߜӋ(c2!XF_:e@EO>uyp*gч| 5m#!Ga[H͖Q[u|K<5Ox= F[j-8_wpcӉ{YZ{TX%r yƋwl0aK&>fbuɏ(}8[ǩy.Y(Wn06qr7;R(2 5hH3l#"E@"RXwwQtl:n8YaIe8'cIu4|yPgk7fkM9vbQBk-yGTfv+!˫uᆴ`V+Hy׋x:aE^wب1b+PF =R!)H@06/@"Wt`Yw}ćn"RT M9#Q!!>Y" X"f5VTmsW'@pvi[}nx3U'i ഔ@XRzP+lp;شF[7 խo9 *[F$N +Ur LH=0洯A@$x]8 `6:X2j iDH4io "IV̳r n;.5^5~ƒ[ @W1kDz0~0oH#[,` ,_&sl_P#Ru]:>`GeZfpB&FxN $وݶsӛȒFnQ0;7DCڮh~vS[|ٵvFn; wl80:1Y#ԅp\H*Weߋ7߂t-5_>7[b?CF_O82XRGO_pGYdf:"&q3j4~GK \PQ^GS6Jp 'm}gvlGOdYLE, c32H+_!؁!h^zơD!1I`]*[H}wm.^@E˙~L?Q~lޯrf|YʫG!Lxu Cu su*E"@ юiyn6JK(PM8_"ͅLk _Jul1TANG^6OE}OZ߃;軄±2rİuǴTz܆Lb*.K|<:Lj1j\>]+4!bgq(8^.5<ւ}'YEtk- p1Kk R 2I2D3b\B"^/GMꥉ~qIФv>h'*cdl1?2tc@x*8wK]Y`Mk- P? 7 oy>/ABȿRU=6AؼF7fŠՙ,q{9/k?.dQ"cDGNL0+Sx4Sjx`]<*wi.#yqF>q^ ūիuNla (xG$SW U$}QdcC4m[]L{_Bt:7V*fQfPGȢ@UſXXxُ Pvݪyz6 ;i+/`ݧs23Ѝ^4s GAU̡ ܳj͐&`|Ĵc?2~|/*]2¢(2,Ŭx4!@jvxTngtmkǍ ≣ϰRW4sPOMz\. "5~hwpQ/}yp2kubiJK|[ƶp`HXu8..y|q P46ʠGXNRvVYG_}cjY@=+ng2F!v\ie*Ob"dhͶjZ+[`U@k"=~_`ETUx>yxh.`GshDSY YP):7$&"R8HN5~*$s(8oVIx x59CgtkH{l:4ꢜ)1ǵNJUiOH!@.GƎoL{cS@Fk Z?1i_XֿLgKwniT7ZLo,F! 2wa4A-_:C޺/cF[h ^+5 ! Ӛ ZlٽI1/FN^ȨJEď/l=*1"1W{_JjoU_a%qI Rho_#I&;T0) `?xK@1nouWBO/Ҙ=cjwFeJ7f>0Z:+}e_%jB(Kp^$opaM5 ~ 5Έ[Brdjڷ{WTMsw&N4 vJèQ nVQ.AX%cӣ 7^疜2Qb* Mæ"Zؾ$Gg \8:-aYֲģ z wI΍2?POnuo$ST-@qپ:LK%TXK¦fw.{G'uir^'{%q p\ aњ>oa@j,( ~xל-kz5} &p84}8}) !A{1 eU=[tnKs2吴HIv/ó+VN1(UE:+}$*~>jGM€C XmBgTt:xSc5Qii{n{}x^,͓A2s:yVbb׊I~+ήoO?(u%f}Z-`^%v[kp$$>lwa؈.2F.)BFE]Nked v|~Wڬq|j;iM׶4]tu[O6hJ>PFTzu1țxg@w 8\PUT83 Ɂ&dkvTuSѣ=0N-9g%_d_,Ua2<2̧iqu2ʼn ic.jmtyVIe!2fވ uEˇR?S/U$_Ə\9GFi6jhpVV\MuUadH,` WUZ-d=guFJ_; e5&ܓ23M}͋fu<Ond H`JU۔2'm[Ǟ1?8 $e 'Qmh6Ч!l ڬzu/DNWPUaǀ\Y] K?GNYpB0yz(` *K2%ciYj `2!NR8]N o$&;h>`4XVl?5n\_ڇ_(rLu헳#<+7ް%C~ѐf@RABWӶ+I)ܒED(q%XxN|O|3!m˥v-n^Lȓ/Ke=Gj'~1"G{m%){`1%h7! U*"<Ia TQڹYnlӟY3gwtBTF)>  aE|7Z2lxyF_DH>h;{QU bﻳdo_1lWku}9F@;S3M=@w $)T25^5'nYAYpDU!NY, UPîx\[pFۼ{C;I^+\ݠez4 Y$D6ktZ2_BI .=vIØl8kSS`I]D6Ҕ>hBx3q>=Ou ˪!}?v^+΀?W/?m..u_3MgMxRĝMLAIemOJ&}.2j3N0AN-{Xhs+v}bPCO]H؄ ZDr\s6{i&f-k'H' m1V6*mP/^Y}D5OT GD0SW B="b=*3C}7.o:4.f;[_?~U?yn0Oxvlԣ7#;FFbܨi 9Q҄(>6Lh5鶨) R|1Jvxr5K:mInLo Vl]l)z$LK]Z_XQlYKd Ӡu=t-c ]4Q%(".?34uh&ڪ%M|I}?Rjo9s+niXU\ۦ^ם+j{@TE뱞GOn QCkq"JKTL)wv*FEg&pZln٥OUz|yfϰ<mWr-zqhqZvYwy8D(-*z͇"[q-8ݱ[Z~AG27<2lhHq=0t4"kJ&u$/QVIig&vgJ2.NƃoVB2%WvNlζS7Y@Q?@oPUR%3}rjqFVܸ \$ $o6)&xmW˧>; bÛ7_ SKscdN,J~{7c2"ȽSl]VbK4)t Q&DX^'l }qzy<}'bx=7ۛhݢb۟xfY4I6_q/bp&Q8xJN&&k5X`bVtUⱸ83"`}'` Da(t6I4[rm64{4E=n}U̗u9}PےOa6%M\I |LvJm5V1%`z Y\g!gt\W̗<ˊ_c*CXK4CV"NJx?Lr%U)uZ+~)O #5p*p_(N]AE#6+U>քGkԋόx;@l~JY7xZ7/aYV 4V܅\Qp= ,K܁ pӬ^6ad*328d.E4KzҘe4KxKqzu̇}pv.+"sQLZ[XM6 8X/7l|@Aj\h$o1f8qg823be$ؔj=x(X/aI`9AK7 ɞre*@úY jp{|ϚLJYwI^boz-N<r2ε=/ۘC iE_C,|Д~:A\4)3IxnO[yv`-Ɏ^ cs$q.M3}`T׎Źɐ)N!E1H<%<R(eD[ qǀqnJ^4nQ۫7ݶTDZνNl▉^A\DD*f;SUeU~r2#28 ؜Rc(=:uPФ#D66~l^G'M%}$%eO:Q|f75Edk l[.fV7TlHO}} PW1hUݏCR֊NqEl 6%~ˮia5oiUaYW%Ie/ؓXF1yQˀQqDRP~A_Oƴ:Y0'Wk(co!{Nxr~VeeQȷ*c}6o^C[i4\w9.%xt7 [!([sxpYr'2pLRtXqUy|FyR_+,c!Ilx<#B}DMaoz9pnCkmq9)t̒TØ 3Xk2X1sĂQqCL 8*N&FXi=! U'|~imMvfk1喺L&R2*eHeumnLټ3-TA?kf* RRꈈ/jWui9s"xh= g5n'wJ"dV+=> CZ9V`CwØrU0kA='q#>`y*%$!\DV!Z~I֭2UcrҚ%vcMj W(X;Ϛ߷+Gpf<í\;{8Iy7vt_ҀGKXp=˔|HrOu)bHـ@yLK#Xjmv_m9uŗsu}{l\_^gsh 7n(*+S>36}DZ>nàa2&JEIxĒ\"WqDCrw]'}qMP{8d+ ·fxL~U~)槏Pe= biy*#zuXNXQ j<J uy2؋E׌4j(R{ 9-Dkej (R.RY@Bk^z8թ 6M A;۷XIJd.oxKH]x ~vzoogTk,dH$te _J7>IPhmiPf4[zXWb&?^ޫUhZ<ʤҮ#9Zؕr/w1L6?oΛW |͇7 pk^.o }z>f? q4h3Ge%֑v]i.3+ihat_HͲeѪv|u5 g /q5 @d.HOL\kr7 ~m <@ւ9\PI \iR dEu2\cʈGe\4-MP ~.o45.pdnt!8"}ErkjsmlNv.yS:Kε`i3>mV˿u3~m6N8(zWvwȐPB-Jت9 \|rpr  . XkS.ѩK.ZQmn6g Y3 O 86H֫ ݯ0ֻRZ$>פuk~2b ӧ2zx~0YB]CiEB"˨\\ҕm,qAWbeϲmõVNA YqlSSkt[.N#5SxoɩJ'ȴ_k0s'{MXK5ft6%?ǧ zO#S ɜN1PZIyɓew ڃװm(s&T j-lLѨOMt13$swD10.d(} n˒vF}* ZZpJ/ r:ϟ`oZ øsv`vDik1i;:長z:),γzx"A9^jI ݓLbۘuy)m4gS%B߫BA{Ϛ<¯^ Ⓙ}ۇk.*3>y{k(%ѢKJ Ysd3C^~Ǯ2 /Gf`7Aya ([eq dACq~ Xz`h~r|ta>jRl&LCNBXbW6t1T#H_ PiZ#ɹD }>\ێ&+j!EZG%+- Óu ZD@36Ϳ=C!F٥ۛu?Ujz:4G ){%UrMQʥ9ަ7V= ‘pHAR!vNڡ榊F{Xjy,եfO9nyG ciHn׹L;Mųt\W?=Wo8_+ [t?؜eo>vAzʝ A u/ Mr{2@M"}uo,Z l#V\ݨ䤵9Wk[z11ͱ&N==NsX7 ̯vn&ʺ|)PO`ԪGs6:F7 T/R`%D*%F$؀{$m36{/>2 G<U(&_.rC8t8;Jя–Kʐ\`(w% " l@AtNO5=8e1Q ņpf䠪# 40r4& HNH)$.S}:r1~6 9 &*+{Eu~y9}uy/)OIkնLm\җ',T״ɀAU"wdC|Mm|pyVH$b|(.$w'kL<m9-TXybynft=U| H.TY1AV:,wZmvZ@$: ؜xPw|`,*/i*:TJM+d ʹ.dT7uх)g;$cYTq\Oy>rOoR[z 4#b XM*Z?^w}w|K~ Mg"fYDk~"w2ңكgV餟.qYKӖܷ\E?b㉧7|2m4Kk+2T^; %*4%?^eL{ҽE6l Z> VhlR#b }oэr/:wX;͇?(v@ ,Ts:GjAF 9 ~o=Cݼ' Z?RjD#vމ+z巯C%^֖O?Z}/[ ^} 72j̚FqX67Hmuɿ$C.ZV1>`LPVp1p>Qyu_+ BR|&j_yХ+hܫXO6z n^.ۂ&Z]5YIZ 9x+*$c M=%A$KBe0VCҎfC(iGRHPۿDUH-˖ i/ge˓+'$\fC1$}6L%F?ٜfo>}yl6kUőy~jnmplǐ%Vjd:ꌓ+^N'`ʗP5SYy9tkYz+9A}$A"&bGּyQ.^e@B6PyldͶQ6C ːE"mv7.zuc-K|Q[mϚϛz-H/Ϸ;b?Zb YyW>•\|s\NX$㰚H~fbm (e3U4&kRDjMn*O' U6X[.eNJm*XeE'\w/ueX=_/ohm,ß~ͽp<lj*VqUye=Z ?ko5<`zy'(8hu4>Ӂ|'67%Ǎt :FEP/a<"&la>Oi9J5w<=n.H# $N;QnII@ٸI h: @5A9La8ĺ>&߀ki:rSE#v=)teWW2mi-\aq,O6sO;1 Ʋ=U!!D&2&fp |rdK/a@<(T)_[L WC*KKiM>`I.–90 a'0 7)@/>X۵oK#u n#)̐`Z kn7>j5h)+,jHro͚ښk+iB kA 4^0'{6Ej%- Oc\?a7NʐaHMaWAE@{{ |+OKCh2\$zTZM57ʠD@>cx<*闀^!.d-fl< R8/S X$Af2ı}lGj!,R\wŏ?},Z:wwKq7Ӈ9 dlN#=H](DQ}u`f8̲ (W͡ұϙ95 Ӭ_܎ⒺʘJJT z,UJuT&ԡ fFĬ%'%rBl}u9dBxq67 |Cw{JE0G!;?ґ:^!6glN'R VV0@Ѐ@A ;jk&`źce= 8K]PIEr*.+궹Y{w8:]_m5..퍘n-Z)$ QZ+0%?PrZg2*+!|QPγ`41n*K -M_ kUk+KÁ,XbJJ:<5@@:,:&mDvho^$狥XY8,Dԕ2r~A6OVnM3 (o iT@Q9+tqL}Yl/B l_vUp`~B1nkJxUDi^G*wn{< bm$cATt5JSGӜrBlR,koIq椌vl/CS9M )49xG}%)Ps蜋(HȠHb _O, KB%Caw=}uiŽ#1 ṣZrTt8NDdOAbORq_9/TYiUJLXިPXFVq#ӈ`?9K$Omӏ*`QhA ]|=VG/1{r?ݿ-l+=+cH0 k`fQ;ž$@B^7Х)ە*PḼWR#X _T +T2me ӯ^xQ.|[G;ёgCj΁)L'4=4 8Q) ,/m0`-T" 0aәcxD+F?ԣffpj#zPIi2> t1rd*@ >uj7WT'Od3Ya+>ZL`;htK!x%JGQdtfᩐqTFH#? C!qdrr W5E*u[0}ȕ,OiC3ÞMh9q5`dN .mܘ-p%` DqG՚ИV!)l= FL;к{{w׺%oCޮوПKA[(/($-+]w!-B|P*IAe% %NG$ i+(7@l|։C# \2@9n f-^M.ų/~D |(= ;x(p@9@a\!:'ӏN~k@R󰫫kRL1? if~6b2΋ɪJϰcs2E^i! Q.k|2V9ORn[3WVүaVbJS'꾅UHu"x@QR"$WP-GX?|{-oZx{/v:[>{-Rl+i Y ӨhM;rT@C[! '2vX/`cű\LQ5Q^D IViRfe(Aȵ52ŗr2RI`d[o!lсmP??Xp}~ʏLaK1=쾳ik(?@^fhҏ8 (VHAB9!BI{CkRل|%;(ffhVmj.Z ֬LhNc&VP_j~z%@!v W`4S}?8HRMfI]+_ JRyq%ng,do!a-+ xь'~z(!g$:*jZG O>}$z^'ZCH.y xjd7j~ϐFiTYmi=^-^mlF/BV T(w#p׃={N7tB] Lp욏8 9ԽpΙ|Fjd鶅-QCf )YOJDK⁼mx$O/LNŢ*< bhK9{ z3Zr{@F=g}t߉[Xbx-s 8Oޚz@6@viM%`]5cKu ";SN[2ӣ,ESᆭ"<}B.1~\dsdlG.wshŝP|j6ܧKt/Ɋ 򀡺W zROaBf7VLvpVqO9Q[ &4@%h bQúng&{ (V U`@J00lo[u-Dr f>kpJIt&AhB%)F(,D.F"/5 Ga5N)BZs9b=zĩ&8-I8GR}CѭaJlb+(#Ik0p/ڰ%cx]p4'zzaS8ߋ2͛(+YjW9*$bOIB *EGs $:û|T XBDBǛl\hV0'>5ȗ`bÐ>3dTV *O9-hAuP 1@7=l%~ŶcR}ގkoz`g"q1 }Q c/GR@M0 RQmR܊e%vn2z:nE˰r5P :5IMeT`Pp\\g:`!N#v`;/F7NS^vwzvQ݌.g׍@cG`tp|w貶A./es8C* ~]GU-q1_,5ztS?D|kYAl!ƷCqvv!n3oеtNÁ҇vGVi;7,em Y!NLt28̷ua$-q#Wq @>i@b݀zu;_|}!f{{ NÎ3{Nv ֝Jæ򪪳h{0hԄ6(Y+v%ٺfp }&҈\J!)$Œ7-;O?{$NO`Wa1@%v8[q3݂ [3H_ WM\{ T ղc6Vz [)$ )_ڸ+l>]Uܡs]z[IqjZ>9mL[q~+=WvӺ,Dâ LVq[Ft$@ޯALL)Ц ʩKl\ou -X@>N,ˏ C+b(iMvh~NB}lVi0'D}x@IDATUixUM\U9 S/o-3RO??b+Gk;gz<fbB TbR-҅Kg$2dGVTA,!wWΨ\e5]Zшz1K4fȾ,7RւbU}fk>۰rvT 8|nM3)LU܌TY@-)ݭg4ýfYZ(vdk0KHnSÜitQp6; 3?٩}*tQ>/qEn!R]=^34!Wd Ŝf ,hdiU~Y(t#%c"`L4<1@[+2 Dzg *] `"~''rגт`>A+n3q <&1g(0ū3ڰA8akuNg +<3X  UmwBh.wV녆uņm Z@H%;&qDLtY8P:N`to=퉟LVN+&_&2ê |Uwj -X`5<$#êR-`1_Kx}{%0һGIQR, d1S] `Tbṯl"Nv 9NjcrnsޖHmT!T٘FU l5ch^ժS fA(%S"` `=ɔs,on.&_݊|%;:t<p x :oJgkC/;YP $© Hl<8` x X8{O4>;Aph &isd8p8y mQm jZ: xOⶀO`ܪTG5Q)q?HlHh R?[L-F=;۰J2(47 (VR GQ2yĸq$W01;_Z 6q*qff[)Uԉmҷ*ZR n)+*DRZ pqdDH6 ^S*R+p?K1Mxo)?c3iQh^4zbw#?yp2QԚ` a$ ]^Ǩa&˭Kdp#!Prxo~ 2DYoŁ3`&Vm1YA'0ہX+Ɗ7Q@ʋYvXQu(XyRŅeddŕ6!riSo+AŸҼT(SW_+\y݂t4.QRSLS s\›cFYu^`G_y)n泤ٸsw-YRGqC֚R1ڥd)y '/~DQac%0ΐIه\z(>L-!&ѹ I 5,Њ PW6~Ϗ_q_E%KXgFy}&^]+Xw 3 1bUYoxo/sƪY'͓5-m2 Dn[䜝We*E XiJOF@X*0ƃAq(ZK<|8&X)Uh`¢,-E((}JV^o~ ߠ~L$׫6EG;⣣ep~f|/L25%֕Ȧ)ˇe~‡'[oa0vht^R:h#+ D0$0΀Q d™HC{V,ec QUI q!9q?L_ hEmζOO>O,v|6yӣ\,p20/`qR~`mfEa{ 1PCσ˔0Ѓń4 VYI9 >_4%!z[& RO9HZvujkgPT)n_nyz*ʱmv ۿMS4ftI jkPpAX&LyЛ6%'C1_8u opB߉q[L'k>&5W?M^}54 V s<QB\DY&X^@v&VRLdh!T0RȊ8 ޮ0*#Nx RgK!ׁvZ)\(W4y/ίF%l D qɁ?b%x3. UPʜMUjo/riT,p\2X)o۹YX3f>z+zdA Z8c6 3 i Y qܜ|v,XH%#Ŀl[pF07/^oNj=<[3H0+Qߡ` *^D8:ʡZ\SqG ]"89Kb$gbnoQ煙㝍Up4nwfo )1{Xgβᢀ_aw;?:?P3D|@|>F\7 FKU`-OajM!/+0 )]*INIr>&qVW%J UN:g˕.|;&{N1 y޺_>{^邝Dm2YT.ebdbT < $56Šf>[?W7SߟPp9sxw#36j|4*jWocQvҚɹ tL)"c  ]/_&8գ)]LKa=^w^Ofa&8 w}Y0 ;?>Ro^1TxKvpx?&b<\`lHvfѵS:Al٣p3$3KIL1&qk pZ F(Q5X[(d}Uvaf@^XT.A7\7ߔ9q&|CxЩW|I,AA;>[q7_Nt߾ӏvE {Y 'xo%ư-<5Pyp0ZI1XppI&Se*T6P,Oz|>;txnrfL0vbCK]qm!" S A@<ܯlB)_'S߈ˉX,Bu$D`z;k]+vуt8Ђx҂;;rlaYnfhZ^ǚbʳf]2Z="ԺZn>(QBtS%f;=dB/YpV@BGtV'@9 Lz{;R|REwX|߻30A<C7w`PI ?9-W۰ETIlE0E+]R_l6L +H8,\.#̧r"$sBF0KJ*V]t澵&;04ؒ6ە c+#5څY*OI]'WK;tQk `lB bk2A؟D_.`j~vZT\  } K!&y<,R MILk9}q7igQvzʈ&§x)L$B^%zzX\^Cgn"׹&dkDhz=#x z5 Bŏ._7:H͵&gs6U6[\TG4FW``0l @W5DI3&T|9gFĜE艤#y.FkIvyoz#f8ڃW dh-ƱЮR٩::w@*{6ndK #֚B5%ڭ۝k]|[ZxTGzָP!X'xJ'8'^e$6@R-28D7GwXaLч_하L0ka/wua t˰ t,XO!oc!ga v{ZlQtW`"V ,L6z(yv,%PPxdQ7tIw 'M*iiX dCM:" ,} ~N dN8I"7Kf4qt[Ν L|Åۄn#VV灍x3!5;G&TZVF X<d%+,u0@L,Op&dK "pN"Ñ :)R-ܶ`~I,= Y_.zESXw0 p8p).& X+:TqPYJI%[llT  ^af20i@~"21)42NЊ9/FDA_K10LFMcN4į{ p}ZцRON`臭[X "봨gvX:~&aa`Km?x:ZWWo<Јv"W՟1Boa,H -fqPME+2 4L6<)BՒz#ut5;Ae}{x9#TkMB\|wmhA@'VwXO]Y MK㬪ָM Mrm!7 4-R幍1QoR2!VFbr">=6Cz!P13ܔAj8V雙k| g◿{#~n9<0o;0(:0; ua@m0&Ld䏪Kz:q1uv:ՏT"8rQ*.JQ_P.غ^g''`:/V`?ڸ"pp 6)Բf@hGW;VU YE-բE(r)Jxe \HNoG>z"^zfS87/E ?>ϟ]}R|qg W*o,joE-MJO͍IJ-詮CVCt)4dV'ƌr}fϋ~(|e~|Kx-h/Pbx,aGu 0+`@us]楯YR4VYS'ww,` .t 4tW7RAѨ%_P{Vf)VVFDM/YmVA 4ڶ1F蔫.o1G9If{O;31 F'6u`1'l>h4U =D$(0I&+&1Υ.JڙKF!:a edpyC2ϲPs쑜d 댺 i`w3u2C xbC9 q3.Br ُ匀~ͅ}+6RQ.l>mC%h&65Pr5EY%J:_ܟJXKR f G[uR'7Ȣȍ]=gpλ4YGo7l9F>W=q%ꦎ]`?ݿzb`N1;?/ s Q yLƇ4Sin,o GD0ctA@iؿO#Sgʗm37"P|FZe p@b~#F7qIp6x;bwĀv }-=[dұ PtoapI}xI߆V\gaJ߁TSq(ı"XH͋Lr xFਤ$ʌ>LӻZM̻|ZY-f?8/7ʽ#Vh .U7Ch4Z^? Ƹ퇃kx_.Ie;y1<С2~BBP‡ u9 e0:yIڅU(ρȲLEYrp6|[ЅWd _= 称URv0y dô3P\_7gJ` Xg."+u;v-)kW(HO`O600n:jl^kXjZy݃8=>0s)Vaob+c:dO\<Ғ4fZ @˓h@QB% b2Ǖk8^z&g'=exq ;$G V=65XlG $Yvr@%4zBkK&Ƅ "itڥ"g@9p֌jh .̸[Ϣ3:仭,9IH0x[tg-1ZlJHu^@JH5=ܜ0dUJsRFH.вyhlGNg!:''[Y.윔CȐST@~öEMZ'8?5NGvyO?rWS)`ˠ_@aam1hҘȞĎ9އkAO|lt;pρo 79l<\+p K1M?kKҬrkF:qE9|._|WURlq&3xNBҡ,q^D3 qk|Z'5X$6L /N$"Rt^ VB[v9y҈8A pًFphL vNӚC%PqkCQfarږў @K,9"AOS [b]z n`-~/`>:v5뻶 XyきA ^\80La6X a BXvh\{^g ՞g}G]ˇ簗xj:EOi>{ Ddž9 z 3BT  dDvFo"y/㑼둚~|C%}vӸQK`@9"hŪl ;I*b p}yfZ!+RQ*U!0|ʒ!>Ō9 cG5=Yl%PJf5Ds1BDedncbQfk,QBZ"-nOK150uFK3q&P+ڑ;]y]V\Nx`きB<׍"} vN^,k3 QGz7;0W#ct3rHHg]@kpkhml,K܋i"ȃ($: ,f6k77r^(NMR|pPة-Ekv6&Ƶ]lGscLkfT_2X :DρNаY M%Ie3DsJ'Neزkx3qyq :MD{ GX.@v}GYĮdN&C"iNg3rHH1a~ah99luOOsXZ!o\TUȲT'C@D"D ([-X7'&w+po_sW~Z}\e [5<+ܓ 詮8&|#s2fؕat:%y!:7Ŏɭ0f#Y~V~JrRL6㟴@stg8'pA[/wHuЃg#eF%-J5>-!Lo7K Q=.Ţ2.ځռ gv]qkL`[؇QF\QZsq}q%.afhݝ>4@CU_!nimUeLצ6ڃm@;_L 9v@$Fth:dK* V'*-aEtcbA3Rz7Wq'}j\\m{ 7\Ç7zw:jKGKvsfzݘT>HXQfLQ,: X p>o5撨9suU.ѫʭn$,Q`ŝsSPD.Ǣ$*}LA d1HL$1f&"m (C#N@xҹkAOp.׃,Bp-jcjFt6KxԅOa[ tp{{C۬,%P+P"᢬lKi:=ڮ%/9x q7ZBw)^:[xt[Ivs/U~0!gcNNٵVy譤VTcĮsVJk#+(L3tm"*F#3@@ahj ,NH!6K[atfâIT7"fʖ!M { ;|5چAJU5:wrmx(6Ao[|+[D.8f0T΍k0]`X=eed=Ӡ*e`8 *ނ3zcs>6' aS@Tk(z.q:<0+F{esحpBC{LwbzU'0MjVMzC =oq+(W;,bu6͒W( @= (=R=QnƖ!drR3LsgLqI0ϋ* h A"d9+w,.+֨"ea#" 8{C o;m]F O^D^9fsl<'v(pluڢ1 koKׄ)Yj"vPBfSX*:x0O=Xpa/I@TX@IaD\Nb"Fh=xt`0Fns=X9z5;>Ї! [[LH>͒Cvh8rj. 0خHaSʟd. G 9|q0,/4B.Ǔ08$TB"04"Aw^Dc)/qha2 f-r J0 c6~5K8p|‚p8nK-Y!6l/Gb.ԏ`(NjE @|'A:buVa*ǂGېwr:3#$R) P9# 0\X"Fg2'&ɈA){S&b 3NRĢ&!YPX_c8K/-q%3D-+ʣXmv߁`'a{A ~,m6xt3otvKnע&1I>6Qb!Qlth ~`œ[tVL`h; c,thΒImeC r9"փ <f9:{V<;= F8p`kob5 = fg巸 `& ESј/tɻJ0c %BΈk"BN=S(.ӱz*85DsG^-,YZ`1C[*7%0SDZ).^LgKhAS=]}='>`b߃+\ᢼY81@IDAT!~F#0O'EgS_ "H%c+UH!f27n8s'6쿨ۖ#Yo66<кBBHH nNrwڂn/|sN`ЕE8~~0^>QMP_Aՠqb6:kۙu =QfO 7%Jr$1Q]HH!b@(/>E(R [%`2:y% SP\d>Xg5a,؂{AG> |@a0w ›>j7x\a9$`)c&Ec&4-Yެ0t]nd_+VPdON!I7{k$1Cuz9~"^~,4 ["گx%ď>9 pQ>2w-eiħ a"KdR*:jB6yЯB6׫x UǂʼndxD4NOFfހ l)1[>@LnGN*P@dx waU;6h )]l_\ތś)qǣ mkP?wm,x`F.fsq9̈~ޮ8 VHI;^³Ffy.6ɗZT:OD&Eh`Fj0˳/j^ޜ<7Wb4m_;7x[嶸 ]`nYLSj\VJ2H7Bym:_e:T77E|p(n+f4#m  *e2dHn/9XHi  LwmqWg;ɅۘOwKD|z(^ !>fvs7%x&0 4gB S_?'՞gz[g+AVu`xOV} @uh}V -0ķ\BhdrTy %Ѓ:|3z'Ɠnſ<{Og1G BfxH6uđF?Ub  :A,k2q2A$SGrCd\Y\#{Lk`a`V.d3*xU,:/b8Xc %,Sba2 d?C Z:2FD`~e g-݋C <umcqo-{#>PS'f/]096xg㩸>Q[&Wmujpی3nPLt?xX0PmQQySfT&**/xݦ0f0X/BZCMn*4Ά[oWTƙ7sND >j]Uk/SX0˷>>1_Cc&z*4/tMW51G%E XK3,%MET>)PҴ5J MlJPS$Y$cKKTRb)2DR^eFl kg~;.MbƝa=i5s3aO|}<?؁o~B>̡P/&Y;Y{7m<@ON7oD mQ׫&u' b.`L,R@蠺WaUUk"]*SO!;QXFʡF|ہ|t;duz 3~r&]dWa lX ם&9W8O rOŊ ItzGYy y:7xY$Pp @F%vdЃknNg%%m`Z8+qlo@* CYiJy"~΋ȌD"bזVV։;qڂm[Sr6-(~*nC_ |v,gģ}/ {u~ ܘxn.FG;U0T`n8ћىٌ@N5.0@U.8OxBO6&ሦŜx0@AIF9M|32#6 snָ-qxЅmw`PPGӏN``ʁ{#̔gӷI obl<=p +=C߃-n@p "XBc;oʸӢ[*Y`R6MD6(37,b zcZ`%qР3\a2t$&q V|K b'=Vt`7#XWޤߚTd VIEՇA ١?\X ZS2{Ef ̏'@9eҠ7ea fۏ[2H#IĠز/,}#ËnxJ9RSw ’WKX'`2=N`:pl\ށ5e{tt'NٮqvW\f嵱ƊQ{`1_,[f.;@b5WU.rl@/ꚹ-=>SO0I$Fpucp4~=,^3 ':>ZсG;Vi {p÷m*@WT26˛p;dPlp )~F&,P\,7vqme#pCČ* MTIH'ņM$39m띡X_@Ͳ%FXw `-=уuޥgSx/7 Y`$^``"/0`[pN ڻwuly`>[8qo|6ګ49w[߸B1+{ͷ:ΑayN2CsBfF'%8$[gyHbD !xdxG{p8F]|}#?~'gSX:ngXFSy?O7N'x`ぇb!n/C8sXgD7Xh ]B`zч3eU]n(gƑ=G*41+SIS^:%paNbQ.6Ob2-Xx@_Wc G Ѻ7C_y!9 {V`1N o0p>R I5a zט h4o|2ޔu ?4I^qn?vZr/&e#C;&`d-74>* eN~b4ZLgHLfq%&/ t_%%VoL)/f㌀iŬ ,@u')ټf̪,j ~?pO|]~9kakCv$f'b2$5+C7G{ !Oi2ˇ\`rm2ށ$Q}܉B mj /h`5ada= z}F'yy+ц_<;*-7۫Vk+Z:;}tkF. vBtIJD^b<23/Te|@I<qJ3-R<ڈL*7| C1tNkWC9].1J*T-uS4*/QA³ф9,x pK|9iΝx_ǰiF9*0mSUn9h8>~o >x҇OrvXsX;rx'.awhggWp7 6hpÝXvڅ`>adx=;W`m>|Aʡm 5> z PETmXRuɪ3 _-6!yN;4e ҕMr+ B|jņ;ΎxKP\__ a \ߌ??ޡxt-^8ڽ?m!`u>w,pTjEvDEFU4X,.*EUe$)^F3UqCh,hNU,$ n8`ûx=v؇_;z*d;( , @b@z3seypǁ L!m0c`ە%4hlcs1ڴ p@0pxӛ9,W z Y;r==X%ߺi/a [i./8?ߕNppfW:;1ə γ 9x-݀'kiyUT/ᾉ㘗&;͎D"͑rŝyГ*ŵm!~'-qzx"[_OoF#|)oN?H<=ˑbw/z ζecZ# ,\Nb"ZM[q~F`)KK_sE:x<-U*.feC7lWӖ8}x{z lC ؃;`]5*8qj3p`},d (   x v~ou'7  UVqbĩp(ء)EJ' g.Eўܾ݅vCה.!+t[Ag#ͱ^Xy@Xyn,~u!˼4jK3Q#rRIoxIY"̰c)LIP 'M& pd wќA =}azb"&su՛k197ox|܋;}ϟB<- ALc&zfN\jg"A'r%,aRRԉY"#D PJyT&". d"kRٔQd0A֚tZ `D ]ΰ  @/7=o/ xn lw0 A> o_mz%pS#+/&`~?7=xdgޚぃí^rX{&͒<,j=3==;k RTJ ?*B!E0DQ 0Dbkf3;{{d̓[[Yu<9'uyDq>G7G&=!=7'ވlqYUԖt 9km΃LϿ3_Ҁ3h-z 7yDb40{}`gMd+7 ҿ?\/WfN| YN)nXeN%k,$kbHNf5 G诤1e&1\lM$fgM/863s\Lr&~цKgX7@}[KjM^U4lD,]kfd?!K@BilyL_X[XUp.APFqie6 OmlH s*͇DJݼcnȬ4) 6~fKk&+F'cvWLm?/Pѱ A`]5ҲE0QF|`F6iF+uǢ6;ӯvޤÕ Zp^CzL푛=8ܜYk恃 ;@dv-g>697tϾj!lஹAT*%bIoyΖ5M:%]H)u 2Vfo,s 9'bv*ݰG"ӊGҧ_g[hI_f^z.VmT`5^BfVN!i7}z [R.2tիv2@ SgO8O0M1HT GF7d*sALкl\ՈbDʫd)[" c>2D%qM3͘Ofͫc2|#݊zp:u]5pej3f`@`X2>\6]whMiz޾9(]3w4:+YA]\ɋsx>n@a@4 ⠫:tHKR9 $ D^P87HIqa(@,4!ٕ <jha,xu-XՑY[[2+sw} E; X k``f`f Wk^7]s;#XcHWUF⿓C=m:UTS3|³|rDDL81^13 gBP$*`7E+(YVNU +Ƶ8vړWi6>9 xJN0ViZx:\u Q52- ּ;w4u`Ы:!}}b,E+PV ZJ ^l]GwFtSk4LmLDwZZ.̶CehG7vrtZǹ6U`uf燮Ldia|Biq~άѷQX70ByUvZ Tԋ;xDF)ҖjCevf&(B:F_G*򉒬VBlV1runlP=@Q)F|'R,K KP=TF<'Dt"6 i=6i=3|z&s5pk |iyq֬.ҧ>ݸ<ϟuܥ0[<Æe2Mtʱ8PB=XkuAe26wiK4t b )_RPLr8,hR FFh.}C^AZg]PI0UMZ.GMk2aq+~oĝ1R]9p5qf'6Es`xۨq!uZgSU*(BEsqzz6ooy[[Z] O ?X.sgfmVQ*v; v250KM;kk_җBNGfg){ :Ԯ,gQ T-gݾ w1Ëҷ5-j![& .mY9pX2q%{`x6X8Θ/fsf"pF|_!5uk0;G4;k^BȃS"iJe fؖޤC#! 4{ 8m^xvx K F;ݭi=l\(=37k6V'F'#Gc,tUպ~=4In%&Xh/%) %%;(uJhV>^'XUgh+kPW3<:A)yMJa MY-rQKx FB.h"fu1pB7X335mz]ɜyI8 $V'`u \%xw~ei>C`f8PӅ g KNkL 쀾l1|=H1<\80;r]]jM<lhH/!%lN5JV7S]q%謲13Ew]Z\ߢioZ/|5=e?5{-e$U/&YG7`VzU\C}Qw/f3mfP]muP bȾ}gBR1{*\o[m^b^hvfL}ku放(pj" {Vie~[4#`>޼*ΎY K f^k`NM",#7#j/Q@T󄊢঱1-(fp32Ă dos&ʧ8a0*TK:9G3;״&P(by9H+ء ߆AڱÃi$+U{*Q~xj\l!^цHI !F'm=Ap?m־eS1=c T]E(@3;{(&W'PV\΂JO8ǴSh-zvnn@̉Y ,N&ya p]5pj`Nz�b^2wo-/W戞`5/ҭKG $}E=U3 9wQS+*o%E bwKPYºA֤x&@h ?N$(Na~brizٷtbŁ4~Pqc.:]50X'4m^ݼ* SJN/1kdw5Хe| +|¤Hl L JSZb:7c5C=^(IF4Z8ڋHs:HcM_ܾ]/hBsc٘ءh\p2.Oe6BW 븷v2ԥvL }"'D\? *n CMү"ڰJ.(pB}IV"F 5e#h+Bq(T$,N[#pe~1t85;?5xXf\ H_5p9j#-O޽A Κ9W. 7E3d77i@GZG={M +(nJC4H&;]wI@vKJ q22c.vd##C8? ʅ<( V{[:_l[7<pNQV Ul[28 K~cƎ W?Vo"Ssӽ ,q_Iċq#O&;[Decʩd[͟"ӌ|=N=>dǏ#/kfy ޔM N):ˁKh0Gu;8fkyɂyy6gi@3@k u \Xk޲nvi zו^k٥gWZ%0z >ٻWnړ,NV>(223G Zgx-&E&-bk4ݡ.ЗVV!uDWax6)4|Ɗ_gi(2Odj 4`c]5hf4n+VTHLwˁS;NI^~6`6; r^*S ڑL s{LHdj8gmWT@ DecjKU؝/h ÝOr9Ad#󑇛Y ;/ $BۈezߛU ޏh`ny |qH9x#aq\k[>L7ӔlӶ Ԋו`l֦cJM| 0FK n Ad .`[sRm-+]nOpt%r V.}$ IbU, 037uG je:4k.ׇOn;QMmH74NA.&ͨ^cKKBAdUm/@Ɓnv~z^:Rس(Z bx N"ՂE(<NiZZT@%M7OO3|-^85yF"0zn5ZPp"yKS'Wf\u :#jgfo^kMc_|enlřQO-Ȫx8t·K tи690^r.dtQƒi2`I4M7:E@ ~Tb}t0>4Ξ]򘺛 :'zFT'k pi/ԗqNS,j6:HҼEPܙPJDOMjSҎv!̠-rhQ[䍨J]:IBC@4Ҩ:ݠґ5 >u4b8k7y@SfVx>C8=K<;3D ?;ssijgp CiZCO[b{Z'5]@)z1YO%^=5[G+^9*9v|XY646ӡGpj5ր/D%>ݖhr1K;tdASV FTkV>\y)T^kYz;fT?sG%"_WϾ5;mz6#m:a8Kc~󜾂ETPũ`Dg9WqR㕼VT-YrL1!&EWxhrAէ`tfJJOo[*Auj@:2j⭪+&|Y4 .:N'vO9+-``,ӶDugdQ7Mc\tA~"ƮdlKHU> \9YNf, Õ]/5V v_rĒ -<L)Z\n B"y ` =t/*,p%Yww_曗5*|p3ssfi&܄3:Q,0^n߯]'I8`馒DJ:S}G(mp ߰`x$M`Rx5Y-Q›1>xe)Bە gg}n^l'i|Fde0|&p1W̏?/1Ђ3qe-uO76D'U(K sq!(J%[24?((8TNCDPZҀ rV5M";_"ʕt -jc9") sRql#$Bbcڶh1APb!iVݸmCD =Ob}]oД ԙ bEqVfi{'攟ԣf񜽙?53tsO Gttm⯹ȮE_$|>+ΙS];rĩP<hЁhV=ڥY4x@Gf=3}HtZD_|Q'f{ꫡGbw͟?_٧.kI~%@vk{>c|ъ#TNPr@NjmC1Kcur`OX PW.8!<~˃8nZx4[zOWWvr1'E]`T̈́3?}ߘ1[t+ 0#80A*ZtrIJC+A]QF JH[2WԿ VЈShr(0αBhmD{Xְe@%i{7HO ZWqvZ`<1ADȱ z8>٥N:nm };s? =E ~ M4KӾY3N@kM<-U J~?l;]8f3JL K80);B'ߦ M UBiǭr(#]Im} !mXɀ'p3rGy h@ _3S yf h ~:M [f~[ >734ISͿ͟+?ysH^>q}+3O :ZY:TdG:!di@)_ \Hܰ I F} Fq偽u$NtO>fܾq_ڼ~ůT||WG{h&-\UYrc}1j # -J f: SJέ~ox-u[IM&ߍL uiN-YHd2oXL?M<#PLϻ'N9sHQgt{D#ѧ0Pt]!xT[j RZ3ڸݏ9}Z߽aZf߬1d=9I Q]y>:tOiVܒ_M4Af ѬŽfv63{ x*YL4oan7G'Yv_Kfmm] Ez,2.42 /@\!ҕWE=`m['/#rR`;N])+nA -DmItyC'"RRzچy]'fpN'09g?Ҽ>4?&-x tӭ)󔶭u&s fԮ _,"_huJq, n 2/!lj+pfұ-Ys%S򍾎4;T+Ӡ ר7Q/ݘH\dYxxJ;/xcq 1n8]owE9nҠ c1{@A{sd pH\螐'O > G'xOުw?%pSٷOQ~.7Nr2爀:C`KhA%YPu:|(9MQ:f%_Bp1`h0/e~J/Ч>\Of!N.Qwܴۿ`e ggtcÍ5a.(0@&0K:t)Q{!]rQwZ:]* x5Y:ټMSOiO>-rJUo'V#xtǮ 0`6*$W:.ua0@ +FH$,5l|&4%u/=@1Z+Ѵΰq㏩bH#;&jDc<ׄf7kp9 ,qOn1+s1{[vf@r@$ n恆#Mc ^DEݛB/QPź #&4n슠Z#kӨUU }:@"m@gXC`m;ю>>4SJ~Oͯ?}i.fkF3yt;7d b՚͝$\Y& B-k.SkhmtmGFji%5k4L.dV9x]Jm޺МTϟ~AWvu_RM"}&9K]pA,gNhoh*e1/3g|~71.d'tĀt{\xFT9 ܮ`Ѥ&JoO)U/ki\  Urm9WJІja=&R&f(%2bt.mʩx5ƉӅ lzz L ;щ*D%<=ptĔjFH4Ows,aZcBK+P+3rN jbʃٴEyQ:*)q8gxc&opo^xt Рf2wO>>x8:x욇a-ȖdGLGΆ KKSw"9>Kw뮹qbMZ*ldM겹cM'q+lLBkPvei T(x XK^ctj!ush:4>[SH.c6YY_h ) ,R@^|32)ѠŅC?8gq\bEED>^j1[s(BILyMKiJ,C?`c/>q*2\ UUE"LHƶ!V2 Ow|De%2D2BA&WzKyL4V > U 6*X5ٯ|+LΊfPP"t`Ysnz[c؜iH9p+ÿdl ?!mDx0?Őa $>u'$氬K'r{oXbumr}@1Ib=2 =(AIi]!Wi#ma@Ѷv1$s ?xNnع>J֙Z[tvN6-oGh9[\E Fw9 bTŒYYZ0^җ.ߵ9A[6Pq[iHڌմ Fp; lL~+ohg_!=x@E?g[Tg:[>؝b ^o?Ҷg3X4)Z`|&f\!HH:軭WcNccyb߬,yӢܓ|ngkͩ.~V _W,kZd [XIh%(63YwcYRIiȇ w ~T*LWrutȠT[\(UgۚI؝'d FЭğ'v<W 5:7K.(\8ʺBs@f;;ٝF9'S6 ;Kd7@20,6~,E;90XL {p_ gc=Ƒ$fm3?&/HT as)֨I2𵏠zQQ0 %?=rpQh>OҜv9A3 8^o_ -8EuHzf@4eٗ[DuOd2ä)f_ i^ RkNy@W`@] r RHAEcthK f[l>"=bD` AE(8wW--M;eUK8d>50mnm.ӊ: /ߣfi`5{6A%6jd%v]6pB8ЪKO@]xbc ]"e;"y]IcQ*(iLrJT%#zR{Yu&4`mw[8xZ!n i BNqn2TcYr+}TҌdq %zDqΘa1OB;k8bz>XAVb?13&.˰]2S O3d-eN"kp{.eD MIIe:JñfKHrc~ĖAxyα(%L9[O?ѕˀ)Ik=`/ xh66g+f&,h9Zx<@ڣaooޤ٣Q-vmZfFmAj'23o>3_>6<6aqɗu}~91UtEF0KD^D112f(焑t`[AE¼(pAN+ V'.Hi8 *F,=l#+Gk`$/>h,e);f~GbTMG}6uҢ Ò,xU{%qEkLzF)Qu$}s h:w]!{,T6·h}Cw!$q c_þfNd/1w`T'Z9T71PE c!w((kfw;`Y~Y|ۡN0Ӣ?hns"])|Eao/YsqE@5iBܣ)37yWG[M! ^S(6wبu~l)ۥ)U\t+t [)A':h NӉwFfWV@u+@))ͱ;4O13+ .'c0b>3M۔iZ { /K8t(jndzloQ/wrQRYڸvQ H&!"(D%lEY[1yL8Ȯi/&.*?[LCR^%|>0IёYGԑKm@.c! EV5,3\ָ²Y/2"lgCֆ, J|_%k:Ys? ӢͱsԛuM$OpQB9%P? [yԷؤu?eU*b$ ]ƷIFr v 0}6^s8>a )#F#xVfZVf1Z몗$yANeJtQ50M6 5JRA2wb+g)5Ptrp.1Joӊַ.嚦ٖFOϷ+ZyhOmH/}* 7/Z@\E}PYi1E:<#NdJ.YWw|.% Zeie[ T5*b*؉4 I*i+be$D_)Hbl0o'ʂ-RLus1(BPR x~caԄ AcFYPK \.V\c ]jNU XmdrA H^'<ۻA}t~]Z paz,05Pg7ͻ67//奙qY41}s>י׭D^iu\)#ֹp4( %dheʴDElbl094'Rtz dPAr}l@($٨щ'l7XElZ(\&]IvpŊwy7fߐx|UI*3'T} {3iU2!֊0 {رy W79'70E7u 7/?7/̄>8GY2w|kd#J|F 3ݣF4@u5v*Ӈ6znşNPZ 8/ R8f꭮eQhB8pR൹ғfn5mܾcff{\*Sw̟Oywa)< I>yn~d\ "]㪭U*C`6>9M{]tH&~ 7PL()D=4K1N᩷Ib -蛭~ySMM./쥞.$` &< +G.=ZH"ّes7."oCIZ!,.7u潅M6}2Gz_(!N.8^`\9)ft~R($y)۔9]X1o^?oǴh 0,]cw@8>k>aGÒ:]ZSGl5y%w+Y8-=:XJoˋ^9Ɓ5rIs()ηj>{|zqH$_Y^Kt4N'66N,N\7n~b{뉙bAΰ̋S;rҋ 5K4J^yክek?r//0$D3c8B(+dSQLؾȀ69W)Sy"-kcGJy "&[ YW4njb~Jt;RVg4BhQM(hWt$es0m5PO :]-`X:%*#~zG%Sx6ON)t'A%+g[Io4L+6cʸ>xG҈yO@nwzD ܠ6woR]9>Ӎ'.]{MM3EIӮHZ\q] uTr*]}JBq+DW,X\GL7ȿH?`Ys92 ;|=/` ])xc&zѱ9֌qtsRdSMՊ5E괿 jii NTe#`ҹFN)@v&n2ӯ;ncIv(Q;\&,y6P,HAx:?!x'1{9ڸki潹h?vk3 R1xM ˗E,LV8cl#ANAz~{ wKa( 율}3d*tPdžRh?=~\ +`sΖ Ma&wruRjI ʞ Wܶ\QjH |Nizhc,b(o(1Mݗ{fP~S>~IcɆXt4ЫyxΞ cgFP(@&)[Z5vPTT['B$ 4r*ԗ>)~MX]fکrW:hѦ*R[2gfD08?m~]kI З ^wnޘ;-ad@x+3Vm`@K:͏mb=B$EBJPm29T:C*'v\)_d7,Ms/&*3GzP9pzrb٧AKp/z}}a~_@ Xt|ܼ/Q DP K4K5nsJS7&\+0bV ljDG LM,'|eycqL5Z" 9.Jw#?Xlr58# &?yqti]ېWPW]*-x 0혤<>V@$5mzh(7͔>e8MW8Bhؖ܍AW jd6TmoU|wf r~{!̘ cwN<;7iۺ.*t<w<-! Tc3K6܄?o ӮiѾi0b:vH'DfEw%.ZcV 2"r[m WF!/ \zN;b0:vhZ,iy\ E:v ttt3S6_9%tZ[ri~^i$ͬ DQ .K%o`vEDupR u}22)2qA1t7}rdLQ|gmcIv\#=:<82;g'fITUUi("Ȁz?QKpY?q|~eO8Y&ޡod1(7Z9!|YT eC8MͺQ~,X G˶ ^j3}# ٙiG.u^, ^=i^>ihr9Byn>?e6Wkxċގ6v[5b?vjLDm4ْю\Q$fOXCѵFKf@Ց`O/(Eшm6.2}j \hoxQ)lDf=ˢʱ8nۮ XGzOOH85,2hS/di1෠ ^ʤA+ONlw=UܠCJ?Y4;߸ۯw6h\Z5h ٳv&&bVdh0q@, H]rBǩOf2+|(y\IRB4~uGDe"@%Ky- I#C03={{wД7cb =^/oP ߛuƹܠ=54Cp$zQnI2N E9nH^"ƅ#)Ȥ0];V銏 %ɥ4cӫ^V. ZT _@FMSX.Yh2fR.i:1`ܘeՉ~zP)P 5XLw<fBVe9e'D9GR[]p@p}g]k>^˹R/TVJ`DSt^o%؂_\rbW+qPTOet955OޤGYoB2OޠYcA@KsLPZM6o` ̾qۛ>iSd~$=S2/‹fD䨂XL+|\esdVRIUYn!ADW@"8 9=5C歛yϼ;t~T;O_;? <<7ߝ24Sod>H(0 NXѡwK +gCnzAOʈС\^Wː:TXRWHs?rXtK&A)6*bAR"\A JE@p](ÈJ3Cڏ,l,ͤu`[!m=~Fm㨈T2<7 ߬ DH eC!mL#{UG-4L'tnH6u$}d޿4+,Ϙ'nw% g ^SPIIWX+)~4t"&jq)m"/qw:IEb581R$GHZ8Nd{ JB"(@i:.-.w?2Og[/@\VM;2?+b \_4]Z{ }+x6xCsm܈6݌q"8Rw_M{ ve R6ܫVn]:~*/-emNkUNd$r" \dYlھA!y\w,S)m+ρxWJ 7=6H%|KB"K$!Jr*`$dL(nQYk zs!+.E՘RJx!C9:VD')Au }*PXyf>MWd~g.Ɵ2zAI:>]M[dUTb%cG\D0Pt#&|i(R;yzksEVPb*rc,-,;1@~Xן4oՃ.A%Z^Lbر1<ݏ7Ƭ.GX*  E+9%~}gJ.(9:Q[,yћ9"bs& ̈́[‡QTS'4V“:PT%jrwHRuH/(—r%6E60ZnI S^ؤb3IbVܲ6]4x9ޤ?uEs2]OeE(\V+4;YC#tO t;vŁ;[o<7Bc'RGΞݦ|GP10 02[`5$h:2w `G1'P1+y[>OtɄ]󓟝cZ`Nn. E0zw|9GQ Ыc4?o 0J |Ծ'\:MtK/O_gRX+ N޲9#8>)2׫kc*D@Ho0DL=iC{(` It^hd_ҊfyiB/?CsDK:j!kBiю%MVmB]d|k1!;է 0J%Nm PaG˅)qQCU+CŅ U۱!7@ltIoK:WN٘ZQi?ĜG^U,'=z/|4+` sKqttNUޫg+ \8?CO{ڙMʥIiiy_ьNVuAU_R%}',-xg7N/m_m!BoM7Kl)HڮZ+! 1?ݚlFPA%"K/Wj̉Hl\J.[*ȎQ9cVw}dbn7.},>EJ}17=iAتK/ͻBbSW #Sx0zhUNWSr+FC(Ս#y"d7NR${ 9 `s:#."kдGCc{q3XH&P,fL ЍЧJ':JsSЁ(jgҵY-F <t7ŪT9|=Ȝ.oTFXZ5?-x^،R7o_}pZ &q#5‘MMc-?i]Lӕeq?!dxB:ołK 96%tD'&k+ 9]VЖM~+ fHJpX[;wz8w^m/iO6/iq@}0i&h {=]ir..qtQ {Iru"/ATQw+lJ[כ q7\*+"_[b)CHbKnCZ$->!kCNE.2 67q 5ł$n@*:DIBr.VV_/u:I4c ΣwN̏ ?<{A/O8}}} 0N.OW(ziN[.K X/0X`HVV (4B.;fZlxr^'"k_p4Q*F)d)&/U/.c\[VS$IV2Vߜ9|W|,ZhphS6IK+h;k/r/c-E"8ame>+b2k Z~fE\-A )-Ü̈39=嗪/xR"Sl~x`>gJjCP{BvQ3_6;;{C Ī IBX:7`'l,%1@D%HEIQo"u_xi\?ZW{ZiCf{VVWụ̂{8NA. >7_O>6'N ˃S @WQ:E8mv1O1?zPwߕC4/_NABZ) 6b 2Brp"˲\=vRWldP$kj[^MB#iMmL_دKۺR Pbq&](H^:ȕeVs 1.ndD!d_[g_\GM8wf~d{U\O҈wiVGpV֚j޽iלN50;A(~~ԣ.]1E;v+]$AMPY O\,m*/YP)8(QPR*-cJSQDJEb>>bԿg TKw@~_˺B(R JSfTrV3wbNCֆB=c64oUEMomz2pJkAa!C*"DU=eU2vGn숦vH4~TB::5+E >BƆґ .( A"'7J$X2eZ˥i(4~$|΀Q3LjRԾ=$nuqf<4_[[<;@W'wpl~K:1SAy,v92 CGkmʚ\JJ< a[]PO 6a-րz!;W9Xh2\ؖ<1cX>fv {-5߮E9-'KzR%%XhE XEmğ^ǒXd|)( ՛TQm)JB7T*HJ:]b}fawEw*y)7\C M-MNZR,tSv8ϒ ]%GZ!G4(2] t:_ԂhJԮe/ߊ8sJH$|C+WoH$':BJZK,'9x]3,MվٛKtoVK|j_~]bzJ%"Q6|FC_EcʠC  h6d =A*3Lt(4!!MʩuqxUJ:ȜF 'Iq2F,!HGgl,/Y][3t"y jl>198>p:11 ܣua: t~4_q/x<~?qx%e]lW& u*ՙ tŨ7`VV-EiAx`&)L 8|&TY&/* iq/+GDGZnpd9&Y9-7TWf 02Q` Mà>zC9)T],);[X\;e];y3SC:i! &wf,c9'`Qryʳ9}z,oske G%3w 83~FЍ2F}5i۟,[mH&o =Po|Acmn b3ɬmF#u:ɉ1Kroj /yhC3\G$e؋*{'2qa 핔H(.‫+;/Ǹ jlehQo*%bZiTO2Ei:].ؙ zr9BR1/=*G|jUe+*[Yn*{pWrP%ɣ)2Et 7))ΓLkdJ]n&Ӌ4υ g|cL6e5N,K[m[zT3,!)pvِ70Z% Q݀lY~9W(AU?ʄ3[9x9]cgUiΊ{s%ry3sDCj߭ZAVɳ]YR])Rۈs)JDM󁇠ءŞܴsB'KGNI50d/y z4Co͒7%nD-v`DQRP3i=޵ibHݰi>iҍ* 'Fa잻OqQVY4vJ\ MFLхPL] 5}Th7!lQ>[<ɩutF :q]'OɅg/=8Em1[O~ɀydԶ^'t.07OnRsspxdvv]렾|.{8)26/CØLWi0uݦiaE܋ۈ %d+9j|>-;(5RFqJ(CSB6;xlJb[i{:7-hb _iI">T2UB<33k}&}2H^Թ9W~/͟O~rΣVi޷o BhMvokxsxb/?IiU@]FԞJl1~DfzDPT䜔K)98SH֞R^T3Dk "dܵB J9`IK`vK6rZ$܉6؄mAO&y{R鑴 GY)[(o^w+ې-~䵚YTh&jxW9kÓ(o=ls~4,g. o=6Gޡ9Yss_;"=  Iˢ^}=\i{ ˻ys^:ㄣ|Rk厚$ngGk{}Lӌ c'`թÆ)pBJ(6e 't,JʓM}Y :htrD7 N3877k6oҷg3vgzCZhcḻ?KZ*^xpJWZ{Ac-#R qPE^ա$ o6*.dG#(FOg+Z"|/DmR`wRKl=Uir0McDGjjcj?#Wa6v/É=؍&Mx#JΔs^| k/OǩSQu~Pʈ-9iQk}djܠmK} 8~`{x,+j]|:]43.*ˠ7;7/v}pDGW~sƉ8\lw.?ctC ƣ#^*XPNzY - Hl$ŭ\I@AJKl {.uRSb2$(%):"ƃ̗$j⛦`:J99:Xwgn޸IkRw UJӣOͳR.@ؤsoݢQyW(I]U|%MZ1f'Ɖ\[∤-%ݘ5fS t)S2z%\ЃKVk{JVz}uRø00J~Jf7|8`'ZYCYc J`~kNyD 87!U;YW,XXgFldR [d{9ui~"G8JN g+l; r9V]9WRiOeɇE]ntA>RO9 2g kCs>8k]2kt3pB^uPĄ6@XI|ҢTnyP->3`#FHZ%.ءj fn8U.k(3= q⳰Z>Csz%`-@ŧ2|9_3t|L<.D{KQ(tOʸa/]^nKGBzڬcw78wjQ2kj3H|Y@@ƷWtjݧV`C,k ƹi*ȼGVW<_Ş}m͟귯(.ےFW\Wް(ג5=gvw~53ۧG#e(Kh,@@DFJYu_ &͛'.C]x&gq^ᱣ>, suq6Sĝԇbd;y55-~,3ޖm~X߆7>O}}7awo"lwJކo#Jye'TKG6gN^ǓK}p\$|3\}XFw0#iRv薩$:Qzc8eROz`t=_Q o'?{,6:c:Ъ[VOmD}"Qp1d!E1ƨZkG@wEU7ԓO"LUKg, [O=dCŋ:$kl$ ƚ%z%H}N-BɿV8/mۛb؀t U.\ؙk3%9؟]WN^)l X*vo:]-14D4~ bTAAq0R7V \(s.\ZhO->~ N0 r{,qslUSm \5̹Ȯ򁈬uwB99yH im8Sg# bznPB)5aS :x+XY}[n0J >2_Q2 l}1 74o%"`-=y{x_ E@ $lǭ;уXvyqxެcЉ(gX'Sp5 :gš&oei j*r I, iՀډJP 3p|Z.+knpqw|h1q! O7WAogYF_|8}/|oլz,v?{'›3zE ^x[[f<>`3E#p[$A34s6.#~'_cg e\s,Ԝ,&'ڧW2mݖ8GER,蕼Pl!Yj:psrp^ScJ]]㏧p'Wn?F_+Z&~mfʑr8`c-f܍:U+dJVi1ڿ0Krmh00̻TS( uIjx :^ @ނ?x|w.{h/;~wm*C|wE@ x6s3b+ bž9##[ŤhsO'H H־#V&&vN3 jVAc!B隔ǓHHA7:}9ZyIQkiMNr$J yRZIЬG!o3"i=x v  >c.ܤnroY}/wLHN K4K36Z8rC+7P{?;\(gu-ً+㯍=V>u=4h@AQ@,-A,4i#7Ž(?#Nq:Iʠ6z_, Yf~?9G'1Uƨ|0%j:}'""}Qu<p _==~/?f߽|^Nt"\Ʊ[@ W#2rk~Ӫ(%,ZCe+vO-b):J@IHo&ǓiUسסRE|q{h!"KBT S(e>F-}oGQ yDMv3QhDOTq :|Lߺs} mowkwɕs]J<>y ܸO2>/Ľ9:}8l2[#7&Xp0#*E$ +=f-L6ˉ'M%h0Ay̫hQVahAp1aD,32RɊH 22x%΅KCU&"1n_er!nn ݾ}v{N؁E]?z>;'Jj|wܻ^) ZY.l.u$ӟ_0qp ;YE2Y|maP܉h`÷" x }Cx;OϿ  5Vيow]]C=yLV1s^pG_ھEyOΗI9JS*fȤ= p>n& 5z(]SUS)NE*pqY,Zkx yk$x)稸̕"Xm_!RJ:Qgsctl^MnoIhVjݠ|:qWswg.؊f_lJxpJ88؍_n*$Ixy/}|Ү5e]GN9)cd0rf#^8Ԓ7x[k /3 ɖl5BQ0ƨ rja,V1gaL?D;ʍ=sAKJ ܽ|!~c+ʺ-.{-ÙƝ,:ʞ/!W$(֑q4ZVuΈV_WVGFnG_RhN< 9TDCs@ |"ݢWAsrXK󷺥m܉G𐞮Cx?8< anp9nsAczx_@I}ʟb!D9_ML\k[So jy`xYM\:cYcKXdh&̦( 8H6՘043} cKPKʯ*żuf,|[ W^D4BG_~~pxyzt"t:h=vccZ D$?с'ȦEt]!ҋJgqen{k 'yE*V)e{r]/Ӌ? LK.7;KgwyF`֎{V)F´Dq p"G2~l&b-j8J}7e9?TN$zhbr熚i!SqhEmVP.. n8~;k$WMN߽y%:!<:P}&r|r*i&ȑ,#@Ȳ5Z5jE[M .>3hHbH,nX22-Q56U#w .я?@<ȍ@mزA;VX1Is o{~s.\8۫U ~AOn ß|n쟨KSNntncTQD.gZFξXI0,OqjsϞK]Ʌ6!ӶIzgNy\А #[e`(-t\NefD s4UldP̙VDű 2Z5+%"Uh%z>yEUQǶѽSfB&'İxTj,, 4xN,T1Kūׇl uw7\9A _R9?"Pqò>apw뫒?gՖFH_^Vlzu;Z;&D;G6,.%Mⷒ3<{hƪ_ +'(ߚ#56]D{ e- V:ۡfwV&*Cɒb&̓Ή']:kǺE[zp- NbF|78Z9 }xM>cJ%ݮŘP~eO|^}7|;p14IxYGA]/Ό(a%ϕ)M"B d((YHjB&֒(Uo =~7| B{>ӧ/¿?;xfs;Ա{pAGCfVǚ]<}4)*۩T 7O96ނEbn-Fc) Z:y.}07^:Uy+@)"Qq:mU0J ȉ ؙN0D 2DdY&'!i0$=^~8Drr2Y%kT;dQ4hN?jJ :1m@mV')Q7[hR JFHD5!]S 9޽A'>޺qm7ܹyujV'F ^;<<1.Z(*D汭: "}p{\sZpaʝ b@ gm FӫVɘjH Kgfaxo)u V0Uw'Y_ > ûco\7|u<͒cg)Eo`_Âgg4DoQ{Yå;U%?N'pD+ "Zs^1f MNmG]?(OcEpS{kM#{M*6 hI4TdU]̑ri_-'Z86oΨֱ˸$9araZrk+ %pP'Iuc 2o&)?V&$zM`>G2Y$dI+?p^ ?49&N YZ6uA݉!U)RQ9́5isfGYiPLcs繴6h$*iwZ`"8ㄞ 0kE}F`TvQAQTL&:t \Z&$oѓgVp(O<;aILrBL iI ġ?5h=1NvH_ &oT~vF7ҿU;EP䑤'Kz2[L&H?x';pf8+ o?<;GG0q׋]σh:+fv7m[ LXZcS^i#ZP 5X*^MS pr^i}z#obS ᰬ2Ƌ ӭXm90"fUi$ע&r+TZ߲ e{gYD.<'Vx"Dcfwog)LLǨf1Y5,;,yU]XeFxJ!LhŕzG)LAZι(<9*|t,iVScUN,fL'%(,'.lX.&G ܖ #LDX@ʶ,=?!nm$~ շeb z`FJfE}=kz? ಻ݹΟ Uԇoç|'ʸJك E^nΰͰƐ>1ڪv En-t*LZ)kQI4ijJn85ťD1FARa]4ᓕQCpLИpmK" k,Ɨ5-1x_ xZό㹭'~~N=O׆=;(˗*WI;?U?S7q̟*Tx>!S 3[ˁiN;ta4ձOLg ?e?AͧǭXnrk? kG `KZ MTzn-r \Wqh2US$8_ǸjC+}W`tcթc,mXHs*ޖ"}R3<䌻nG2t$Dɥp|A̛\ߺzA|p𰖡v]pK|ϫϺTۉ$^$Yn>)V/_X2? *07(Ǩh'[e ,> )?;Y"afTDBFK#h CRIQ"[ uV."ePRZ2kaGqahH@/E%aOl&+;U.luLZkWݬbPL~fN1mi5j' }Awܿvgvhq__ЃS 9ؓ-]֛ŪlrXv8i[vDݨ?r\8Tڀ~ <}>'POŢk0[LX&6T2\ lwLDt@PYTMgTW2Ke Ckdhz$^Te.<(%<&\{m/  ֭޺핾=ҶXdVɭ&,ڴc0S2+R M  ea !J )J-K.Y0=u)[rh [E=bFKUҊ bKiG^FdO8Pad'8GYIILXX/[C}/pϾ|@Ym^ ѣҬ67u q1F3!@N,ichV85jw*Voк!Klr.[D+Eb9a 2@,=7Ȁ'CcX5HhOtݴkCa)0KZXQR &V0춸d!\wݸv{o߾0u?}x?{N(߾»wBYi++ֵʈM90ˮgrƒ 4.z gs3yd)('bjQel )P8T:V&kK5DZB`SNDdzDJM[}ǖ/x Al~"oO|BuJ/EGZYKu~n ?xtԩq ӭrzSQXfzG^("c/WOɡj"}A-`nFZi=M7!dN '7 ajmB )~x7(pڜ'}A?#pź 9R<q.z6P)$bUma +~ 6 |}bsR{Z5S`eق;EcI14>TVd-X%U\YZL 9zi'<5n޺/CGO~>nkV{[LV2-<=ؤqV:4=p.=d'$B"Fx̦㖴C:! i@WDʸɠ#\/Wdo.d*Y<9rI}Z%y8ie- ΈS4AsS HV`Z"5z-]E0&8 ɅVjş+}E 'փǸ~Q Xn< G;{ IxVGC_› Q<Ŀ'd(% Ӏ~u/N_>Eڸ:_IŞֻ`>簺^A/G'Vp:1析NMqoojg)}"a{Q'Qv)J1sFYx@&^5[mgkO\n"]0{Nayrvp1?Fą+*},b<*̇e2ra-(VƏk&3Ҟ2@IDATGY(}_O o|L<ܓUnNN-褬xX:"r)H"Ut4~TjSAiނ߭؇~a8?{_ d}x8i,߼~]BM:5BĪa.vJ$s3&iM3k̍>KM/ͦ'+7pNA$OD#?}m-/ڗxqA<]L6Iꌷ<Nh2cID%&|J4#n/쳮 I 5:Seԙ5o/[1}Žþ?Bo2ұ@ƱTg\̔ Wg:/{wckGc+/ծSIup~Fvn>8dS$֌Rbu2hϖ[%݊|xG(,/wŧkZ6j KO@J $Pa%uo]ɔ&ۢg܇Ww2s25G~OG39}x8euq9L2w᧳+N0 $'b+-QH[Wҕ&pzJdY}mm5/wyF6Ӆ@ƍ{$DLK1Oہۖ~V hCPDOg"]"L,-}`#69pj 9cj:MӈI-3. o&bU^y979Ḽ7бvF3+)P6oZZui=^xas[]Oj0ZXNʲZS=t (#{ada> Dx=$Ȱ\,c,zH`LR $bhtb||xN46\ŽuV7߸ ċs~q/>?'ӻkzw~[ 8F\sD~y*r U_"}^G7Kgvk~%e)vӜ4(Z w8,@=|B^DY9,~ a"4Tslu1E'*HHV0$*QV\R$/8.`x(_|A{m9%)y )1Ic {KKbvPq}DUml?2Np|V8\pܼdz[NׯQ{q0I,)h@9*oRT[T=AeOv,ħW}uDȬ9tsGA> `B}aEb#$iaj=Cmv[$~:gFD7]L(7 "V&W7"^8'$󏞜];5|{iN ǟOCIe0)) {ұx~Ydv} G|Կ&fvM 'qeAG-4=%5j'٥ёv=c^0'g%~tFENDT#Pr[XK^mZBخ*KLm"Wn]; .ڭwm©%T/ĕ2~`gb1ͺunS29WǤK{"}_ˍX\euG0*^BdY5Z^[Ӎk3v1+\G޹E?8ϿZVvӑ\˟&j;i5HV Ay&j ^\ `p# Mఀ&3KjMQG,,$9Vu+d鈁v\kKQ*+tI u߁7n3#~zIb]V9I\ EAD-V }Sc0 XfrfH>qU p/O%Iǽ~-~4E\=-x/֭p{ .OwZeu&:O›Wo Rd[2/Ӆc"F6ոWFi3AI&ІbJ. SʲtV9=N -5'YY@?&jilkYzػmz)قs3'6ɆdP.N(/A/_wƒ{+A?},_Wv s3[h`,)&]j;u\*_%ʵf31E㭵_,1UC"( սFnEkG@{jh x!rX9qQ2J ;MJvLdBqa*1~B$= nr::ũiqgQ/? pj7 x݃K[d6"!&qЬ``͜&+C*Tu)Dq,,~%l zEr@'\,ǽP$.֞&nݳeQ$r Y!wZKE(`N9}IdZITphFeJOVEI~ .ԖR}j,YN42 8nQ1HW-TG_u@tgwINJ(/nxm1Uj\s05x ,7put^?Vg9|N7͉ɃҘ mx&&~;BUtPG^ڀm p}F8Y$p1^#CNS`'LÁi$Ěcljo{|It|,x=46?e|7Ӧ1/ޅVoE_ [˩fЈuUM͛ N?^pW<}q4tfרf75*^}bi K4k}NAM&eWdN0IR$CK~GJ uA0(G9`>'MEgQPˈ cfso^;K-g=?ɇ/Zn] 1opZm9$WvYF*4KeA~ȅp4{O<mIڮY8ok8V*#eLFJOB5Nrlκ80)UIPd\mţ F𵮁cu' w? ]|wor#XTa^c(V„5Izwadn`h=4;獹08^JTtOvUV`Qb*Dr p* 793 A/ e\paV,$`lU.PIzC6ZowpHxA4Yd0!>F~S|c سC cN[LXbI̒;Vvمow.e| `3 0FQ cTramusW9& sq@]Sim)  OW^dgik@͡\ro8@@儱uEN\:5/̍3 H:޶cg7':[kܾRakq`a'}^@P. P%x1FjD"=~3XY#'3bN9!Xd)25LH@Ҵީ,빔N}$rzὒOB;'=l0/s5AplŞ4zJ^e5SxȒ7!eDٺ[l(7-#Ѥ#A'N/'A{Oi#8 G~Hwt74Ob XCD`hǶҸ9\y(ܿ{?\هߝ'Ռ]xp"{[#ڨ Q̱Ղ/ o^!/0uu[ʱRH"a!k)Y;ITFx9GVlW \ + jJђX(I&5uYHF9 MgjBbBd* 9, EO>zT.MG4Dls\wtVv욠h;dR.^ZZwehQs:;E)]0Lv+m9=ۆFWGqgQ0x PGېކ7BM;\3W!Wk9[F'0zN"4+oyA*ԚN< Đm qIgHeMDuĖw/eHrXR; cN\ Kï]~֫?(zz C|:wOq+[|.úpI{Rx+sW!]X`eu tWu嵘Gx&stV?ZCQ)\a/yO~ҥ0WITVg b%rn^_IN=% ʩ.#'+ICr(7Ev9Aƌ$WHMf՜*IK)e ։0EO*BLsWh.+6V%vŊ[[=%pC kp|p f>7OXj-$YIpmQĹg8teY2i(f8 cd d&"@ dd-F?G؀ ]%S` 5*ȓag{q_N:+rbJ E#s8K̈-wzG"<>y O矻w'vVxve.hVqBeUϡ\f@ #"BT6MƭXDEXk~$z|-y VF0C{"6GPkk3pɆtmė0q݀#q옫@HX+ڋMͪcLӍ0'K\*bZ`_*L,F9Uv5Ǽ 8'oN 6q^n̓]xH7avǣܩ c8?R΂D +7E2mxK&^R*G9XQW0jW ")< wt6uxfLN 5L" 6(ѕ?E͚+ @9#uqؑNL9WME;4ߤ]*̔X慕Y.F|={ n ːRaɗ?+F}.Cݻ[a.[Py87Jf!\j1!@XeJ6HKdH#NslY /Ӭ?iCZb_ye!.Ixp+pS0B΂ =1jɄ5lw'zqQZrH%D'6VQٺ+94(\%[ OUM(]3ff:VUaºՎ34CAxaD^y|> =kڥpu(UKňkZcJm8 /;)Y.GG5|CiKSe=pJƺR3U_W<1!hqC7  -.D*M%/dU7xp& ں}cUɃ9EˮK uK@+}@WrҼ G6-JA~u/FV؁޹q7w"ltuūß}ox;)yԫq|ڽ=X: (G~*WipQTvbn-W e̯دSSKP1rw7ߜ~se<Ix?8g%#'UmHNq\rP<(Hg-Z4GGϴ8%L>HЍ8 9tƏguD8"qxmctIS먆*Ī7ft=+ 9z!Z{Nk+/M,1y7d;ѯM֣Nn u܄EhKp㩘,PeaFѶޘUeC'ʄL:nHC΀h3B 9م],}2O6|@GuKo:w9Jj vƝ=Ój?z,Qӹmx^Ps0(N6⍡C:314Mm 7Rl#%}WEM 5&Q[g?@5S"@(|#sJl? AMZўH*&5u A1Be2tJ uv88dic2Q?j2,JDwCtߣo  2-[>FGc^wiZJe]ԋ`cdwIݸW7D;Tۆ/Y.^ 2?ņ8YpC୨YYg3cM~^iX-0 l/a@LtP&no*՘6"6xL*"@("F\˸r`s :o'@Ms<@m %Ix<.loG󚄇d;x﹬Ew.<z>@\K$8lrguArm+&o|LNHl̔gGV0y(n>UX WTixxЬs"tcZdu9~Ts`2a|n߱I|7wqrLDhFt (uf ĺ7ăs)ގrwr NrLחў:,~Q /GADX-7)0w>6+[ 7ņ3-.O{:b:̓obe^nwf,ءO'G8s%-zd(2_UeJ8:%'o)5k} l᲍@KbViM!piɟ`p^/:heo?|;TxO>~_<~!Lk[%-L?<52yEnw@S%>:Hxkһ!2GN }Բ1\3Y q>T20 O -_l{"t:"BlB)~H YHOC%OS,W8RKpPW;1<Ƅ8*,N>=jui>aЕIħvuAҺ+.t[h2Fvxoؾ| 8t;,sx^W}.) o[=K΅ytݵA3}YV<2]-mrx!M"Xv/(NU&)e'd<SRE\rI8m ;Дdn,1L/c6(s ,!ńߺ~%g.Wg+puopI8u+٧砳f(L1YN'ra>bV'^ҮkԸmٯ%/``*wVi)mI,x%:$'s%jwcqxhnir Ii G' MvBax-(c4X.'sxaԫ~VvO9.} gO›Sz-?u+kɃnZ=OZjq4O8\qX%l%M#2p )򊙥쑥ԳX{E;$YfUջvxӪC,"(,`kF{g[Et&qcaB013;^\I8Q5H `5"!6 m1޼Qoh-9bLum23lva˫])ЊS@QnW' ̘y}x<R˱3#ٯ(;}Z+om5w7Yc.%]qOXl`@,h V|=\^ĭP0Q/j db,Y0! + k4>h!?/ gvK,XK6Db7e8?PpR&M1LR[&qS:şpfի>OumE zhl[-t„&cYӺ7]6!U \Q[]ze[cF6D"fֲ蚲#Dl_ژ甄 ɜ)n2!ȰML2Cu%pYGB-"PYrqQg_1 GvZoq+bCr{G^$Hox7 1%U[~Z元t#VeP)Tm㊣ynjq*J \A3=)I3s.qj ENAUQBLl8m؜aotS"呤3'`Q(\P[{mhnʐ K_cP*\FfuED'w.^lۄkۺx/×OLaK J7=mfĀ{Kn&"`\oв <-,߽cnnmgyC8Un n3 Q@YmHsNߦ<](s &lxiEOTw}kdkgcR:Nf`מtﳼI&-gx ䷮Y/7AQ̏ f4x27m/vdh b1d˸+JZǯ0\W2VbK3E\ʼn/f9/[MP߀LɜR@֍-`|&R2,cW\0U\`@†=#]t'|͓\ײ= `v6V0wvjI+JHoK\*w|Ɵu_xQēe Z"= Ū"%+F'/ʏ+ɗvB #_eb``k֤aCJXEwI,%RXְz`dMCDsPeDz`TRe} pp]8鳣S \}s@s#͙%ij,ٔ09KU'`q@)T#>ov+Lx x4vic􅺕EMDƲ S> GgFhZ43MEeV#/n&$I{f&|Wpsd#c3'BLDE> ,R[ ֱ{nus%63ў9faT}y(vn.~~Sx E7𥊿`ɏZ#T))3onm1ӁJWڅ^=Y@ũG{T^bzP$jUvcA?/52 9"*ƀ%,5Ydǡw+|(9hl RQ [5k[a$PG0] g&O. 0,N'?ŗ [;߃+SA7gm*?O9$ Bџ1WTz(i;:YSMYV% g1q %NE3d(>Ǻeu䢧S_gVq{f{9Z*E̒O䉱eYGZsɡl~gg˧ "e1/ (cx?>DL±MMVs'57;s'C Pg\5</f#7f 6 5:H;;p,qmwV{ clgy,Zɳ9{ڪ}Z&]oP78?{X^/o((ōS%X@c[r_On8}}#Wˉ[~5烍gvC`a6Jӿp􃆙g&6!sV0|l0tNZ##ųi[߯^ >'Ok8G=]ن+lZ;wBx}>TCz n~ZۅNjI7BbHF{~pXN32WR! vy%\R)"WVd^B:Oa%%o&c:){uf᎔h_kޅ'W_/_y嗯pvx`i{|}͠qtTc01댛[ēVIaG͘FT/ "*T-Hot&ț |6W x:r pQ(HKQQ<\ ' 6y,7{/drALIK׹6v4‹81ٔskܞڑ;Yko ܩ1)N-@#U\E{i``(MH3G?&ZH OLeq+;Ż v7<<\>Mٌ=ۖ;}luK4Z)_nxH'@EJ,D\jZD氈ZٰVg?$ҭqehh8m0S\ˇDVpʢ w+[&omΑjHApy@S)ctTǏ.GO6PͼGp|~;6<k!<ԏ}pk}S:p͉Қ@3.qxޕONoq:ן xOm,~L2ʨH?( Rơ0kg͊1kntDd@ ER$V)粥Ţ(@t{v;J0)"RKW=3w7nt.ʵTJ)nĉ"yGI ppX2rˋa::u ͌^SB5E-Lx%w0^K&=>r=fNXyAGƏ+ 9 kwetw{z|/Wy'nT6]v] u|wVW;FD_=TVhICȈH'T@ INt!a;\7}fT+27VLD_^0jkІ ':BUw2 socNW([ X5, T=x䣏ݗ i_-7˿5 H{?nKX.>2sRe +2WHNgj>~n2*$ʠ@\ʁ܊BHjqRx3Jou  $ӵbt'`O k3Z#%ҭ)W#v-=a!$كr!6YD͡sdySUz8Gpr]*1aWٲ6d}(bn\})ԍ ַܣ,Y@;młϊqLd_2ʛVtmV4L|AUEv#˚.b-!fpcANa*@̅dNU߿G]߅7v:d_3ߗ>2ҍܯRUo@WߋNQEF/QZk6Aj4&zZ3!OL=3uk¯_ cYB]L\⒯hfpGz%^'}IR"O吞2ümWv~d:s7jPQI:6K'޲khXcoh@9 `^#΍9%Qel?}pVFž6wR\&s¦zcK1ܸ&:L]Ņ/YƠ׳|' c_XPuQ@RZJ]eܷrذr]U9~H:+o75P-> QPm,4, j,3UdqMLpk .9]-,-:5}!)PV$LTf[{Z޹_Ҕ_a^¯x({JCz8%/hv&V*OW1Ix;2\Et,O㄄1X꘠R6{OX֛ޢ[2@ڤf鈤2!FS<:u mf@u-ߑā"yMѪv .V̧?z&/tCl,c@rHjlJyoʏi[\J$j'cD5zYs[(RŊi1>M6󂢬N/DEc~|2nz:piG%qW}|۝[7>8gL/Olc;̊lf^o `Qšm!8 מƋ|fݿ/<7yҿwH JG2;8j ?% O?8"` U:"|log4QSlvIj,NEd?~- r]m׿,oUy>| !XlpU* 3H- G+isL!:cI&TķW1`ѳV;|@нTc`[QV` rՓGʵ^wR+Z8ʲvbue{cnSR&M&܉za-8yMԹSKk:Y`; >H:|Wx@8w cq:hkD&J vx/u{7C~9=96;BXy?k_(RsF{S4]:~2a,ݸҞLlvB2F 7MXt=1du7;3|{X/Ĝt+BxPSUAoJ+32Xl%49lu;=-m= A*vI5RP̈sesqw~*ҙeozbM?s^Ib %s.@Q*AGyN青zKI j?DKͅI䪦[:I~ٝXR!3duMjy!pW>yӽ{d^xw2jw[5ӥsWk)ލqcMb婢<% ezhL6Z; 8QG TcX'fBՌo|3}<>T\QS41We`VLF~V ^DY&Aٲ9ht>T.֪wT~?x}0| o/Z}?mT~,=y_7[Uˋxć)jn̶,—ԏmooxtK&zdZ$;jNz-Ϩ ̘,6HHL+&S 4X+/Uu弧Ef|bn 0Bi*΋^zm1vAC%GmJ.!aVhNg҇j=]008V5iz6.3ݪyZ9]N=57ߗ[\3|ct=XmYь~o-Wݭ{Zt}7:w۠JdEWdY;zd[>=Zc:(r(MgeD,!nLp撛rfEɍ<6Vʼn16pL/+ a[1jh+n8#wJ\J||Sx/]_-_|ox.ˇqG], !]6/2D9|cn=[S1WӜMiN>ܸ TN0 #6]b$me;RR_ۊժV9H5!+{R^kM8}QnG Sx1?  j  0ܚU/2eWjޕEuFQ$ ZZ~_|K/JvAU2 xP8f<pGb:S\[i>p_,~S~L3CIV~}ojk^!59h:1O4":N˨JQ@p8BES9I8:AGD 퍷I:뮺J+1pCŇ>- =.ZJϐ]{a7TֻhaUX!ѹy96 4š{a؃@LO>xy|m=+X.N L=`~ 8ڊ3͑ ݬ>>@eO#eiLy9a.8qo8XF>o~'妡 ExZm/(7ڌʔ}.배2lX+RiuHg*4t܂\FAJ<,ٚk OP˦R>.1cobn E= 7r~knpI響Cܠ{y#orj ʝ0Xp.^~ 9y Ś-{?}yݔ2^u9?._5\VCv==zJNQζкv["u,f jfN Io/ ) 2- zW*PVE؋υxƒZCѮPl^dYbI^j6vqVc*ʅ1*у#iXtPHS/__/{{Ç7Oש'DZfEŚla x]S:'Uwh $6HXnD xA ^w|)յDiۨP޾. ޼]bcrQ-X=wISc |X"2Qy\֏ d ho~1"7ژg^Z2p5w}˝x)26tkeM׏LohVoef} s}wKb-#M}uY)q]?}nw晏=fyf`5Le cMN׳ak%&S"n$as4ħQKV"_Tl  gY;949T=pZuXśE9Ī}p-;G.Y>ո n4/ C+ 5O5!EGLX$ž%*c ],-c;o/_u8ӕ/;m?K|RGGZ†)V ڳc)_ :;Cz3gfI6$+82Z#tYȭ<{#4׌gtbH6-}%[}`7LˉU 8`hkw&H*OU?Yv#)8͎}\B(@n>5MQ<}P *cȖ{C:5[U\wh{ʸRBC8PڜQsxe *ފ= !ۤ{;vݪf/0ٍseηn"أ[O2{O7G~θ]7u|mK^#K7M'xw]I!kɛVb19&Ɖ%y܍X{ }rL_~yeGǚXN-\c -, Vu8r;Ls@g nʙXLt: RћP6\OQW&m_-7oL7K_?|\~gy$_Bm 9L !pBנ B&~s Q{\M{;]%pĪY+M#/z5 0 դڳ6mQKQj2UdZ3`sC[gqrv/ceR E!*X;ZPTc$aS>B"G䥞'xqյXU{TLD'}qb 헞]҅e{wIW&K#˙A[~񍰩TՕ0vl> GȒs#Wښב"O(/1'JO[\m[+c''5k|Bd!O8~t^ШF*HU(6v=̵B .%Igܺ)?s6| 7zewp߿ks !\a9ؽb7\MOTo_BU;i[aS%{`T ȇF#W'l͗Lk N.-vmfz5l37L.Q$~䚆o!V/ku,ZҌ `i֕p;}6%8eƓg0}T5Qho9IOXk 7PIõphT(ϳ'zNFB9?9޺??n+.(OΕQ%rB&XՈK/쩴m^ݙ5N}8.p;@q+I *۹~^EOٴ hL…d_"PL^\h]z1R =KT!A3SSۊy*i!D+/>l xo__WEȧ|S~>'S!ŇMd**j6a/(Y|ꏏ198q;[,s?LCdCJMAL j\G[Pk3I]5/gk*i'e#4%@[-78*<+5mL!"@Qۘ@UueRlA얭8Z1O3{DhVc6?~5Fdc֑:}3Ff?{ zGz/{X'OoObR_01#4_\aH2qAa{Z&p^.O4x*ޘ>xl")ڢ{c@v[ok,1[~/xp0uXfL:"+/cb.T1ķ|L4$EIGvěoMXnY7~Mj5X p|Ѝ .~?ݻg[vf-f2lDi7(+1ہ1Ѽp_+\d U7DӖyu^:ƐGA.؈U7µtTH!d6jJ绞vmƷgL5R5fa%89EcZc^U_:EF?(Y[fԒkpʼo[ 1N5(?6:"NזYs΁ TFKi;H#7ɜ8d77own,wn_rv\f5L`W,NAp]d S=RUDZ ]EidUy!4y6b3؝BܛՇ2-\*XzlcRa +2(69d. w:ZZyv:eՙkU \QU(:!ۄT S!H)#C0H_އ?Y>? x=W򯿹Oܒ+[XᶖNya"g N9>DFx]:JRX[g5zed;u_i\pbj>D* k|)67 Qz]smV.6Nyt@dq.C4۪yv]c/Ax7J>o/+OG:kBTd}m>bG<>km)nKsnj^,4 _ ?|jj{-S;euP޹:Oi<NĊ M>ZLwT/@i榚8HBCN$|`^ ysv9;nFQz 0G>̎ՎܗQB;lX|LM#dr(hAhf'~*@"UD9knߺ%_B^ < ÒĹ7eSl(n684wMZ=HXe?kUZg j6w鱱1E;Tn7>D{ckRϦTIլR֤UOFplxpT*lQﴯF8疎"rNƦTkCA"X÷xۿuOBLJa8&Q >b ld~v[hs:\B{p?on' `}fe㉁/Oikk5o.( ;Cece]aUOU)!mYXdPhL6/$/%Gܰu)5퐃x]mc~v0 bAdsc d=\_m z6~>eG=:N|Yχy|L[W-\8#8#}vȩq׷эIW&NjwS}N:f=ًY hG1,Al`V.䴻}搿"uw)iE[~:V<:0d>I"P3"wa ! jJ罗_ n@։Ofż+:ȎAFj}jc0oKY߮2={@0kC -Q'^E_ 2U/.hhȨ' ;;J&:+@$/uRṉ(FhDfFA}?Ծh,O\٢+b}㰩XJS=&1b1NA7}ZqJc.Y1}|y| 铂֫qm]>Z-ۤ3yl;$h0;6^:dǒ$ M1P%%M@:յ#^-NlfHv3 [ǘ &+2ٽc3|NMnұz;$@O͎5Wgk1oŜ] ڹ\^w1-Ł5\/ µԧ i~.D @Lmk5 -/zG@M '0KSu&A4s:Gǎ[ nU#8"6f }|y?ߕBru2>!el?B|pg!'y\IvvMKG 6yOSA*EAO^9b KSv^to{}%BHYb5Ta~!*ς rvRQb3bްE0ڹhm}ukʤR0@`ZߴuBio {J>iC-vf/9V8tiU-CK߾XwUG/)y-t Xr3wߑA5_o w9Ue[f,+Pafgi'G֮ .!FM?8u7g>2|:S]mcjBK :"7:NfSS7[Y TvEVW$="[MOeW oDkDU6HC׉Yae-o%K>08{,!y }B)KFԡz/{'t|{xVxM79yA+ >ww˵RSZNOӒiׂdk\r;9D9>'w_QYc>0p|9*>QrEG6VP0J|B1d$WtzAeNG+^Tte*Ϻ`tЙ&TùϚXOX42h{3hqP.?V[u|r}S~9GInL:Xx<Ǘ0O?T~LkwvyͰso lwQhͥ1E5.hzD3g$`}VWK/]`3ѣ) \om Xouf˕>^ƺqpjzb2[ PQ9Pɖu傂X.](KlVK#pmyo_j;TYG]'A-pQ]䗬hM|H;mk+ɋEכq*s[rs=K2d{8T3̺k?C k)~ٸr~^va鲬Z+G?ZT!/UƷ <;uSs&4c{ n 3w6~7\f՗$Z)(hhC5722pntb:@ϊ w_>c*X_|rُis|($ݺDۡ55F((8h_E`uV=V^!TxHUutoeCGe-B po} 1+Ըy9Mϩ\ARqU5G#@)[ɧx|\1NgЦζ|vLmvKĬ]@1}xqPɽ֭}sB֜eL}lKn ?_˳]_^CE„;|M!A6PEYg!d8v$os ͙0&|ɩJU?S4)F1pO0*>EQiQF %ߵ'.52;†b%ȧVhV_T|`U=WOxO>\/n]r XݻKn.&{L A݂6g/[:6tH5lLFϮ;wBan-v5X_]rɷ~~).wf$#O&P?|=sڇÿ=+X1%xx ,~n&|Ӛ.XZk};}N3H1% ĭ$NP;n6274ٖT;M9#qx)'3"uXl_Vp#Y*t>UY:u&Z1xu,>psΗ<[7Y^lzlϩ,?y,MWdrmw>Z=i\ô\t*iԡR3 Z3w k55ԆA@@~>)OjۀnY뚾=Ӌon;icށ~=6U0Iqpl-[:)r_;'֗QO Yuǧ&ZD`c57ea,)C:xIMwƮ (mI!gۙ/C,gщ'iꪂZ Ub؃;KLcWЯUJb~ &n}ym؞.^6qvoNќJ$Sd> e,ʵԝ0aPBwӯBޕ%$8s8Z?d2>e5]f6;ʻ!>)DijyukzL :ZDQӛo@1>d_gĵSG{<$C)TޙQ` hEWi_VTPÂR7NmwD}d?_qu;=$YMp!{Gd {hv92cGKD 홠JH[f P)]h5%x]?&@ /Yek5is^jekB[Ae3- b]u2@rJv9[b7zOh*L&ѣfkh$'$0i}&Zi.ribkH.i2ͱG x' ^KaAe59,4 &0ǃxP{1PlsBu@z o0f)ܻRVgy]@f)^} Zi"5! zp1BڠtHR8[%EF>~hsC )Bs/_W|jg'߹Y~'-GWoп>"Sp$o6:;UE?\5$ ) owcIDe,\Ѷ8iwDNO!,wj3-Frhk6i}k䫚GK a[~LMxNOSqrC:sC>ݾ^b=y-hzVV^zGmͼFe7ClMнY?]VdV?-+'Cjow* l75N"~G8nY-^VC"TLW|('.PyhXe6`<bvX|&Y:s[^McQ(UyA>W'k\J"$z Wrr$%}Tl0D%!*Ч밽+!Ùa+ `z]:veiՐxԹY rum;OXk lN"՚d&ZQD29ieI}kSD,=Mc`ƭ)8(o0seIF>StՑѣhcdq݇U?QWh7fD 8FԊ.3׸ZSZb{5=W&- 7&ϧ#97 1;3l7XR*{7zi8zZWk!>92gW eoӣGx;}  1@ܡ9,]hsPDJf԰lگEey}XRQJ~ 5gʗ>> %_$TUhJ\  bf2q|-!䡽P-żv6~ܢE㇏cwHg˳:i f=x)SS:ߦ5KLIv `gu(gָ:9ՍIMo-xgu( 샧oc"&וs\5}ɺUÜeW\1~4[0 28;` eb7p0F.\yĈ)ɬn7PҏK嬫J1`svG|ܺG_$;{覟<ܻ-BҍK1]T_<%lq-E`^U'^^H2k*z]Louk9#+e&to2\FGD!=Yu8yL.Oө.Nz}R"ZCs^$c*^G #5tcSXTzŪ)tδ ؉z⃮ϡޕMfl ]5~}r? e˒3HoC08+O?x_~Gp?:aℤ~dYYr)5ƿ@L<d<ྏtU-M;JvzEFf!eZ9qMp!Q.&RQ|2WT4_lJ jR?-Z DfDpIzflD {=\M|EO>T9ze'cϗ:]&O8]JyE2Np٢wIї1~ {/Ps9iS^מyV}6WG1+}fNC==jW&UGl2^e-40^R2W4glSvl<+3V0{A*Kj$܃I$guNz4ZJ2Sv/9J).&t={Yy?{x/? x s#ΏsGtf:2(QMV 85-Ƃ.X&5U /ǵ< CCJ=c?vJ5cA߲q}4-R?D<?E]"щ3+svHhgoJF/yRO3$tƍMX]yP_JuDگ6H.-ww=@6K-mZa +Ir.uI.soviNanMu*V[ZEfT,:?*[W4"eh[Q+gOI yO lglbt}*J0IB( 8`FhZU+1Fh:e6tBD$,=OqD^;F򻯗? ~ A\j cp"l޾By~+֩cUjT:K_VXW;qGb_zw2&Mzt![˱6ڥ.n燼L(j@rUE'cǂj{`7U<ؤ89;rhz2W¡2n=Wv}V`Xuf1^B!Huy ו<hy=}1WQ4@ĉL*\QZvHi!e'!Ɔ峿||/XuLm Q:QU^mT1YYsne2d0(}y9#MhҩNVE>w١46t^6fq$HkRqpBӊ ظ27.CEl%1^$j΋y!4Z7ZdG*}C ?@DfI.+o+KJ.O3cv@zQg<\ܒ_}>{5j5KCyLUHv\."x(N5.`wQ]mY[+\O'm2-x;_]i|hzRxڟ$vXldݰMW-I?UػyL.< 7PۢS/;Ϗ5ȵ=i>{en31 &6+l)_;ߢc*dt 7fPӬI䲕waY-ʲ9xϴ۱vIy۹lX85A`بh^<j%>kem>TO!b)ZTq\݉5( FyL: V@eGG6V--!7\lod`p.ÿ@~q9싆աO."=4 _&qWuHoP:.6:)<8dț9aR@Bm4GuڪWy~.xAK-Z@nИr)ZjR"8,Imc-5D$e`lu|WiZ\مfظwq̭X- wv; EG Ⱦ031uTJ@_~I6έÅC4? ׅQPtޚu1gWnA/^WA@&K%+j6[F5mTr b!ZӖ?4?itc4toS;Ϧ f -=JQvT0ioBǢ™%{#_.n^/S._}ҧ|'Gw};4CS)4G.>^FW MES;F%Azb i[I`˯ǛsXk5I^D1:|9 rʐΪQUp< 0BljLlrW|8PnZE$.A7BzGr8 iC!zA'P.8+3~`puwںIUoط0. hOh":瞔fz=:։x.\ƺPR7J/Z7^h@ᅛ';c-MIq|D] И,ri43Pbk\ˬ;OO⅓rW/[rP~}upJ꾂;*fϧL9l'n-4|ձTb2atoXMiYqV7) SOfBBbB:ʱ1 |tl5pvx5 bsw'6ӔqbtciC `sГ.%.(أ|~Ovy|offyW'ݺ|pY>'q4?Ǝ&Cs|ť2ങٵUbPc$58b05œQ?GFTu4J1p_j ;FM ߜC1﯀ 9bp\.e2`SehC/.ǵ{I3eܓ8}ko#ī7b 0S]U㟚(iwyW;{S瘞1sPISf 4W\}B-"N47a |;fCE5_UZXjE!?J۰Vc47V<`kFH+?eiQ TYlSkS'HuMU˧~ܽsAs2WW_?[49[.@'ܵek:qUX23:thl鼥HTyWrN3IQfa ;Ulcb;zXb_DDi=돓#l͎<6nPxUr??m Uϓ|DP1%c󉈤} WyCM4TK|Z*i_(W9P`򾂢G;~9fy P__PΞCgf =6OaMJX[1lJa~j!vѨ7^]#ȊmX౫ /$A|מov2i&cu+jƋ6,5Iɫ =#012j[廒2Ц_ÇkًeJ'to/H/λ);\5/s5|ȫ3]ӄ6jX?a JlQ Bfk>vEXίk ȗdZ$[|3^?SocZJjQ<=Kwrݬvl7}4Ta 7r4ă)E~8zZEO];`ӹw-~χu=ׁq[W)aONx@wgY_f xg}ߚ 'c}mHmMyŋ\:Й"hpr>y1@9l#T&"'EbtX|̯g40SuRCΡU=B_섻Rqpq@P(P׍͚7T jh #v:/C͇O.gw > %sOaKcs6wȀ@<3MfIY|(rVQV! h10("ϬiXk:Uc2\LjF)R 7^"GG=UejP*ZVzy3l*&Щb5{=>V=Lj6EQkX9?htD,Vy  O Puꋉ+[1S4/ްN}wjũi׸uZg9teSiI'²f8²h=<p}hr}(PY۞r%/;[JlE"[68/ͣXsо$wR?$01=@->z 9FDVB`WMOJa PXJER(QE;mӗ*XnׇbgIlh3 ̚^H:2Դ9IL}Zj,ZpXbb[MxmFU2nfQ'f;p$.oX'7"3|x7}ߞ -oł} 3G>cO#Ҫl 4Aa6{f(|z>m$M5`3rv.>Dq+㼙êSHRu WAυ;=\UM5{/ĵaB\B"mx:e6Ֆ@eټ(Kں@]0 hV,Dn=;9zȴG%[H$Y2vQ).꓀'٪Ó{~u=#hkȐFnJuiXH'txL#I_\^UD}X֩wG{7G/=xwWE^GB7_,}z9͸>!u kǴ7 it)ʍXhRFvzPf/-({m =9 Qa1~FOpQ4/Qj;󊞜) /f:Я < NY hdz-}݇Y5PeCu'CWo ;ל>l.DU\ +dERwZŐSJ?3W#Lh!JmD'+kgzškA a{^)_fuз=,$ev/"^IK Kc>i~\'D`^yTPQkWdc+$2_r 7||o٥ԣNTZ5;@!; XVGUx\ZQ̫VSm[D.uv!jbhsůcǩ){a,1N֬C}QJfiqCҘFcU}k}Z[e|ig?xܕ?q-f y9gT<ʻF+p=(L|G?9ߣ?BٶLчD߆}*[)jiJ#a7]T2T#aaePiWSS {^ciRti}* iϳ4FDOa bmm#rZ Y~a7m{6֪G9;!TҤS=(ZvfdWľ~k7=d \=%mx]q=3P'e?$X8 :\|3%q?S76qdZ-3=aQtWHp״q#itucMS <މ=u+i"gw| 2;:sTqM囿peytoi+[ZAs6`/gFr6kDSk\*B{Xb猃.[v 7d.|&}r_5o3%A5f@3e~fmF"*jm8@-Ks h2e't$a<[xg}~ػc7w)EqPȪdvP1Z9ᅼO^m8uoEr~+ҽx\j<|I[(=abvz~ǀ#re.u(4l.E4+ \lq؛qE\j ?L^V|⹮{->m(鳷8ظh%.nњI {8S<>FRCJqӏ?ww7/_~Lcw!1،"׃Bt:o:]`_9HyC~UsQ(ǒ#mHzmMl4q:@Dd#z^ñԾb6J1ZR!aSàt q(fU)*B56{Ȓki.CMpYCIaEKai&޿s]#I:)q{{uMWq[q9>[.zoo|ވddžM: 澇%L2 ႅ{U!RZOC n޿bw52ʘifO&g#,b;>9U-R šG׵;չ$gUj75dݼ9Fi=EMiHb$lڢ jq{W?r+2|pʳg/U9OXߑ1.>)п~D'16¯G.djWzC9ΓܬZ`I P%4n-,o%1+Dhk%`k梴a3zstDTfYN)os V\r#3sjg=VA>tWoGAVoԍd ;{}߷AU@5@?C_"V4s&Vr,׮@v]} =[j$JtîgⱽYb?ɖ)m0XI[ AFʈF6|X5,Pqmx@: dJKPYWXk 稕Ҏ7K16cJ*Z@Z,Sc9߼)7>}*?'_.~ۯgޭ7ˇ] 5$df` RjaKE[boC̣ehcj-_Tp[_ UE;+=A'~]]Zu j$~ #6+ e ]Uj'{^-oolmw(*ӈ2nj7f^Z֘>+1HfGC=gzx A@4Aj9쾳!/C"7 n,Lç׎W`g e~rxO_/˴'S,za{,3>qV-N6i8!- A5dgg?k 1`*xe,JTfCfd@Wc.1i8 ]eynBXvq?c4NCpbۯD=ѥ_=8|MB͛.H2+\cunOFAr &YoǦW ˡ\r1EnM }JDڿy OL ) <)RHHTI(ʃ7?Ow)>,60B@h/h`/< .}ߜ={|?8yՖ%e7xy**LI=er9Y18^~6 {i-79 X^v91x3qUL}ǚQF]5vװ]lj"|Ou@*%mx^?FGx[Fg7׃\;,jr\k|j#:h<8I~LL1moח L8;4+w!KrIٲNE9߈ʭ D9 z| ƥo N!{:wN*By{"ŬЏѺ;z9>O)gS_[gU8[yNSw[aq m =57>K|壧I._~ϿU> wO$ZAa&27Nk}.İͺxxB''-Ln㛯qR#,ZXckoq n7kze1_oU;etԌk5NH68d+jנ}o/7vn7:-}3,yyF37M34"NG-n vtAIgG[7#DZ-v)3;tWWג. .@SuWo\7燽n\zs+ꖢ<(D7>jGlMFֿ7Ĺ|W`լLvJZA 2]9IFs^lP`42]3 k%YD9$2N6kTE oe FJY(EK$8 b=AQ*J+Q)IO\(UnnJ1Zvw+I̠|dF4dqF\pCO Ծw(NO?; kD8]?eRQK9ڞin9/JyK œ}Xӿe1tqtT\ۼCczk Z>toK,qtLI#9}aK_SKLQeh`[,lkR"}Z-ҷ ()c)̶1hZ~s@_1YKe7`IvQco&FSPUicRQUW4qR0m /"رn5PUgj^?] ӫ'^Qkz@WI„+8:)UM<'ў1ƴm;ޮ;>fg5Uo3x.0UhI~ݍը e;tVl(AR :ΑI~=4_mԐاTL%gq}gؙ %pPfM M Əc.(yj"N2hĮ`*UB[2G`7*Z) )ю m)U5RbkrYUA]fD5-YE@dNBcXY !aUFVz;*,ܻsoyr=#\nQ<{<\i9$榃٠vUkC|"c+;%ɶ%8} b+R+Ͱk.f(~6&Lvmiĺ]lXM'M+]|Q,iE@mݚ *"5u$3=YMsV[t4/*/CF\ce·Fۺǥ;DeS m&8EY]V&r聓 cgn,rZ-G7nۊZ'vgCN-noȬlڪ*xd3Z4XŹe] tgbR-%% &} MpKlefA(Ӓ#+<2ݔ/p5ɰfqb"kxs/Ksڙ c.tAuө3[CAҟTUuts;A.-Df&nag-7>}K;C<}N3%rØig)ϧQLN8il}Ľ,$׉zSӮ3oF̯m)`򟖡K2QV.MT]FxD#`?bH7 Vp` WSF)I7r4v5sU5A&&mZ$\P܋Mc?~͵g:zby&uwcf/AyUT).=(/e8Rb?`rX˛uu-1X:Cc/aVfܗмde@l`U# SA989_hvW/ |=g0i>MZO#r`i L24)[Iz#daKiI/ L&QW )`k>>94N洖C/O/)~f-b]{3hY.K^d6 ;Kxnyn4"S#b\0}VK];*dzg;M }PgǾMchK}㺟k&cs n k'v0Ƚ.!\&8$?xWo~y~1$1sY˰L0-oV6e%b/ݜ7$%&VG~M/mx|k Dw'edL 6NOd=^Vr[s*D.O2F |[-2: * JAټ(;Z*o~ȶ9<iD,MBI VoNwx%dh҈odJtm#ߚelc+nK; m;ߒЉ04߽ܼIrN{x5_ov}tJ *6O|VX*RZEiO/H$"/hw95RRk I0*aՃf19h FvJ c7vE"B60nG  *%}9+?|[ .jԂRYcDmvHqAj]`Jj;,ʳMƻ i^_؛o1j]֥*^<=EƃU:5cc[bXxQ[(D Gjf״5XWkt3Bjh,E1Sk~mqqRk,{Ne][G?^yD |?Mc޲O2?+Z:>\҇7/OBCw-3c'\uflG^6/qmF7n{6=~<(ϡqV=vm6g;T>.L8Eֳ#$* 8@j@ ml]q[uZt Ly^+淋qs%W@w_5;z/č'Gƍ9)ܨf"OG $LlgyO@f :=Ex4#_$tbft8HVª/27b<:߅f`h@xw +Sz~ ht 7t'wVi NLϴKcȁ%xt?iu&EzJZ}KVjeЁ% Q/ BR# .@ `[8V|+CfWOm*-q}bN˵hdSu\ڙWd|߫jVSM["΃v|%q^{J`U9lH5ǫ:ת5'3'Z+zwС7,zޝ;iؗ)h˺zWg.+̜t%%KفˀR$Q`w07Jbd `KKdM$u앸i4dӨX=]tI}HH6H .Ӎ n_u ;"Lv%2*ם(WGC**m&U(Pڇ>Ʊ7o[$.x;ofyG4Ҿ_/'=)6X.hɧf=X:HѾA1PY4f)4՟։N|k i'K%y$jQLtP)l' }A x!P߶Hp~-m܇Q^OmV#XPW-jFLzu}gZJAۗ<h b)+`_8=ẇyo}ANޓ>$ot.u% a`3j qyfʂP/V d(GAh*rɚBM%#]Ȳ@lUuPn 7^h_70m*L4 ~Eyr&v(ƃz3|AwG!_T)ydhQ ʐR׆$ J:X~G:0u^-|@3þh=Lř4U챊!&|xlY/=%X2kQe).M6A|5Ӧ7PH"Td }ɏQ`FE G5tA'}}~V__xߟyx 7/9j.WմQ{0F.E0S>fWkm7ȦP+eɤKHNyZO^yZT9tՋz\ J^TE_EۭbVDi|Qp[${*mX$ZɦPP.^W2,]*JjTS誨C1:Kا{ǖګjqz3%NY*t})8&4b?þFџ7?˃btI+M۷u>%[b^Y$V O&e!uY*޶dG 3ё\J|0٠[tzF9Ǹ<,<=O]fAȁ/+ \@ea fz.JAi#qW=ΜpQ~'TA L$];bAܠ߫]'"M2 xUj|B>#߉{nJ:jAUY WIqMjT~F j?cvVmVf{7`p1|9_cr )Dwzs"W+3;k).޼[3 K.K=0EEnj@b؊[9Y!O9r[S:ܒh;^l*.8 !ҞPiZJQtuQ6fsŹKfslҮkBk͵A+&bñ+/6J=v+*lȕG:P'gS%,#uGg)5%KcmyØr%#VkXơ9.on>,W۔a+˩cLMY(ӦhٯfD.eM ɪpi}K>Z9h 0T?. W%SMT%iX)'}eN^G%hS i' 8f03}=G} {tL%v) m'^,ma"I8XpΕ|/>_H6̱sa-Iy/vȄ#û#DGjbǖ_ue@;ZPKfӲyFAH kS~? v?%pNdzgk3. ]̜:GRd3Ht `!31@K^ϢxG >F^Qt _hFvGK s4ZQ\mʂZ闪ZvNOCAH4Q4่"㐅$QCy5kȯBCx.xU`CGZo n2dNl(ZUΕ3ٷP&l " f)zGa@i䱁'{[p sq8p7I]r>⍑t fZKmY-ȲحSUՈTUU#5$ͱi bɪZTq*XY <:&to^Tذh 0U #}7>JДajჺpUbX nЪAXqnkN8Cʱv'S0>s2 N $X[7 `2qҶ*U9a|O ⼦gY8[mz &N=eSn&nrg_og/#Mژ$n@ײ(-6$uΨ(-~Т8hxO0HWIgs%RZ9B]s+ȈWaA[ixIg( ev3ИBe'L~׈LCtD-Al,P0#'pFft6ܧ?CǏ/Wdy?^nM~dFfƼK $r4h' Dc| u66:7Q elm8vKs9{Zvy`?{ 2 ѕT}p47sl9A}; xi=8V.$%ֲtW/8nnMrnIG{!Z.3KlIhR%-bk x]Ý4#7|-g'}9y&潻wqu`â23quԹla6XOSC%`ڛ*+żb4_ -j,P6ŤTp@j%4* Qߴı 'necŢqXr![#{(LKVγ$ZM{~4!x> DO5HѓAֿڄ-739Ji] ?->f?ᇧ?O~CzoSz\nNoF9aE3iW"7E'pv#9AZV8[ ;,rCW#ڢ^9Fzw8F6۷hN骏e-WdRS2hٷjڶV1p=.M')$F}[b2~/` 9 \sW`ҥrSqrK{L.E&2x(kӳ'SM/OS';w**߯h{mk>[.BKTNm"6quͫQ]NeUn.hL"ǐ% &Pc4`BIΥQ [ l# B48Ŀz3咚eTOHP]\XYOD=)yAADCuEp2-5\V~֏ڙ:j!Thlla`kǩJJX`˖w +4i7uGGen ރ Ց uRKg FLhQo]C]6t*AF\e` N )-Jc~{XQ ,Jؒ};_ɨ!tJ!0.f85skK xsM#8;@m g|ĜZ Պ?_GU_lI9v^I'7t6钛M4QKl!nF0b` Uk[XS5LM mDzI$r݀Vdbe[k+!NոLVgGz`z$hzhH_p[P$ )*(mwgo.D;ߞ`Ͱ 3sJo^ q-_Pɜw(O޺?x(a<'p(,7.o{KeB>Q YҹODuI+rJ,g_V⍹61 JRZ`Fuh>Gɸ<g@>d0d, cȫU\dǎvc4fꤕbH/*6? +Vfkẍ́G!myoKcp=v/>tL ^|wLS'|qq.[#[meY&jMcd [TR߷xk48,Yͨ~G6\o]TIjke& _o9K k*I{:ͱDM#]p~hk>-Szh6.*qdY@hS.FXNtlƒId<8v>~XΟa\-ǹ2n|;tҰf2mעЀxSir(ka:Ӵ܊8V#ˡrEq\Hu X,8%zඍXdjs3:x`+TbHA@FJ:jiYpAfqk1Ւ8QV %2gؼ52ccXArm+_Xk7hιU?P-@0"7wGy_3dE̺996fxz:/UۀN|7zsE]@kk+y!&ԝV &ՌmdE;vkSK*+WZlZxp}e 'ZGpL*o4/t1`QOo-G˾=gzc~]j ,w[f]IbD;{?|^^LRT7{wsѺ1)l=O q^+$x@]P@exjeXl`VI;8r-iM3*^_P{P8 ?+pQ0~Q@>5F6%E(*8oG1o; `zhC5&@cL&SЙձzuuF|GuvLd'&iPWR)W]Gg2<zMW:5QpS;xO[h~7Ӥ<.S<)KEaM 9 W"h_L&1vh_'".sjTe席c Xj*%%ӏjR|~{6/+ d:mky7`:hmH<#q?P󚄲S?:VQcC{Z$_7/iZp@^k.DdkQ3;F=(G@uަ(Cʬ#!gs߫Z7UvbuW;̄nF_ǁJΕJ,VХ( `'.Y"_ !._%j5IY;?P5j5Ki|xEH"D*a,AEڿUܿQ{zP)8`tTZ=jAHG0,˦ gMN&T VYk\pV<˱ ;˟G>ו^>{H^&\'z"^x{χ#4T9 9,`ņ3:Wnq2}[2^dDH†όW*EB?ƞjVh$d#ļW/nj0iA1?SF];/0 rZ$2 n LT=ClX~ś$,aasg9X;o.3g7<6nzy'.}kvpуKerBgDv' ?{+]V\¯@?.-5Rm9samEMeF\N&㩩8!~C$ YArq9/`WIɜ50M\҇֝gVo+Bh=/kO Șm¨lxP1,+y6҄uu\ 95u:pD+wmR!HIv~6H[7n-wgBB}Hʩ; m UͬRۗkѣi'tXl,ߕt]7w09vT]M9c(ưfU֎\*"QY X/;$L7n l BGkOOиlP(Z`5ɂB.+eh6tRl;+.SaiP).`g}[M.êY pH?gsSz5Xe]q~]Զ 8[3]9r}˿>a=0c,2\gjxJ`P.Si*Gɯ]I¯DRnUi4S*N-N ';*+ђ|I|ii*i4`!o,#G]`cW5$E*v?|K9lI:< :WST'7Oz'W{isEɽeK@`X.{wQmƏ1 X`1' ]kw\AdR%T{'v LS Ǡ= 8"D]EtwQy*@x@Mc}OLPbtM b !20I6fa-Y/AY7)v-3&nC"6X64iǜ8VGO$'y^x~^7>9K\7:$'y\>ί vcuK˵M8Ҏr h_8l1CJ|=q9ӲmcOխV+*— YתGFTM.xHƬ}iSY\|Ae:dHAD]~.&2J _ UygQ/}Xl%M:j Ǔ=/spnc#Xz1C,eܝ9@XE^G+ɍܪ~?UNpÕP$ѕ?E8D@̖qtk'8Jq&;n\L%ͻɬ3[Se[>tboqT'f77,r`r>n|ы_]|ߤ۷oMك_َ }~]!WgcЭ4pQg;6ضri"5!(TF5DMs[ꫥkMKVbUIh݁ҊFNA ¶F#uT1X\"̠CدePx FbR` $*OxJ=0N[,yuH'is$a~RF+5 @:w[JlX:O}o1` ?wmo~ث}v=G/쿍8n&q\`椿_JNpj°G=n[[U_r=ZyܷTM'(WŹ{bpD3;?eƄ $M^ ̉ jk f v_Y.2"% 5Η'iZBhDI Sl~0"0m&X_ƒ]r6*b:a1Nk?_.4(o|m:ap FDҡS)ɿD<hY$Ycvtr{@k.ze3Kϡ6rn e·%k$@ˢYMA٩Ii,Ex:P$ݏ hQ" r7O0#7x% {>[xcNRLmRӾKOM:lUճN x2@؞搘SݵtܱZ1;6h=Ҽ sFɦ=qG] a$֑#J,wsH M!=ЭE*{@F5WMګAI4Mz p.*OoΕk6P)dshsȾu~G_y90Wr_[pPӛ^PuXyRƬ-I}'^{: xrE6}QrΝ΀1Үh͊ %޿pOGJ잽@7*o \5 SQuW|&ѕZZP,fF$NUU¯&-?j@i! `xx %uE>ZNTQsP}h8Fy6 U<[A$1jW7#mEUL!]XW14#VnUJ +ʩ99U/:6Ȋe|ۗtf~:,nZ%7̀ka gjyI%|SZqHB1*c(..v4ʰIT[g2( B+HjTP?ˢw-p'XRĄԧe+>R!`!3N3b7؅~`FҊbD/1 [Hp*oU"ŢF~j&U+ ߗ6E\}bsqYϝ ~pJ 3 ֦jĴ1%j=uqnb#yq* d˯U<2N]MjB`)1ǟJzشIPeɻMk-ǀAqBpP)svbu &Z"Je[n%<- ;e\>YH(Ti㱛϶fN+S3.u/rxffO4ٛ˭#E.'}Y~~xu9#y߾M7OSocb+N"8p\J75:Ή΋lr8t]kb٥"V*i,j} TDA_1RE ‚ș8XݣԹ/iɈ@R 1F|&3'٦mZبe;%WS-] oB^kIɸW+o~C?Ow\9/-|ޝ"5 I[^;0fY}Ha։H 7iie|fN9WRVSGUm8 Q) G~zH\uoe%6$Ҳ\Jy 4I(Q%舠yk5zGXͿh:EQQߐGs9eyUtQJ{ו$ϲɔ(4î'zzX#?mSqO ~NvhS)NSvAdݑF6aDoJaaRfqYסFg?8%"\*a=ATwp7R+#%ݮ4e֧ZLJQltҟktz=sË2\C ,jf}1M},ySr/2G sٲ&Բ`ZmE׵ט-HNR4JN_>қ^ D< @gD/Gێ׼`:KL9`ulyɦ* Ⱥ˷%^)_엑G+2S5ÄEc/yP*̖*"QK_s\dH!2ĽKmNecģdjZ(Y%,v(!aQ}g˷O]ݥ_OO_-/. Oɻ_;*gnvZ!2;, UqlȚkoi\cȪ̲ol*ȹ\!PKoԫ V}/|JB=T=Hje./&G9AS"jnTt\`+bw/3@ igcA.XHP'3fVQXdUBkNt\C{\Ƀ'>5ƇԵ]yF&ˍͷ}4݇tayn8|r$:my۵n_8I)f1z><:+U9G˙.N5 +EUn3b0%)Ynz"uhOMt$9VVaP)so=װDaz~4Nұd$*@`ʄ_f"x&ObM<^2[j%R+@FnT Ȋ_/}y }qjgzhܪVe,Vke6c.]6ڿc`}5xA4腹6D#c:+)Fl|IsјY#EYZ ^|tqo` <1זUY)3nhYZ^1jc&AŬN\^t!*1 -GoY,n]2sN]ut9xfRɿ:/uF$@IDAT{`VuUɚ+78솳@aNA#+u(|@wOpCɢj{cPc%8gv \49VQXp,GiԂεH[r=G |{?ߥ uV6nt[+^f+ͶI俄}:(4(y 227q%-4ɣdže?|l9Y9EV {k(ˢ#7nh _Y–: ,fћ{b2BK=;?(* )l^ ڍ01paR }d&XJ uEQiXH%tox{ =(eOoE%T,ϬAQܕ 7I0}؁۩*щ#iDm^(7m̪9XpXlj~+GaYD79e-U.pI}yq߾{I?]_;/*VRaJ\`dm'mζ g +7a6F(DA#־Unc"%k7]6Hj ީj|Joeb@~1"ڑ(2=mA) "& dG#[Y9nȑP2d~Ԡ(͂Zոem._7 |yyK@#' <?;5):ݸ3іxJ0W\WgԺRT\m\rlFa ~~te6YYDE Q}BSimX 26U\'tU.'m\\7U6V^5޾_)0lOE"*\OgrnCGk&r1VDƱ6RD9L҈AaJ(2T =Xci7_Q"Mώ{ ϗO˝IOG>|Y|pJ7ip8o)j(=Uz;lnXO;.]>&^ɡ!;@ Cj-V_W_%pz/r-c$'J <R0@ʜ$ 7r//;lQ605`oOl`(+dVIkcT"3UF*N(ֹӥ&.Gnto*Tf'?SޅV> ћWo_|\S.x4:bȉ2sB _GUt :*C޵Ik "`*MŃQy_'8a`–JWb0A@@KT5,K} h= VmD{[pMf+= ^x{0o[I}1iO`ɟQ& bDNR2#\ 1څ7h*:PM1- T7OE_I}~_ˍuh𓿿_:wsjs٣r2m-2 hc$phN,j3puSQzе"+&1U``Ki5?us޽]n'L6Gk˫7$䣇Sg|H1^7p5 U,c22fW?@HeCos钕#Li,}4#9ʹi GQ#QT pՋי cooߦ?8Dk`;]\I.[l=v_[rl~}g.u3O\[||LjWE-%((!I@{"@k zYk\CE *WEq]P2@AZox"s)kռǥXxc_0u~vV`$V)&pGCO̭ۗbT);tj 7?~鿖7?}@  =|{o'.+ӖBTv&9ToɉsxkI6rη&D[rmX5 CRT蜚+F;Fmc[BZR %~6x43zmMEPRbr: i3Tc9G!t:%1U5Ra@48i ٙA瑐u46F96ΚuLj=J1N/̳Vk E'~?s ?in?^\#b֮@1gfR.rq}s`STEΆ9eVXJ9KN|b.&'&L/XM:5K:HZƩZ jeк ueE `e -1FR(a/1=X8;zC!2G87~-Kv֤1ZApT i՛ K(!@  xi:V~r7^=|t?;v;e9;-*VLsޏSrN-ґ=pL ;'P˩Ct )mx|Q NnQn({itX{yj],iRO?? Oޗ~C?T)KmzGP1nT:[H3 }XO[ˍO "^Z_ko|@_9km=f{/k}ƛ5i}2htfiHԢ~ck*cE?/r?=\4A%Kz͵pu6nIjo&4P,bZxQ{[f켪[3P ) .p+ % TwNkƱ:!>qК\̀f"ԅa7ma&˽;wo қm ; o/wNIu{g˹އOgU1O!n6"J9g+q (iBJqaO%QEn% 8qWk;)P0*es{t]c%^%G/vFgC_Ɂ;h8,@60=ja5c{oY;Jnx&Y9HȍÚ%)\:)Vt2ߗPzB?tcMCeawh3D!caQ[M>,u9xoby~t[f%7۷p>tEX*UJ,쭯i )ZSDUƖQx3:GPdy/=`Z:kmHaukUգ=%$rrʋ2.b"Iۂ,J,KuZEk{4E7J܇b7D)DSK;J¦C ~B=n:FY]¦훹&!p%ѳ8,#x7.7&~1)8g/ xd ` iqH.aUi?94|SIj҄yKplwJcT"7Iŭ Hqsɱ١α$el%D\T{o^xkQ3ך\d ۟'xƺ,˺YcpSIs6m8f4fT:J05Ͻ)󞡐$٧ o_-½퇗]5+|xrfz2pEY߃Y˵Gv7+ ]OW_0S Y*?/ *8W)SMKȡ&[;A%;v>31[5PU_kYJ4FR-9rWCmhةRiuߘDZGNh'.8Tdq5Fb(*Q& nj^j@d[BKFwnyNj NWXɛׯg>.}[{_nvjn7Gܤ; Yb٭ڕTz:{E@< zivfĠ<mA'wmXq*`%+{VWeɕYN1ģZe|]ozQ.$n%5y]H4}ΚCCzn@Vo6[qٻgqDy WN>٣7FuE#\LN} 'ힺ/oٷ؇אl,(梥gx* :M Ő0.v&y˷_{GפLj3Seg;ْ9cs 휕 ص 1rzm|r(E4y60}DQl>)R:fՁh䑿7n-?\ _2\}Oˇ78ߚ'p6= ql~9]m: :wL&&qRUexNsu̎m55fŚ^(s I(u nٷqN'%ab8(=I BƽUt0iLOX>SJ3v2Z^khCĕ{in5P,"M!s }zxepW~zKvqcleC/ kO.nkCq{c.~h^G;duS/g>-Lc؋ZoPpY33%b[YS 졹(?I.Se<]!ij6+w䰀 2#ůDXZ'N76E(EZj' 2!߼̀p#8pL-8R(gC2TH-^@\fq}:&~|}?=kE[(Ed%^~c?_WˏO,.Nӓ_eodKZՃJB4ՆI0!e|>|ndN`!HkɣsJ}4ECZ(IYu'; #RbVJo8o5&;ko*ּJ/l!hi~2^T̊Xw$ʦee2@5>FN (^%Q>Мa.`k(%oI_hzww_?9R-4ܽ{CAB.ף~~#l-!zj2VuZRI+%{f6 )w,8Sv.?֓@̙GMn;~i!}v3\,-2I-ʀzTb#%&I!e ` :!43G:~}yH>}g2@~S5f P7R'EY/zT k2ZEK 92)6RV(ЕhpUV?`z웈wԹ]64zJ<.5;Sc氮"ܧƿ^8Tf(6?ϟ/[/F=OD^_NCy) ??T/1RslWLeks655\X'Yg4np,8#&D3zv.B*FfRs=cce8,l\AaU퐫تkBJYdbly4wc] t/u..kT2=AR%Y[bTl*O tPaz >ieR?/[ku&y5囋sz |dC?6{wZVq4QC(l>?1ZفSn(}i׏GFݤl:i99)ykYmj0QW*PW-ddi^E؍x tmvpK,1kTeKR}oiY9!DV1k5LWk<^v񄆭;[طT*k1/!o4h |M@SVM\wk9{ҜDo|f;-ٝ$Jr@;ZV|+l\5qU ע'ӢT6'6 AlYyՐC4:(8N0m`+1j;:n`lo+QC^4_;@(fjm`xEC!OҾ*`AU_JyRJ:V-\ elMAYCpBd`TQP-y7Y ~7.M+SE߾_z<}c3=M2nӟd9mȋ (- )pVA-wSnoGH' #4eM&6xId̙Kj C2XǬgnyM 4"LM . Q զqX@A[j ?&&md/|lV S1ȹ ~7=sHx_HOџDo˭;]Orso7~Q[$묲%a}3<*_c1CRYYܗ"\n;\@>2Vo/=ݕfXs*JW=A4q&FJһ0iz6o v䈛8 ) Q5spw-( :y'W3@𘋌SېkVam(@|\$ -$ѹo&QBn"hI6aR=w"4DAnřȠB彖vLEO:4s|qGv땚Cc].&ז+Y}/om`= ]ep9K՛|^FaOֈz H&֔A{!#xS$s6=ׇH={y' uΧ߽oosyffU:e)3$ϔXAH4yɓGۣd歾}0@^[$bYqC4C"?gコȑtA&3ԥZܙa朝{93s,$S) 0 pȈ`u9gpOIY}#UBLwZįa6~tVmmL3{h:04r`bntzdo)fJ9IZeKIaqv>^3=jqTCiPQl@$&A`K/Ih06{5i)z@ey\CIx8==\[ݝ;0Ũn]{ ˀt;+=S U :ƒzS(dMvގuquIiV㎍xU{TFBl %qȾg6ľ}$@2uћ *qFI#&ARVb0>0lmZ?8;G'{̤;t퍫pSC|4DޅK2D^Y,RŴ# ҇-EE F)Iݩpp2 .ǖe񡩔!L9 E*E$fѯ(+]}IVDqqoJT;*RPYF{^2, ?o W~i<ۀNy"~Cϱq7 Z+[=-x[`oIl[55a(Vp:!3O][0į<)”"l%qws `NU_[(s~e}GZH4…J+ ,mBSMN%a'htH:(S4SH˸K?aO5d>._y |}0vdopr />gޱoy.{{=us/[8<{LYu.4{G5+Yպ{H(ˢe֥vRY:c *\t>{CNT "G>AIyf˒e*)Չ˼SʊNM'Ky9&QQ'L(=vH0K U2{A?LJ>4Č%QODftq8ߌ<> O g)<-,G/ ͡&!6FuQTQ?& .45,[U6 gMcr 윚ЏR9W˴9% =?8dD|ؚ+M^.CV4l5}`^j<& !5f jOk τI1@?EEbPev_peH:tI@7m%잤)>F?A2j߅cƷ߿9 }EKl )D!r"%ݒfm m>eZ_֢L*`"q I$HD&Pn &Ʀ ܕ[_VZBÇe~&0 eç3@` j@~ fR3`~ʚj[T6@b^T-ڔ&@[Fws[W^t5O .FViӆYm --o~Ɗ`Iҧ}H;q~R5jFZ~7z]4}$@B$#Z` Oږ[_,k k&H"30eLNg5l߉N(;5ȫIԪ53hjb?!;I[)X/uM#T7L6Q),+1k5^_(3,W.w '|t䱁Ore آP c WS._xbӊJf\Q- \"#ǘQ"yIGPᄞ58"L"Hît{Hj QVT5 h7V ᭸+^'1Ns''mx觃ǹNW!wᆏL}4bѣoPiQ@G6DGo\^;H<雼0pe%nW@ _h+R`B% _ 9^0ƤYJ?HaT(ZKMuT5Qa4ȍg,[ssvٵHi'uGq-E3<hbqU %FRt&"Fz6n>Eۓ1FEV]3ƞhF7,gzz6MXS{up^8y/Î .z Gꏿa"Q٤3k,>ՀN?1BT%.R I[ Iac&hUJ&d8yXZD`+e%B&u%' DFdh&X20fAKYRB&PxB|c!|K APBרpHX#@lc[D(TT['sfyU2>OˊyX}snUx(-ZNJGpp&fcg^.k+vZ/:uZ  acT$&DeC%+_7ŽvKnKuۜ,M1-= 8%U6.eK9ԉ \3x^"h-&Eg {/l;]YI I: SBu#9>~Lz/<Nٳ$P"JBuĵadkњs87loY}!#,؈8hBP fY*v#2^g wJ$LgP!I!p(Er3eV 7 :ẝ vt.XΏu/c%K΅,ɥoaGPX֚_Iof km\쟕LxʸEN!uϏ:v9'jاnSawxJi_OB繃 }NU78gvBs~+ͳs.cxI8;fKqk4UGy}4rv[[帬EVV"cY$D.UAc Sc#^,< |1ds(?ZvNX錬IUd">f,d0fZd .K>YeV+U &z?DpTr)qlPo&m%35F7˘bsrPnKzA ]*="%H$?(NǮ6:!\dn#r:|W9,Jxz?<ݿ s_no^ ؽ 6"-EQy?RLj%~7.6[#~\G=}fSP};U29Rҿj "U"'OKg/7>_ /e$GRn^li-NU'՜=o~tq8RVOYҀl] @^or/q^fg17ZȖ^gbыO,b>=:|li&x~wN~XF.;^eftz .&a;)LJ帔jeH.eLU Ajid m $/7(E'3Mhg,AU^)Js\%ZDSrRJjU!TPNPS@Un D~H<.%e3|mcbNqPԱJcɅVS>9D8x*<=8?]ݹ uk/*qY'bui:Mj.Ys[r.M&$NB%e6º@|t 'O+5~/^?~-ɂ< wvq$IR2uL?Gmx/K)TRob9J)M-=H7Q}!ᅂ\,V6p'' S~2Z31&C* m uZtZCE" %>J㞡_?{8 i~~!Wݑ(? ksr/vBoW(n^;[WL/b{]iwRK'(g2 G5s&RgnqFo{@cwcty7R2תU#NÌʄMJk@U$]Rk7*/Z`j=HlO/OO(ŕQaE TW ibl89MؐV Ӱ$Q/_O|W-7:<-o}2*t^6PN 6rO`'YhKtdd1/,fԦ@ jSkQ"C FIIHӠ.t/6DH*% t+."| WYW 1:e;ƆDsr-<`Otj 6!ck6\[[7ކcnDg??='Ex x-EXK}Hl0('8XTF#}Ș&ui%aΎIbybpЙD)6Sn++"j*eLz1F3T$"{ ?(| Z+TmSmD>Ԅs͙c+fFiv޵'@eXIan,˂jvl,9#aϢ?uloOxA,BV)hYd>Gf$GkQn`e'w2[dNa6VT_EUqIT,%$ jg,,X(,9fI%(HB*/aՂ^1}dF>eb$| `m؇0ܗW5 o쎗5Mq%hx y.a DfɗkVd:bc1k) FI 9AOfQCϻx;Aﴟx;Mt*ñ3LI9G)繈9H_)tخQ+}cŊ|ˌrXTm93ƚC{B&Hl7t5ʗaab&+=s>u8XW:уQ GRlFC˞Rܵ^t* ̃XT|`o\l̆I-1P1k,qKVJHĈ'{mN˝ J6 \ 2Bd,z"gėvpeZ{P'F+%@D@> ٔl}$ Sl"AE1%ߪ}Sw_(M%&tis >j͆V޵~a [~8:: Gg2mxRޭ-v=K|UfMV3IdWʯa(+ \JkZi(`;н4392x_RI~r.Pt-Wm5 ǑZNjR"Dso#Mg9 &9x 5vS Ŵ2 H7VkoBsJڪN#BSW{N)$gߠ{ʨLl./^OalA؆{_ON\|X޽} QY4xm|J{EWWxϘ8*ݨc ɫRyHK@E*q NcA'Ed "T 9+,3 ;pe)u5h$,‗r"Jqhɻ>xj-dI ̽xq JJ5p VJr' _lvh1Yd]&}7~6F@?k^pvv / O6f| G;LtDooPAf1^5[ƭS*T:5;$ծ 5sS$RUiEF<2h[y,ַ2y(}&$B /f::@IDATqIm\`+sK! )Y9+Jm*s,4Ϸ^toҏc5u*c>BTȑxPSޱ$|MwaGlW<XFI@7lW O2^@Y&޲,{,Y&ه Y#sXhD` =k6@t4cC \[+x!0$@"ɚ5D q7?E!- A/00Zo5k]dău-%j(4Z- FuDF q(>P*ߥT(" 8jBVl?+)Yo.ίƢւN5ep>Cؖ-wRp3KH:~}^< W&詒ކo?{3ص]1.VC0(UkME?z O^]cuf^pcI 5,Nk4GD"ͥ ѥJdic> JԖ޺Rb +uAIV 7`n_2R±fAaa̝0P1-۵mO$'ҭW߄OƳUϾ~zh?x/5~s$e9 2c;W˖6lX6D-z"<)3]2s "B$('/bĜB De;@<LgCt7\N抈rj\sm{cȵ ~%88 2v.bqNSׯ{wա\w@\7wx ."U1KrI3e+xgFRgy]Rx1Y깞r.L} 8M<ԙhT-(_*poNFݪ6&ssZ(~Ш&-Cwfnl$I ۮxb/5{-W&XSX,uORx,K|f$lgakﻰ\NL_j$\\6jn"͍{gyv*p˘b\6bjn`=fTB+!5&%iM۔ 8(S*Ґ(:J X},&g1pa_1#e=Y(u)- doԠ{Oә} TAV^$ciOյsr Ռ'&OL#/1%5&=sz#"5qXrY7Gx0V"^3An| @u;rOi{Pjld+h:&A2eIm$Kʀ 6 d(dFt[J2TJ'OɞՃRٺ b^m| jam`ȜFx$ە,nm-Sfӷo U8;_S']w'0/Cͽ\_6.V= "m e̓)\ş DgL=:;IYjŃV-B`=AAМqA"R}PIZR_+"Of4$NW#v?x:,݉XU9;C8F@̦,q8jx]])+Tt;F hGy)Nbdo=`RR,u̘0nw;=5|˯32(™ggᏟG'*<|\ܹ}+ܻ-jA\e!{3喗*RfJA `M:fVFEd: >#A a# ~>$|Y%DqF#R\e΀ p`8*T P"rCjFj1ܾ\ȟN/sP!lo3 pn,ӯ I㳫p/8nMCX3v}C $i tz}6,B/!%nc>uIUNcq*mEfN=%zyIB\]sǯfRg1-bU `k V,MfշH`UkJ'][fchR97y;%ƕ5)C9kuMAUɿ߄W߅7GBsxi͟_a7ѽ;w0il(]8?]u!Z%sA 2s"1tzP)hu2& ߠn dp[ESp`8( 0s!*d9Ac (ȡCMb\Ѩ(c/ 0xJlYt#O4[jOJA7tRiN;%>,kX/[oe%1J;y Bʈ,ɟLq&|$˛aKūp0-4?w𥀫+LCwʪ$m!YpٞA" <Ā`\xf;~6%fJR mQNlhL}") 3?7bdK(NjA2'3R]QUp\^:&qa͉ͫښӣ?~\h:̍vwxlo :JBZR<`jY0Q[^>Xn o<xFr7HG 2%rq"q-󤚘ܒo9ёRN.e:4F'w'Wn%z7= x\苹GGښ]*c`ҨfOZu vnm8Ƕ*\dɨ.\lB:)Dg{g'sK\= g0ǟ|{>ݹ w6vtlFqutq(FsTNR<[ @[cm $('y"Ϛu|XW@Gӆ&ymn{8$26kb-'! 3 IWaF{ uߪS`* d5KD[ vrH80ȕ VQ_ b"Nr: fmiyXxy6ٛ6[J\c/^p|r h 6]n4r#.`zM˽&sM1.+o \7ݾMg _omWpci]-ѣ%.R2 tVy9'aOh ZAnqʉ@߀RV/UnO\Z)e?~?q4PLI(c74ӫ.`T7| Agd(Q5&ag{r)\|N!`So] ǫܳ ԃjgb.o3CG¤=]052m1/ҏձG3=iI.W萖ٻ}@٧I3U/]8l/Yc[W4vIT2ē$:SRP oQ5-zy鬞o oUk}ydxOyx<л^$V+4y PC`3SZ%9%t,,A7l<ڵ&k]g3mo=ܚx#,`l (Lp*&q$q6ey\.vh,r&w8{ټmטP$0t8!s]φhZٲÒ\qka6ůvuͱT _Zq\JBmnĿWX9? gdy˟|2|>bo:$7!R ,HEG#zB-9XFXjjR (/viXYP5ښ2t*+p?y.Zon{W<p˻:d./D648{&%ÏԉM=kT3 ZYMc&R޲e=Hn)> l*U9\)ؐn**GUbԪNYpfEFK=L`b*6?E_i;0BA;ô.N_כ%#pEdM%1RMYX?z64ET{v>yxx&p-8T4G8qu2bGt=`;iVoduS`\NMerEtp'tCw:#'k5||a Q͉B'sڂHܵ 5F+jY0`\s#1R5Pqcx'vM8]ؓܺ9HDƥ%T8j$$T9&7) !*(: wpw}Y| `o; ߮I/W+x` JԹyDx[GptG{s21nU} *cl%8 n6Z>hJF'B]ƞ8#([JcO=2abD1Abb룢iU>sfBXlF;T#1 ,  CWTQ`E aZX[n2}FLz視 k/O˯ "*oCŽs`\VfxjfluwDP;$/kB=U*cB*/;EZt)u_ī"u3߲ k9c+E?ϋۏ2 |&$TpJL? [n,v~w'O? ǧ5PY}pm4Ve^[o>+cb'{ƽpeKY7an<Nsi5ix ]Rri#޷1<[%=gOawO`:+׎>VR.oɖ:2eJB1cx:]@ᝫ*\$=@ 9bE/ѹ]teo{!:Jض"K\*y j#Ƣx#v)ZQƋL بGWi$,H. 5@tqԒVQA#`G]m7a߆[p`ѓl)p%bsc-'uwzg9j ٣iKjyN˗(v&pu!(|IdH4"I  }21Hq8$''UZI'Xx4yIN2@d9WJrܝP \n):9%mL"piCqcx%3ȉ$B"qX5(j홹6PUn`pZ)݈F;Aq ˘!70ni}c `77*prp OjAOlu/_u}@vP:DACLTSR zw؆v ٱc0czp7RI(XrD᪫'bi#/T V>^ S3ƛ),FӉ}>g7별 mF5 T,uq@.WLS [;_\oN{f2~* I[?ߢC8~^: e[V‡pR ꤆yVt[:iy uʼn'rsؑʳ(L1bUxSɀ@6gH+]GS$AKugc :{;H>s^>)c=Tg*Ĉ ^\+:$-rND@Fg9CjoV'Ԣű/Ur_p-.K>䁣6qnm <(/m Yp|W(6)^tOa:Ƣ :dAj`apcKQ¿hYMR-,y,Ze]yٌG ^e\A&_8G.Rr>s> Q -9rü_** xm~6@tJj;|}PޟA322H\I` 6ѐ*-QzՒ6|ںUFZzs cMFfLE%^nhZ=; _WzO84SoO¯RNX>}Nnvďd |f]XyAY׏c[b.&$ғ't/Pq$}\Rqoy- gӰvEb/2#].[ݛH-+-NH"A$U,[E-l\>ЊEIXEٓ. djn<š/SCRdalrg2HCeF쑪G`Rn_~9D }| ~2SNŸp vKi(SѢ[p; r7Y::&'~Fz 1Z y 'Bc˺v*lK?/g'TW?άxgɃ?w^7KɹpurCkwQS܈fse#=|Ѕ@i=/عr!}4EI{t9"b n~*UYpT.n{)ݮ=/eyK۔~ 1fBы % =UZƑzLJ'-4<; #.YTiW&)=LSW<ġ h0n&@aJFfɁeH5ɦ528 9̅Q-E-@tjGrJQp\knՔ@l-fp 0#(0m:?Ӏ߂˂ICs0|ؤ%nvpГv"_=? / ko^3Խ^x/YenKhu%^. C LEcRSP<Y3O0,/E'4 { 'cd&P9N` :XP%lZ^T\Ӻ%:ܮ&% 7>|iV p@Co7tl$[۔o* yP.*j3K Q/jȇm2*K,al@g|}m-c0\N|@Tw%4"p{TG-SiıDB]XGW/4 $z.=US8 PEu-|XɌUcV0rޅjc$G JI+p5{BA(T[V.ío o}DOΛ}?*<}- =xK)upS(>@ez}qʌET$vm9I382!l.\H+qaM{>˗:EvWZ$*".Hr:# A٫9Fc$wgBI˸(5Y%ҵݵ&z`0ܾK7\^ߎ^|9{ PiGn緀̜ʴYJ)q"0" %^ PXXʪdH "z15|ーD 򤋌Qݴ'^1+wgz-C hx(9 ~+c*q܉.{O(U0U9 dt[7YɂNrFnLɮ$䅨OgL[TđSn3NT8c.TtS|*S! M\44P|+bk"~q'ˑJơd\ FԩG$.SCU;۷QoCxY8zXo&y ΢9fz˔r߈| .rZr#@grM̚$в$ ,^1&il5Tl--D7i)uS$cP&cd1j R:+Mٛk0RKߥQp%fm]"yA qFBFLbk_M͌Kx.ʜYKJ E0/NZT4d{Uz͂]t!{Si"_Laǿ w'co'RΞc2ٌ)M$`VvxU42}+oqc:N+j z)֜RR$FMGIKHNP*^bk8a:S(OTSx!x}~ŗ` occ#ߔ(^P~ Iy۲6u=e ôBZ{6#ZFp )tOKJcWKZX!; hJ"0)Sx!!RgPJ+$qRf?埭Ē@Y%T&rIf910>IC|V+57"̓m4ZjkYбB$7[S\9rUW%@ծ)?kpĻ wip6`U_sMe? ϓb/EQV+6HQRmT+i!`Ty_ yDK0u-@^3\f]#OD"paf-<=lyv`s\O1Th`8YbxA+~,(!)pT' A~z4fg÷W{~93!|ɰ { W|6ϐC/W? @Nt怷qHl kK_g!7jSԈw ˘WyjUXE'٤ EjHPqun,lxY,jC$!$\Sm%F:dUK"шQ| -֒%!eIWMšǜR3s /֯U>`eD< j_fIxqV,۷t RGy~~w腀o"?Gm}Ck2chla #Xlq)MTA9$b'ǜ*hI^zG()qp2 E:$\Obu'ft2nnʑ[}HªFox+um׺m6Um!+joMO9Uv[c@l}SX[}s? __,6ϊkMTn%m!ӎ 8)Gkvټ?|<h)ˑD bC*(eYF*-xE5 ;SjRV*9 ~b;0 fjS(IXȬ! kT.%M.58 9`&j1:.*X⍀zI<ʕ,EoRG;fml8jE3,pab)ɉGƇ+t$ Jl&@ S? llZ(f^8z:Y  jmYm(`h#! mWB4mK+ 'h[Eqm>@aq\\%=\KKIlYC`2a9iA9(짴!W5ւ&6íi5+d^{1w^Y+B^sEJME|ZzQ6z89U\yZW\=؁YŔelۥF_d%-\_ J9&%lUJ c!z>E`to#DL=r,EYbJ7VWN\,*Vzk55:d?"(Gj5$JgA|W <"ru{ƗG^x9̘6aM_MU< o<7a)#JTV%oQ\:+Lc% ]@BN#LŜ/ 47 ,̳.[c"5{ʎl8$$:d΂J ǫ&TIR;FQDV,;$";FZ]x^oz·n*q1s*  h4.m[U~(5Vֈ|&ߠО.(K/\뼀'^? x~`-7cږ1*G4%h`ب wHlL3E39IӁ \ *"zҡ[KaɊF+;Dw Ɣ©'yre U LOdM\#5F8ҰT`L:M,f@E&X;=^=俍ESzgpۃɧ҆p=MQMPr,<+?yGX^U`Kp]QYM"R~l)1<V\nMoiq,`hIPUxR)-hM@Skv%[1@Иv2N +zSDx01$~_-o\E4 W qnIa]cGue`wvOCi秧>G<'k텹\["W[ 0G.Fq\0؝ &o'Mq\7Ʊt(=o! b|M%AfQfҀQX{䰦5 ULxT6^$rwcQv܎\OML<1zK$c:@AI8u35j|/{Y8ڛ[S^C>?;8˗~o1|.дb CJd)DŽu1nih{/ <=*6R3"D^S„OE{2bYOc .S_5q *NXn# (BǥCdhIYź*@IDAT"(M"@#!E82R=@݆8^JVH6*y:Ï&!m3Kh5GC hLE2,e &P"XFWԐ-ƺrV$sK2Vֻlx? p.~%vOv/;>N Z֤dհĂ+IQiƶG+ɩ̌nPn7DCGKc`zߣk 2 ѮR\9t;KB |Z8$S_Nt,'fP1{6a |YK3jg 1ʄ1( SEt~ Tqඵ4E6(Z$QZ4j=$N[Yd_-ЀĈJ6q?[M'a^wO=)qy? p? ^Qxуp-7ƮK1PXW*-}g8.a#J4(uc=fRGcRԂ@T)3.'iDS"<3j[2} "yԔzjC hTKx.'b[ 6f#H`2x ^(RMCeCǫEH *c}܏ [2Hb$\Ob̹%cbYK(2"K*{s+|Pkwl)]n[[1i<> Ͼ>_^Sr)>ؾ^Ĩzctv6,ȦafrhŴ!r-7 t_F&I?r{𲫏e[6JB XL^u>vuHqiCc``8‡3 ot-ΗtL9z'a& ]je|Yn;NfI1'޺O@J4.JH`x}Y6nORx!pM| 6y'{vۛeLxM ?ys{?1Q1ٌ_#Fz3Ȳ'[hF'|A- e;o9.K@KRЅ=ˉZ"Zb"5w1"ڭ@B$:S69yew.Ѹ4S=PPsƵ7"1DURa:zcjZ }4s[RUqjDCS,fIU/#`>:ءh, YۢHγKӵ&e|gu.PWz%EtHlCDN'XZ{TZ8%i] 9$D$\e} Z\L9]馍r\-CuƷT1"IEKFk,C0Bw솮GOJ")}U_xs&O'C8|/\L;pCx `[ 9p|F!̐fN1ufL‹ShR4F_nmm͞XS+vo;#dm!0^ $Tҫ0R:yu9R^_C7gnp7N%8Kax?'Qw wR2-( $:k㏈ te&uEᨔՊY5FzS[>$)nl"$" y;15!;@5Eti#}^|EI ͒a5 -l,X5YDYi(M73V4$P-<RZ%A0EuگR:|pZ Q8/f-Yסe?&"7;1ը3V2'QDkj?`BScھK4Fgm*/UK1TΆ&r8p8F=3bllsI)'o;*t*s"xqK\>猶AKoV߆oÝ'ga_7ͨϞ1Ϗo/k̺ N.rOb?9nT&ObOI$ 2!G, %DĥR*9LhrlXV[9. FkUǚhm dTuOD -cVjb JԇQ $,I/I4E{|zɹY ,h+:2~Mт<q++*HDe 6WW>l3ONV!Q$bos`;z@ `h;XrZ=; [߅ ;&Üux~|~7{x Nt/{qxx._S/,g9X1qg.iZſnኅ.<Œ&Gj ŌRV8JӹD>71Jojsa:pX05Y@L> H fIvVHk1Ai A2 NBA&i䉽TJLԺrڮ!xmA n(1<pƯOM5jP)l<ՌQQlٰ%댦UP6EqX0ǿߧwT7^|}8? 'jxy\\܄󃳰4 Q/>"[x̰PeH喞hsy1"oX[z?qk/|n4i jו4fgkA)M,cN SWY#`I0/s ΢: tBMٿR=@`\!{z]ܐtW]'oބNNnvw/~!o|93{CYQX1ůS'8z>'#)HdW KT"Oa(|emY X h%*?ID28+E;`LRP.1!X1eb)[@ #^IF+PJNNP)Cߋ䔌*3Mx[>90;E7 C[GI#D46G-)kh窸ҘAv?-1ܜ<U:*: 6!Q+}!g#U$JpT߉}%q,uֺ;3w]ct|-ֶC/;nŽM/-,'C %яtI6zP/8x6l1iM(. %I*sy#~Di'AtOz AWN5#֓s["#Ĺ_ AZ'lJc?z9G?d|DF;cy(.\pq$e+̲gw៴n^í'?!~Q%B8V[ z!Xwpd7AT<V%  ̘VI -kisBzZa.A=?:2H2!TR4Xs $" pb/$$ Q>&ަVSI7qT'. 2T*Lڦԧ{lP^ +U0CBkY[2e(yYX5(m-kQY%]DELlhWT %)g-yJ۟#%-gD>5*FMe9]傹)NM{0뫖^HWu/Up0`ND.`P{z7ϿOm#Ң{-w ~r0UPqy TnM9%" PC`y4餀aW{Ϧ<rf% kP^K@}Ne3Id;G=s@)r,m38c4fx󠌊Z 0zgQԌ4:1S_P˨[M,b2| 8VueV2YKDz*\凉81 O8pu:oUu{;nnn:oܦ:5`|4Tűn;NTv!dk aT(77<)о<l- !,2Md'di%Qf"B֡^":7$@P"p 4bSd*m7TlK[YITcFkz:?E{HT*C!I@z~ h>i=ZDg^YGICF[s켻~{&q/qknsWbE>mߨ;7w:(C\}_=ۼ-"K&۰TA+ hgJ q%qTYIupІ@V5- ,ox>[aT;Xœڵ.Ua) ۪ZXWXCVL!_s蚰 6A [oy Jn-=KtYuϟл Y{4}".pq>qP$5fhKޓM#Js 3-Nrԃl 22JidO/`@4 xaHH;MX5nrziw۰X`~d |,pF昲,_2{ w#w>ꞛ_IEj1]o>}[_J<y-zDpyqm+ w=,x"-ʠۂ'Txg*]Q;\F90c'aM裤eVSsC~d^-ہƚHf />$t)S,p/5fWVh!Ze Zt0}pm`Yxp `.:J(\KЫ ݦ>שF;Q &gJjxLRmUY $ 7Rb2---WnC.mM8{v73ƌkT}+쁻,SoJU)iF 7 x`T^&8j#G٨x!!xlk,,9;Ryhӈ'h,`'#ߞpl0GkٓNLjTHLrv|.^ P&/'- 1") ҥ ]7G_tjx29h}o}d+ y2&^1XZ\p7^u/Y]Y4(حEsZ?).V(-yV>Oh,1;I'-T„6'.;!Իj[4޵o !.TQ kT "YC+vxn dSHXd0 a7MCڠ0ZJ]-7iqS2˶o~#Frԭۻ3 "kt/DE} I5s'd ;G5oᣃs;n-uC}7?O9Zyzmz÷:;p깍>m5ꬵJeJv\-[#NΈŊO6Y0u4Ky2 G];}ZJuC <;'$|\= 4$f))T0nYM`Tيkoi2#@mR\-Jy@LX6d8RBlͱAoHʋ"\2UR" Jv`ضE$I{WO9Q#Ld9mm_j͘ $Ę~T %Iݺ&lZFgp0Syj;@U/mX{ş$p@=Ń*&ZIۈB+Fě֯]ӧ=sESlҝhqKw>Շ2Gzۢpbc~vF(x;cx]j4++)$ N.X2U A,,=Vp{xVm j!CjR6s8MJ@a%\GJVPVh WKNz@žq˯ө1ףs/wܷ-|i[]L_qNqdwcZy2Y>hq1h6d>glw(;8CDv:+-A9ӻ"Ov̇!IhQGZx=bBV 㧣h"@Az4sFk:HK/@8Ir_TRǭo!?ՑHAvVR6! \'ևcWa` v+bYQfGőx1I|ܽp`5KM /ϹŐ> Ɓ&aSc6 2=LXrі9bR'Z0>ck:U=Bx[:w-.ͻ3KҬAoK$ۢﴜ¤6 1 rGXoGxbPyUe]:QbHgJR/r},X IZ$V8gx7`?wlZ@8%^-eJq3Fؘup s/~p/hOӛ>rnڔD6րq,$[^tTY jS(GSV&nqE}{[W5=FU#*d"sW,Lҵb4/Y yfXy$I I;vܳ*`5%Scl1~:6! iҟ-w%>|K#^y${OR;)?]L*wꌯMqI/›[^ͭ2tFhoYb v>fdZ`)jpJ)6 _<Ѿ7\P.ebKx8QQ|@!J?x16e, B7 O2 ;CK]%0E/LtѸ`P Tlq,Lf 8ݫi*fc H#/f!8PZ){%T)2ZE]D~'?wѓ8kٟ &^˟hFdai^Xt_qsbǵI.n%>OmQVJĎÌ? XDQ:qhukd$)1AW&)U)}䥔t/Ԥ]~N^|Jrh VEm XZѝWW”$kdƌnTx@!KCkxp}.Ng4N۫nNd4x7h[7>qugjϓN,=ýi"Dӵ@Wz%DG<莓x|#xF7#FSpbXBQqDI-(Mo.`g6t_ Ph4]'Ύ,4Z<@Ϸ l:)PY/NT8kvWY2E;kٹ"ք^*Ö\T 3&x4յ -~#/20 _ #`5ymwuLּLU;nsk}O|9v#MYt+t]j%VPU]"b@<#IVp WBH$?.AV2 [ի~+rTzQbSӷՋIFǹhڥms>٥”hy[J5J.ǍJ n.?u}V}1]3 ?lr}ι._~#Stv*; :N0Gԙ08ӿĨ%O@>sNwbhmxӀJ2JUm ! pa@0C"L,0G-:)4aL/GK^W-|K^ToJUMQ5љ" 70d{b>i8-i8]4cXR ,EI#͈)8pO;ug_ݣNɎ I7ߺtYo]Y4κ_|[Z\h-8S ?,rzp2QJPbi"JQ@%YKlX?CM ,:7k3ڄUnant`15gx#/{ \貅O҇w0#-zLYc@Ј9iu8zHyJ2E  ]]YYJ ja,{4@ef ~^~Mؘ {r[:\pY&8z]Ys̸;gv<$qhC OQF,qWd(m hݣY''8LK7T'J*+i05JCb)$m,IHᵗ$,pϥrZ8jkFE9u+ϿwK)yџ/9C]x-jE>C?j^|ٽu[9{L2 J=l2K]'EOrkv co4ul?X--[H0F[ rl ˌj%PYP=H} #sSM B1Yc X'#g$IĉZ44+xCR~ ␈% $DPh Ve *E_wIkPvXS ֞&XJd__S4g1t.T'P"w1dy鬻u톻y}\0ٷA_z{fݽp:=ySߠ^[wK8@{%F;(]V$TTb%R`"el*J:NQXt/ >px17PoG *vu_|‰0h#QT"QEHXsM`nj4f7ڏ/a)=νSl̺θ{ns}\n^.]8_}l)tC $j&(Κʼn3^1;"mHj2DgAA=%NJM b:HpqXZbOWe]~O8y&G oxbBCY3FaB-QDxmP.ݢ" 'oh},^JQ^),ysYakLҺOV7tH8VS}91dAJ ;IV2,# ^MD Ͼpo"4mgw|~LCTcŀ떳wovrFp=׽ڟKM.Q94OMy eK"NDH7-[Mf jN#KL<$RU`S4"C<U0(Ǻ" {KtFD"0^qjVvؔXXU5 Wp[;xsbrmp@Ÿ DIށ27 P}+՜1a 165A͉@Z5׫bC l\Pl;E@T#j\pԀuq,ܳ%)fޕRҋ RIpz_\Mxe@/ Hb*D.0NЭfݍ7{KSqܥ13(=Ez`}]s[t19d<"t31e/jqdѺnhпORpbP HaRW1hӜk($"ULc.m`%FI,u8rA`67)P.)v'gQa*gpmw/k@E; q&?6^Ww7]㑏U:}yw_wr‚/YzOdj2l-zS30j0 P6|مE|D}̇mE8 e~2 +3K t#qee$eEPZKۚ[𮛃L8b71 dVԔ5II 8M*eKUdbVѻ^'KZ5R3LK %3R Y7_r589xxk&gwg.] ęhp{evT.[w#RƱQѶMOdTޙa1z$u@܇VHF. )&fīu:SV 5FǶ tp -# pɪg H=ILʌmx,;x=Y4-?ގ;Os/t2c\cBumϿx^?MRc pwݕK4&Pcf-QL_ˎ"^&쟔 eKV6ZOT8qhSt#cJ fⷚQ8?KE]>t{H=᳁Z=۪_U ðeZc{ԙiȄ9 4J6NPƔ=HFPeSoq™'ؐ#Y`z\id0ɑ.*P&Ө3 kJx:p.^Ph5:-&t6ܳ'4t몵 ol2QJN:*_. :{gPi*>D$R~GUoWԻ݁$,MJU*Ehn8=PHmC2s A2tA:kѼ@& ,&)N1uw'“ro<[qr{VUӒqCߺ~?-GS8_̓;öy( sd?;:29K$1r@k9Q Xhh:%ք"\P`?E֦V%=@`2KVze_pa'aJS)"~Ҹoi`~Ƅˤ>M V{'(=9 0Bqu@-J]գ`S dgx ņֶm ڿB+se=ɗLa@Kcw nΈ9I|cBd% xn|;nkk˭厧&O[enŠan٭͸+n1?"]&}4|6ڥc^b(EbR9[@&%RtD4bB9"R7=nh pɜ6 Зh4}΂>-De#:-KkOg߻gnnwy5fǚ>q?wm忼R9֠¹z5wjC!LGݓkL!UbwZo:JU9 *I<=D9bY6I&cKhdI$I,xɆ$=jcy*$L _1zM ~CsɁ@ 8pGzϑ7AS69 \ uOTMqBo t%rbKLD!HAycBnڨXTXv+ дzqlyZ9Li&(rw>6p"rs<bu-o{n}}mlnt=cO)zM?pQwqͷȣOcHޭ@IDATsn!}OzCʥ6 W[615R_M){n]3B kx *\LtgHpEcmz)W56-A"8g7|`:Bi=X6Z|^O~`vgݝ|Io&A×ܷtp@mޤ/`{E8m15J J>^=v $b6Psߟ,?po8&"k1Xיqr#5 ?D^ִ@23>*k"+ ,ɤ3nv{-?qot-t[׻Go^o?3疖7o.u8= Wۅ/TɑMh 2W:Ҷ,EdR(dt IV6مXq00SϚ##M KRq`VUW2@F-`,`K1AcRvx,`W#A,kY( ˸(Xi6:B% XX{Ϝ2S@bW倊x7\Nx8\pmݝ`s>s G3O%xI^XG$2OH%Դi`\fjyQ ,ZA ȣ6+憉f }>~G_q_~# 33=n;7_N>:kyCu O7feF; RKjF~},i*2 =I[ wu[J]D--ync/Th;fK.EtC`:+v΋z~@$Bs[NФ].yC8JDSkjq4~,YE6FsT~c0}Ug-߹Wn>7m}!) goOw2{Xѧ~noﴳ wNo˗C%ps={j&Pyy藓I689 q.g*U5(c1tŧT zqbi !pd8+ NX32ƔWy}[X#ŊëPvfb]]CAb2+, f3c V ODImE#nc ~hXqY}AAh.,i4Jݦ1jNr#()UL+2Y_PdaA":nJ;ϟ@~=~ݽ{4hY/<{7GܛL.,]Os(=;K/tK|7=jm2 h(XdM >D=Ei+~[.RX"Cn  DVV|c5Ҫ$c#u[ȅshA{-GvF[i`LR&SiT91}a^?燒p)H)өq:iւ8+L͘źm澻+z֟sz&r,t~x궷xA/Ϲ}лF2DC.R7⵱[i)9һLS mϜQ;U]!1v1sЯ m)0Hڃ}}ç^T 8VFEP?WʏVNSʴO =W@/*coV)Kq gJ :RhP٠\dڌ9jP38~(H-5"+kZOY3P8ZX5968 U-G(X'8ϕQٴR\ԉ&("}b -!mQ_x/gOQW_q fѯ +[>- ݁x’{9K[d[kWfehN½zoB*R%j1늋(5o$SL'wߏ-3V:na0G ePPb}55١xc<ۿ]x;>&>9 Z[q-65 ?xo;a ؉p 4-| fё<&%Niz$< em~w2îI0qŊ0!pLr+H_e[yXJi;}ʸsFJƠ (% cISفiE@`hepqo%E(ey]YcHͼӈoƲ$3SP`CaVQ1խ3D6)Kn()]1 Vbn9<>iHk޸ey^OcD/||i>ff5~*} :؜씕x9&">Oc#,Q05Hs7\N֔ 4K)^ ƓaL`eZ y5t'p[0yce ; D;~R:+?KU6WwՇwg_??ugg_oE:+{ﹷת;OaOh 5eK9NRF#;̥uU'rr8"5H Gҳ`(l/RDmL*'0'0$NTII/XyI/ֺ0fh8s^x.j%1CƗ=#F 6A=y Oܗ1$xCA~rϲV5 %RX0 y93ސĢܦl,kՎ7GؒcgD @n9i7҂{Qj R@?_в8&9z{o?pwܡ74&g7^PyZ!Ywuigy1(G<`mέA=>0h;z$@elu6tA&SN&U4j+e!0ilLPJr=n|ܘsrGG MvsYCzqe>w>w| Vfb_1ҫJ -Y911X :UjL?x0CFuX[B~ش۷+눌):@qHdӀɆ|AS EL岵RB" z)*sY@ X A+T~ AeF|CygIcNmۢs&!5sЪ0eR*F$mZabPIF@Q:46JSJXS!@˾h"0S(VŕEԷtri"OI7_nie]Gy|/ -yE7$[;Ј-ʦ6E:h'bk4p-ėofFԛFC0ʹmƩi+OOeibe˛ 2 vk; (헹Gpk4 676fj{{GnEڏgݙ {BC=wfwV8޳wpi4Dݏ$fڇ< *)oj4A s#HWnRf@[VFoBn3@}u%`a7Ȏ"Ǝ*}<ޱ n dBe)q;?C! kWM0Oz._OlWQ;٥C21 7x23]0k[#kt='ݤ xt N@nŸmZ)I=YAEx>kiAΦkx@q(),YYC)sԢ5фNTȴ2\`8.& <X5TI~*0ǚ wGYBkiiƒt,kI/*k|vxweDaDkXVJkq`H#4̝&fc$iðb!!䢜(̹x:T0CqZmZ%N%?_YW~=ػ;{Χ_PE|Le}~ue>WG[w7.׋nc-EDx& mm>>uP۲O0&ɬF*kXxl'rsy)I 쏴تYd~Qn5<3"Cc+JyDz:4_ﹿoww;HК.-nC_wٿc_5HAlsjtcuWRM;Ea³yѤm!CFfGZW}Nd C}VQg`1 pp e"Ҩ@b!H1 N`Z02w1j!#n6̸KGZN~׎YLwSctC^lX@BJ! xL'ySpk,iE5IZ *SIr8+ɍ$\—^Wd1. ƹ3 O7L'^9I6%#OC|QM9E->(ޮ[Zw= ('>p{4 ٗnk]ϲS< Nk^$&V6?p &ᰚ<=:9wru*DwJ5[4Se3MKҔ{b!rXGa24Cw k7$=yш%k!f0}`jV8I5"דp)蘎2כּqk%[^ۣRm̻O_O #5C.^p{~j͎N۟N"IK2 cxwpv,aS3pliJU_{«N!`ȆOÝ:m dl8K qt#Füoo//N8'>`B:2^REBLh'LdL˞o{O8d  Y岕J\gĀmIhs[x;B_$O۳(_~iCDfPXpҷE21EKra+xG+Vk<}`m}ݽzrĺGw<ۡ>IκweD'Uˋ;.ﻯ^/;nGOhB 5FC{+<>6ut4OspWEԒW&( Iض5cq]*tm݈I u.sufK`!. B*d /z~7臎]`Ss쳻-MK?5!/s+nvbo#tV{:.D=>a Y ϦuS30:HVKsPWHYʫ GS~`8BTQ@LنŠ `24TG1Bf#.> ]sj2'.WIN U Nؖ$I WE;0OeZrIW#,`c7Im-}baӜbj[Aj/"kU͕R*D-yiBľb5ng勗ܟҗwuN&TR!z1Kw"}C܂>I(݃y|{mˈ+PD Ph[Խ m#dWa`vdRW Y*ăDYɻ\2BiR1#lѳt>Y4]4U$=>}[a>7_kO٭S?-3n}澾=zC7G_|ݺv]zeZ!o?t銬 p@>}vv<3ffWܭ7h2 vu_ 3pRG9 =Ӿhg/$"({b} k('uBp%?<KHE[\Qd&/FF#C RWRWdEgDOX-5/ R٥?*o-!N+vI v7}oe[|;t{=ݣ 7[[ۚF9wEi 'pWP/ Fa: L2BuA2f9 yRuG[{ LʠFЙ ZJSQUT ^2iQu_bP}YkbPeY 3^zJ8xd4 Zn_cٗ_4 pp ^ʄ ~f~M=>/хr>pMF|ta]XXt|51Y)X~(;Cݴ.ZvnTVY5 +6Ƶ+!#(5XE3م5ԙ}Gʔl1?QKZS>|A8qZSVYoƮM%#gb\IMfV5NʈL ԓ0>iy zz*٢xRrzD.F,-:bIR=7@_kw{!(j$ܙ)zl|QG>Bi#d|q(-)j(zO ]B3K_~KJ/ܡIW߼PDZp_˧n~Iqm_gO:9jK[n:& _Z~x,NRHɕ6,Mkra 'AD$dI|07QE83\$&E+snF&_%Wz m99H ꮽ֝[}D_ӿQ7ǯ?#u7hقOb'oqC^6Oud.ı#ٴs8 I s=!4V v\g1`&Q[q$JƓG 6( 'dd 6A@l X :ċ0 Oszvh1?qAqWڅQ}v>4oLnM6 o1aYq+cxCՒv, S]AЉ'ZQ8& ۚGbPm/bpݲФ&pڊ?w^vzOt;[ﹽ [T muyq9ݙ͛%~9Θ{۶8QTx5,HI#LcRbL+fO0*qH B#t,6:Tz̭V-2PK2[] `Z y?V(o >ʹacaBK} /?r+Ag-EL{?p/w?|ֽ}{1TlOM4Ճ5]! ۳4(?t E}M\0 <Ǐ,o̻^ݓyV4$3j/&v@535OYfd]>t y%369&*d*1v80D^wk0^!>p81J}I WpQpW4rXnbDLڇ(^D6.l{<:pwf}c UWTT:|ǝ!<7;(GwB1D0^a5_!4璉)[$& %`òY-8G[FSĚL@$uh#jt2}%wN'^Ћ^ŝ7F7 (:uWk;w=q_|=~흒؇,kk҅nҵhݽ 88UI%c8:xBpơs)-'Ȃ;`Hk!2P6\7Z[@ P5MuD8/bM5zWZ/V-9K{]Nq*$б_@qnXHn"J㉮#Y{߲// ]oqiDEAK!fm25z(u!KWZ3ajeCBYn'.,K.?y# PSާ7eoogKna6=pnwn^w wen͝v u %MG 7V%iN#QV +h!u=Q7A+}YfؐT}&LV֊cSIBcf8$9 -\M [碈릧$& wd7,/7n꿸Jb!2m}Ŗ+?>Ȯiux=e:s;K*ӏL2_=Ɨ! zgOQkM`Y)u yf]+-ul UVP#@0rqIGJ4$UnTS2CYiՈYNJ U9'IHJ Wb[>l(AeB02 `5!$: Vͪ03T, g `CeM۞RWiB+j &>zO7ymT4uwg{70KJvλw/q 4׺:^sH3zL֜o0}tfq-Qs,0 *MЉ:z"ƦXCod2"ӍQXG]y5C*(k PY-.x4=C*-/;K/[Ub5*}r=&65M$ KWi"@'k]}"NOZ%;t *6?%N@j'Q"%{zBJszQ_NŬ(BN3!(|C) r_L3fh%4z$b-]&6<';Q|MdKtp$iaVzNC> #FT •RJ1Oisu}mEt?Coǵkw?-3t!iZξ{j=~J/#OY9+ܭkݻY}^ܩ.KՊF%MG;Pwn;[H:Dh,7y)Z}FIŦ6UIYJ\(͵+Nc=hm7/6`<Y:7?;?wܣGV]u3[0 I/zm~p։@+oW{;[կp/PX10i\T8*xRЂF(tp-''Mq&;y8hZ= Тq>⓮0zuGr:b Qw~[Y쭻:nP"*7;_p|t{Z-7o Baekؕ`]C䖜Wͪ\? 50[s R.cz9s^ȣ qFӽl#.B+$W`c>W[B p X(&VFz2\OW<Mf[PŊC]Qh>ߕ$n=rjUՑ0)HcNIb>G*z7ј^8L#ְ!AbWI1+k[?,4KU΀GbЂ,_엤=p[wN$uHLZyZW.ѐǜ$=wsyz,`*At-AUg X:+8L ؞,}G)ac6YK#3éʢC#zy N$z;|4= n{U5~:/nikknn{.O Tͦ\Kx:KԔ`Xx[7nnpK8G1T;|MB}*6aۃЃ)i{5#h՝Aql<¬rh$Ok%|] "hפq YԉxDc UD*eZe0,P@016@F!]ezGK  omBBl]UzcQ8D( d\/1, *6H-*2fIe+dxJ!ˬX^T yQgl\NH #޺~ml}zӣ'NxX}ݟmqcgh0-fфm jgmM- IH00aO݅*2=lxlÑ[%aI2ՏQ"bYB֍v*2OiɤXĨуv7iEnn_[vOZ^̿Zv9= vsK+W۷n V8`9futʸէjw.}~$6:]x0IʌSfvƝaB=?!ni3bV: #D̴ll㠣^ojRŦZV0%T!*d/T[=!PvAўwY(ELhHЙUE4|`֔1а(FpB/ B-*WFUL@Ҩ!|ŻFZiL\NO2 `1#Ӫk0dehDfE>"{Hzl^z~&0MWw<_h3w{:|:Y|Qc~ؠlϹ= 3hX7|F+[6j1WGÊ C!Wb|rug*eWb{NAVjHq>l`,2I$} gmR=nnGS;./6w߸Oz>~F?9O}gَ%zʕbw y2;Ҳ+xt4j" 9ɨQ VpgAѮapľ`4I8t@yB!ǀKyD8Z-aT$e0.f>(>m,Dʗv}JQx1+H yb ,z).n%6 Zr64J 4Y(4 b㬧x}Ti,>&LT,C 1n?&fOVd(4uqHr!,Є-ZX#&ȈBJeomk//I!~n͛S7 p@~췟Տ?p{B|O\vXz ,]uiftZ={J5hlT.KLw x/}5߽@ "tc_ 5|k(%L-Qɕ|9Lm!1>ZY{dN=EQԃ˲Æ](1NTa!0bec $:@llJߣdF_K,F aM[1)vg ɺb}q~|x]fmmQh_.pdv~t;sifz>Yߘ61z{|>/Ly(3%)/' <2r hdlKkiƱ6@)I 8w@IDATpܷ6Pb{`XN`/bʸS;nWfwg7&{vݯ_a] I4<.|\l>wܾ~s4;!%gc8ȟ<6&@ݞzn 7OÎWJGhRlwhvB$Lf|c؉(< @*_ `|T&;XlD:#I"A=ȻFB׻֑P{bvb>-1QhYk U[ H=C{K}-][NfyC^&@_Um'ȧҁ?77Ns\΄6;D\O S rCBgIkPw)@Y齏_M|4M3̫~`~~oZ=1OhLgs2^ >o_5"zllհ۬2R"l]G9o~~Q7 Oq;kTzNAF&މ"G%5OlK0IeA<=W+hr*przo\|fōeOYEk~_~<z&gsc{󦹰4B+jg9Ķ Znt6qlɀ:ۑ#hnNtNv{>"),=%a_n .b2aucF-Ձ?)gO|]׆LYPHpY-$dM cn0nG0$+^ `/ ]^k9+' s!](*->KĔXUV.+y()):䑕(md!B5Ț/SrkJ \Mx^mEMeD75$A3?'f/~frwZ Xك'fsm,gk+'nчy^lMa}=$6(˃@RT`-؇ :AXt.9@N&gB&gDND*Lt+#zi.{Q@y.gST'y>]o~gEV;>~%?|g=^-.,~Cs3;3 j^1*`!oY1m֎<'hvu\z=#Ph?*燦 pgd Ti\HȻADx4cZfQԮظ()xũPG/i(Vʧ€S%}XM|UE:߭Դ5i#J"XMx>OoU5>[ėG ?-^K*;ta?2DF Ѵv}:m ²=Ѣ<pR9B)U=/ˁWf R[FY 1=_hx*ݳv/ڙ5? MF!nh'ŭ%z" )o|g{€5M:[f6>qm~|p/cɨۈ.9mfiR CM>dž !Ik{yq@}dPZԳ5 jM)\M=uq)THTM"9A]{.|@pdkŸ4}~Wh[fj8xߞOٱwޙ#85k3W.^*ptV;f-(#=5MځmGSE'ZgzjW'5 <nye  N[=;׈hUm\{hYsx%h4j NZ?T<~0pM!#;z6^!7Esm^yC"1 BHx&=J!bUX~ด@UWv/mUaKG œQ{e2Td|v,&$іDd!8:ӑ"V͑ݺaNޥY@K泏~ :~!`͊ˇ;+1fӖmud[,Mdsk"*=FZ!Cˊn(f]u|aT$!QB$Xkj5_BlYJG:`@.r\8(b9T<ڨ”x:zX=h]b'ظ&g٭b5 g S@]7o_xw4q00ufQ^ޙ6l Xt[5Ad ^d,KZaD5F˽Q<ȉ-yY Xo wZζY[12  ?݆ {m~ yګ?W_#׮\/.]f`U&Q$j) F ]rw>ާ:ݧ;.yhv d!5T }8t^\BV#7ݻ>PxO,LYNuK$h$Eڪ0mdOS>v[ \2U}lBj:),n)Qvi#h &I]XOHjE%{gPL܈.AV#&od4痯@`Ydұ.QbL?{-,8g6ÇcUi b1+~e.5o>a~᜹~ kƕV ,x /g͓i3e1+`O]{'C)%xfF-MQC@.9zYsic(ѰFZLSG' [(SjY# ¬#2kw̯ճUJm1t|G_b+R%J)8\I@& )}lZO9[h{d{=`Q(<ÅN r'nx9~˚ ؊gϖ HH8mVH'q \`O mAI{ isTyLBU0݂@*zSmM'i!^ŏT%$@ƴ! $! ?Êab;0ĔK0d @_ckC7O4y{}<;kj#7?b\$r3ɀsΌy&YUǶxl,UvCpKVUb}suVM ._& 0 >'W74jl2"/pXq;ZUBV֞텀P س'hY=rA`,Ȫ$1¥eIşU: qR `^QgXм ͙nKoj?ʴb JS9<HUiP:H 0p6_Cckc /V_1uYwrƣ pc 6M[ڜA˶=c_eXzN(Ԫ+=^=k0}?I"0Phjj5</ӳ Lf\bYĠ/m1wX\8(gm˪:5c~3:&by-G sm5:F*t۫p@1y@^è#8^#ugv5Y+'ػ D T#Չ^*ٓ*aZ.~n9T'Ӊ۠hRCE0֕ADG(Xv41|D$6^#~U?\/nRMU^I5F|; 0k5UmsoLuǣy,CSv; Pmo$ĢTT{ePgh6D? !f3Igg6_<|lnMm?2Y{bg{35ܮ9- b{ bz{,)dlm "hgZl?_`n{My̟ w819Q*<p 粲7kfskO :3U^d>w\tL{ΈnL҈Ptx:h!QXzS(Vzt;Pk{ޢa,DD~@c(;L96c>ueĠWXie!p3VS!( $eW$xlpBb+y&miZ5hKʰv U}h PXKwJ5gͩO lK?OԔ~;ųW_~e6Woa'9),8ONa<NX,pe b2 nFmV Q)tUbf$Iψ,|*1beD vryᜃ09|ovoLOr1?<26NYhu ^l/_5kxy,wUs _tYes'N^2g4٩Nt6;3Yڲb4b7EcLdeW.h ,7`@*:R-k*y" ;"UWZLs^$9oj-ڊy^gx+_ Y dً.Nm+S"|~Yk[h#>Е)#GLur@Sr2`ײr%6WSVUS;qHv /3 #à[^LsPYq}( OdNWDŽ޽ay ga`wM1ۻ-[/e?<ލ\Z\47^37]3W.]`<絟NζgIި01uMK#HXԸB_|{ΎRA ٸ2y&t7ĹF@N3$һּbѫ6y^NkM^J=5QTɤ8 Gwm9'q@DAmy|28> ȵ2S1=^C&i;A.1 LS{i F`hdRaT˫F5vD+%3 z#3o1;pGEF9 5-4?YN S¡YOhBi:;98Tg~".[qǫkUAs_ֱfSO`x [C ~Fn;EfDZ!1b ve2y;,i)(&e IUݫ 92Pؖ>(/me<Ǫ7TA~IeTزۑRDbBHITVbh/lOׅ>n륜֞`0^Y Eݓ}o鿕 HP mfEH L_NH7p,3{[fyٟÝ%.އɛz؞xXb_7_NMMk}Yħ9uHӽECe9z*Sl ÉkJgrM766TgwZVX#lczjə'{ v=[Ŭ:L1h~G>[F|a=; :LonLKѱD}Q'OTCrS)H ]+ DU {9BUJdh>x҂ lcNOp~WܡWo&3!ʑΏB$fݎ6;d+I;wNLq [Ɔ))P >Ջ5v$}@oKS-KX{ʭG ^wxQuY#..EKjf`7O<6_; ?~G ӟ5?R-s|]ch̆4eaBl6X?`,Z;Uct;uiޅֆ3AJ1y?E^⣂TTaDC>Ż|\MG?#[ϒ =|ym~G|oeT3!&33w7$Ҡy.?`9+pN6lKjy+~&{bznʏ;n\-B*lKR' ^ID )h  Zh>9t:eF \hd9eˌCwրJ^%Al ej&WR1G 3B>RȮa*ea1|(AN8f#zOP|*D|)%#,\s΍ iyDaI$L˲M0Bc<\؅u,"^DF%hҠr4D9J|Ϙtznx߬]Ӟd >W7+ӟ1?`N.)+OcGO*EY嶶wD;"w}}ZO$'#f"fV"IHm :Rey' 4A<; `E…iCœD'AlݴAk[aCbݠ,E(֜Ҩ[?%xǒZ,D6)'`z%| (>",_r?6 oRRve ->x\))|?sR]b:GvMw9J+KG1 Pݍ=b9.DdEMz׭6xD;g~L%SEtF ^n \T#Asa1 p"@j%@,kEumEba4}!E;veC2`8%֏ػM:#(Q]Mh[Jҡ*(W,S[):MvtA$OJTv*D=fMD ?jN2AԎ_!hS4-3=m?;7xm89y|{֥i33iF/X`)f INlq''yyPS|̀y7TBĀnW%X [>qqBDM@|ZtB)氂Σ{Xo]3 >Oi? MOFەmze _0`aRΚXwy+v#xB;~FᠢLѡ$4_ՎqI+\zuQj.AD[oz %8`A hzd:6bÊke1"2gY/(뫘 Eo.M(|UT 3NVkkd2~}֪_OT` :A!3NLXlXY~(TMfSQJ3W$⪸|c=h${T(Wt)U6 1Ǥo27._7?Y4 wWYNvwK_=yaO7<Ǘ̧w Sf6'z^ϟsaq.)s%x\q'M>x]6A9GR/Ͻr<[lnpe>=DHMxp4"ywO|\֜{>||Q9|yC}Ӑ.m+[X鋷fu=,x^k3wo߸i>}f's1ygVI|j`7A38=2vw:Jfi"$)ܳSAg٬ ((7Hv3;XQ|acߙ/`sf 7a;S ?:)^w?~|eyJ^t}xޜp#Y!-^,)$rG*AZ); n0 @jk?!¯)qikP||ۗC|` _6$ $Ib%//;"'JvCm<k115)m`FU߿l>Lm=ZN)<}tyY`\'>?aܡLF"Noe|ڔsJ8NQmVG\V]Nui_Q|$} << ^L+DS:Hŀun f['0QEc%+I"3B,g l#P܈g?dYL[~_SR2@ٙ `tF|_kCQ um\_E{!CJapEa$u(LI(ZLHRA-\U{W¼z = .7w̓/̛o\1/>2ݘ;#^t E  z7ie1 o%j\>cHMyYt%L#(Xo`-V >:{TkM'wzGc% 3a(~ҿ|5ĸa*jS()3ա=o+=I*RiYQd9J_̞oV5rfR|Y 9 ؽS8̙7Q 7+럥cVWv.{uOΛLLG:lWMLb_ ˄8(|i*K"d;qXz:AV?Ǐ;A'{9.^V@2yx~FDs5тcJo*W@ V(j*iѫ`A 뚎Aqґ,50BR`Z@EnmTkVORq)=:' T)37;cn^aܺ) %AN%@90U#'Ӑb7ݩEձı;!m}z&<9BGjqkB@!K44/O!tr=SHE]c!n:xXb;dmU>!BI=ZĎjΠ"㵪{vVY@ Tʈ 㿸Zhru09l=2 BWm'(+YG)bPsآ{5S 6M22*v&R d2 s1 ]_z0/P:s8;Ec]Qc?OҥM#B\TDxlRg#Ē\(T?c+o;۶O9%r^G/ͯdh~ᬹ:9IL"p9PG Vw͓B;rN8?Oc/}|w bp,{y=Gu(Y r))WQT:=6J& :'B0gu'ق(Qܭ= u+dϗp-9':uT^9`Y[NmXn|Wc`©Q ۶8'`4V8 V""z`/1]~"tzT"l㌵UD" S|JAڀg;j U-suvlՒ6EQnUZ OhMCgPS"S8?]|Ԫn|@=aFYo_}m677$c/}l+_gw̕#2&RL"0bxj_Y]5+ \i 8$h fiqU?"* U L8ڳΘj`:juI@m5؏CQX:\%:~do!t"W4QL*XoekCf UC 6pCZpƸ5 hC$S^> 宭 R!#34$`r1<գN "kYo)ӈ}[jGL{#ZV;T 6+/⦓D*Q9I\KMFi\ )>tODm+E1*S-,BJ$UI҈? >E\ꜙ50Kx__}_y7_je5WͅzO4qG` w}b~[1/^b=w' kYs (#0ĚOL)r׹O~GJuVP&:x oK6I:#cȫm_6^G:0gƺNzv32urrrHUZJO,9(atA F7#Qgݦᨽ9 ޛ <էTk5t'B*uo#-䊪S(W> @ )RdSk'U2Au[ƊĦe(HiBSw^R[ @֩!<" 7:x[' \Y1kXiyU\57/Nl:AO\DED+O*+Y{׊'F'^ݸv_2Yī[33*[5gv ůtU(p a8"QäToy&RH,l"UBM갇LPu)ISV ~p k #PqЕ:/?1w^-FF (m:6DfrIۦgk* x"5 6 1 ^I9/1 YTS*%銌:. H]˳u#{!̚WoygY]YVީYb}U^ސUX2.[͕ysg:L"pX[˫D[7O_l}X2[;fgg LSb¼v-whǗ'|QUu#.lZ;c-O&2Yqp/ѿepaqa7N3*qd}skt?U'8`NoK"z, e0egi~OJT$.Rx[rU)-ffպGRA9R$Ss"@@nc{bBlaXxFdRQ }2*ҕ"ĒN􀮎+vbcm^PưL4}ۨ<?B.QbFu",W'”x^c`Q2_IJ:IŅERW@IDAT4o,`@'׏W̟< onpn>ғ$g+;n;}.ŦxEj o=ylHjyz4?g>7]3K8s X+*mطktZ:g^O@p"@ݭkD.z@DMfJN&j8So`XGY/dqm{!f4o[4mV~c44rwpxsj!Q_ oC<_e6KAdu/QZvOъ: d B ̀[.\.A0:d"3 W?*0]Sc6.6X]$ rYխS*uAbRbVTU"&󳖲& VHKwO~rf=~hvH}2#8Yjos?c~x*`\rd" wj8ͻ3S`u@uZ+|S;N}6*q[KNlBGʝz jQXBGG]8;wo1Q9?:ʉv졭}SX1@cӘ1b^qFTUݫ13O>4D %EEy{_jGfy x*@|櫕*ڎ:l{7^GdZS.bCjC>a%4&hFbYWzD` ~2/x(":C A0E&/b@6%Sf@rJ[\԰t< j&BmRۨ 3Xzu}зTpFuεOޓKI*W =uU1U+ ^& CUfcܑus~<E]2/bS~ʼ[ Tb}GWDgK]hgͥYO݃TݪA3 b" ²oa*T[!+C tBp\Ws3<{q,m9@9xz|wQ_ SXk"%Y37ILrR#Wvy7Ʀ.'K|ɧ%9V??wE|vuLM؏3F3 uE$DM?Q8 @|yGZzR}[5SPBȢH]qå= @طsVɣ̈́Bqev%2IAa;w 90lU]cuŀZ/KLBPD"% VGHJ C Z|_^cPLT?)멜v %!PYpG̵l:!U{M]&fZF$N{d3<.^1!cfgͭۗyIi8u$$#F`ozZ->ǁ뷛:&d?bJ9pիޭfbSKJFcWc8/Ih \ܔ:X%E䢢JĞ [vT^JGb+՘Iu=+vof12ideID _{|4wߎyt<}nVV63l߼]3$ffq C6Wi?R9R+ َ\=#sR{?đ?sG}v&5GW d^E/ 8j} 'JrxhLX\N34Xpۄk56Xo[=fQXt`Nc>|_k@mISrΙTVqQLU興wTG#tzv&yy]W\naoMJ">y8lF jgP=sw=vA-"X%"@IYErBHHe(^:ia<'<[6O_╀0!0ǚ#ė.`2✙+"70AcNNخXY E^7o7+vHlY~>οKp}1fwrj9$Դ<-s3+?ƝMǡ g8i%B>K+ƵEKxGܩz&A^gHl|^RkWh}W Iw0 I깳 S;rЪPE.qqTGq -;0,[6|ˠI¥&E#2uj>q}G1R!Ld.5X_tCF!Ao۶zUq3 Ŏq3ܰ(4V,zsd+y~(L*Z״K+~q GKO,I!nYJ(Yk Z LDJ  y< 0m7>X]]Gę`ϕW(05ǁ%~.6?4\c`n~,rGy  e_D `}.;*[Â||ƒoö1 Oq MmLUqp{/㩝1i7 ?F?6iiHŨO!TQyuhysQ2z</*C}dt03=S9;7y<&f yLh X N,wIc :ůMPǸَ9t}{skE-k\d0;-jO7+ٷw>geAx`  G6Qm ]M$Fp̠oϙxy&!Àя5pn&r7L#W+>Og(CU]qʊK/ IJ/#f62]+m9 Lč~y#"O2 +}Qi9-9P%g\rl YhCܽ0`@dxu=;Ĉuƞqt.W6zpLR4;#Xfd2.9d>\A46.{Heu*&< X$ S,!ז"m/ڜza9SܢoojO8P׏(Xo0Bϊ7/~j\m׿2/_>_CQ) !GګkeZ>H}UpDZjr 1ٟ؁ 84گM$.^d3QNʘ` q+/)g2=.:(k.i ɍFR)pS)-}Vi B\¯ađ$" jG2B㎹G9 R|ZNK"s RJ~6NVBGBT}iH+DtճJX>bn> g1?94S՘nZrN6}i*= Y)V-laE-m,KL֫P 9[YCvzjwήooo^6xz2H"^]IďIjY|U o~X៓rQS줍wL=G" q>"'5t,AD첀 %ǚbNn`:yD=Pu\x<3j5!ּ4هe2xqH* $Ǟ0Ow܏+̺ǡLE1!?Dl"$HgDU̻)Њg6KR8%yAAMZ/,Zڎ<&L[g9xAZf$ò턂7l!F2e*$bQISYoԶusEt{X["bOUdUCqmūūD[]i+h:JlyAn}O=1Kc$L"0Vx2/,-n+)?icnHkrr0Pp*9>*dd#WRQtl 0tQvqlYY0m֠Qv=EFTM)@G E%lf2'Mb@R(W'uWiDMKq(-x9u\ў<{wjO ^hpq2VnM v0VR+q"/~pOТo}nZJLT uiR'w 汈,=7=hgwxɳXY؍\׊#$L"0 ?trV_20_lA~g~=RòY'16I._F8hء vԈB''r&NBxQ(uR1}&t{4散KwGSMb[p'ʏO9{u,g\n7w|wQznzH-XjWv|O" b \#9z0 hg<{V%uݠ媺gOŨ ?屑LB~z}߲Og-,|bWJ/_>7kkkX-]ژN"0$fK_Bpgwzrg/"thCh`6t(S3Z,F9Yʀ[h_N,+L!W108>.|ߕ/7d)*,XHU< @ f5( D΢k`!bBc)2\!Y0^ j#=KEzDAHܫF HAT'O8Vy` b&[UIǧ!g1Qo8bV?C5P)&hQL"ɟ* B4(?LJ~m5(OZkR\;aߚ`^yi666ijo(p'I&D+<Lc?3gť֍0ax~~|=ݮtz's#c¸ڏ4V:5lmHmz6‚C aRŸv"- jiն4Q)`*c *:'MPU԰I|\J- \kRA~pXūG@B KK<:gz':-eL(i*q{%xa׺-v|jszb#-X[:dCH݋I%ԨǏD~nvS6m;vyyq9љD`4 /u|M gfuM NBK8Q#vh?#v8rJ㙢%`Ut̡p O@J;FJ9жa=4>\zs8S)(k}$~xP" =CEseqr$||ͣoY_[螋8dSI&8q%?1n6fs}&L"pF">w[o˗.y;us3s5?GgO۷5&I&x"`Wb~ e5x)Sy,#WI.TED =ΦG>H>GR=Rc#yvl¶m`x;I+׸戫~lL'-kK!C@l#ϮDaaD[Ɉ6z*S-KMBkE}cDvS,R]t\`RnoS2w3[K䋒ꠜHw<HUHNzcFu#lX=0^ЅXС^1nZQ=0XJ\K ֏>Vr1LC`ܴѻs_X\4o4/^4皣hVjѨS^r&M08CM٢$9F{}gBWŶ^Bl_qC0Ak}~ BDQɉ]Ol CRo=@%TՊtBjNXv9O[;9aVڢ9겚*Qlny3j4{/E3LlL"pcL.`B ]dplFB8K PpgW۲_q4зD@QXTjڷzTXrR4{NJ$F)qm9Nx$VV{*_6cBI&]n{Smmz7&1cǓ RsjL>>$4gJգD^aFMuGȡ4uNj}.g?ypw˗feܚL 8)O"p #gf>|I}˾Fwgm_"Ӈqp| WZW= HrK*E,UG9(<֖>Oĵe^g< vGM{KMG=HljF̈H(6ʼnC[a:bWD<1cV鴛+y Xq,Ԕj؞QҔ?:ѷ^".IT bB2Ckf˯}yWO6wk?zાVV` ~2< tU| ?3w"ik"9 QХ̥ղk5=cZc[R00ƴBӕbT< > >:qMkJd)=Q8fkX$1$ƆUų|c#V?YK:VvfL(-ؓ6&*"6x}m.j[۞,7ۄ݂%h"Q2 pbIoin!8v.* %kb(Va ^_pQۖ_1\ !Ap ^(%H /\ .ml]WWSG7>l|N@]jj>&l][~/nA{%Njc kVu2)oZkJW}/,S?i|VxgHӄ>#et5&YYj LY IL-TaX>]0N`kWu4NRThVlLŮ'I#h2ʩXTqƚLt2sh:t:ȟ;F-<0yg`iCCA AH80g|E^YK6!hDBw?Ϛ44{Asr|R9@Wj*Ob:ܺ/?߀mK_t .]?T_]o_4*8Q*G )!Gf*?J {~28$LesdO0f^q6 HX$5 hy]1%7]|.'`&OH{/+I_q!d#d} @^@ f >BP50= zv^ pP8ٱ!o8 n t (#$ .KtM@!+4Ws5O6W:Qk"J)D 'K9 TODAOE(ڗWx:>QZg?n>{yQۜ_=[pVynS؎p$onl4x nqjs O`F{R%vndX(k9%Ra(Ar6tiEPs4F?Ka"^.\"s`nq6qLj]2%y*Τ3:t@Q ذQ(ot` WjL:NWE"eE#bsF /HjSl1ĩ#36Et=*"sl_0y\^p`nvZKr/$`_pP񽠒!uS7#Lk `(}KdjDZf@qCPTcFWX]7(aGF}_=5ma3 ht UQylopۃOI+͍2|~fY8T 0Fn*` !Nɸ괌 %1%:[dWs'̜X 2 ^Q @&/8RBA5w۠--ǔ30t P݇i.vK |04۷f&RO(k"H%[- ^0/.IsL70캠 .u61ĖY O; m!.]JO/l!T Lègd6Sxa[[OZ_mqs)z^3; K$R|> _‹bnָMŢͣo&Eh+g,#VVek(o>zuX\{OxgjׅT`֔$A4 `\(Ik[4ԁ~Th&&+l -3p8/ckQ]InP@[rmEzuNIa-J s;FN 'Ttn\@LXIb'gCP( ֧͗? ;{ O? DHBXJS-x`&>K;[Ώ_Ay5% mRU#YHBBs;.:4UC+qbE^=Ay^{tux"H9$8lUִ'\>44n[T If?)]cS` ^#[tWn m#3"@#SONBvhaE67N_:_ox^hIf{w9 ,}kx(ExJ%x8օ-x@zFmM:8! 1pZׅ;K-?C{~5P{:n ;c0<"&v{a:0VM SډCMq"chqȈ\[o(q*EETC؆af4S-DW$rEzN"Pq}-qTh8V1X:PyzI?Z0'FL(FϖaBׯ:Mi[ ZE}2rA F({(k;Tk+gfi\õ^~0`y졭UvFc&]o^xA諾f;&pppE%F~nղV[d~ni'@ |pa /'hp+f֍Tbv׭$Ri+aBzbX)ARN+Gr&u63 S5mF*[0lYExQ8?qb#0Uz,l2cm4ha6Jn oIr\YW"k3@6=:6s+=W˛۸wT',s2Eݽ`@ըLh#X(1DPK$/zKV*Q[p4j9.ư+Q;v \QL;wEF1X nVXX \pRIW{ 'pdi _m"Qs|r75ӓ#p 8 UZÿ @VL\1 n?  s;zB[{X.0o*ץbu"TLNĠnpT ‒kŘSV[jmsYQ])4AeY)@(؀(diQ d|ul?}}rf{>m1!(-$>i`7|CG6ݎɤHIHjZ۾0"~ta_Y\WI ҢP vpb3?P _ qb*FbLV"B0%5G\#_@QKdH´9+f"|rxG$+DnXrZ'}xϘu[ 0~ő}쌺{EOS_"-بۖ9=M |SBoŻ(rJ` W{ ޮ/S7;x'pU p.htۉelmYxw|vgxHO_9ˀJ€9id A}9;%eiⲒ;ǀ%ulð\ʙ`$봝POXڝpM p48  D%Sszι}J [,5~z;C 1RZu@#Hrn>hn#+CVM58N#qv!:%&z2:OT(wDSPrvƠ3H| HX+^jmUT6|@>%Vsi^'1(L.>QqH۰C5nVsD.[|f\ ?܏'O)\LŻ NY+ژ<JTLD\, ܄OCwnLJW]&\._\X߄ԓNh뇹ܾ/@Vś1H l,PDrzuk!Q+:$ۊeWL^S{4mr7\m@/aӼ?b3TRW щ 5٦Z_ Wq$҈fgK{V$#-.v)k/LwdHAXڦSxrTîa j-I'fi@ŅRjRVh8$Gd%q0^\d>uDmքz=u h*h#` Y&H!g&A0n@$vp1wC6=>! pg / lp$@pP ?;X"B6rtw)?_' >( oS|~Rc{p${}yQR]W2lV5YG9x~,dF$E-L IAVE:V0P* uMO-P>_gp)%#'VK[y?_'''f<78 /T0Ӈ3Z<A%/N瘍usxBZmƟO/SE8auلo)^!c8 4gџ51YB8~K_r8ˮY\lYI w a4 +_I߲o H$z`ԐBn xE,J̎z8@n B'rDcj, Á(L92TJ dqIIQÄZk P690$yjPNfܔ/>coWMD9D8ϛlPn!?Er`}q)}􊾶=\Yʽ:iW )G ʖ:D $*% 1#MLM<`"pkI x^+ Co>kN $>uw-nn{z⅋BgR 7FN7'4hKA|p^Kܓgrܻ.(uC x |LlY+-cz#w. bVy!i9ڀ&^d+un/`q; XNڜ.-aW@Ruѣ]l, Ѽەk*Ý2?QXaukQ;%`+!Tlk&TZ&L@f]hI˴6֨{k؜0/xBh{nEhŢo]]xt<3ř.Ffo;'pɉ#-W5#"8F&J C^!3 Ǣ `t/VSƻq]' wiҦflA{_!>GT#4jنV3ز'e─9|JCz Z2E2jLG 7wLn2^2Ԋ-_;幾e2cI(^`g&aw[pd3̓Ra@`߮81ʬs6tns"ԁ.")ĤN ^jhG6%>3fFD#u<} P;-'JȈzq6+|< ٻV$p˨ZJ"YCܚB>[ dP#GP\pq%M8871t˄<*]̀ǎ_ a}0fF+ҧ&FĀf6ŖJ#egfKs5 * GbkMS̗ƪc}L-y䲸5tm4c<;⒞bKcU`CyݜXW$rZV!,y<`Iϻ kuj3O!-< l' <̯ E.!UjH+mKD(Q?fg!ѕ9~@|r$e /el"#z(n8V}hu<ϗM;1ƫ#z$ȫ**Hhvb=+vK1$9#)46S %R &Z3Lx&⬹ps̜&VK'' ~a CeDO%ZYTY[BmID -I;T[SFȘ""M􈧣2c4TWFDʹlb/aZ+/;U90W EDGx?ft3cʊGJ\h=`,el$cTv! eF­O)RĞfp$&O3(<7-L:1ݢ [ɀq>i N}{^O5=}IqA4 ̚%(qO9'ԪxH$eN}S}D!+ 8 Q-k O! +?ԡ"lQO-w՗L.( C'\|G_QRj]) ia &q gu](ۣ4r\s:@'1bӑ&4I? ŵ:V^XQƵRL_VZY2=STpz d4&@0KKw >/YHQ!r(L\~2*L?UN*o GFرȣjpr ڸ>A}-/LR@d'׺}u˶^L[i/0zv(jf)޶,hZF89Xe}H8x-EBֵ%o%X ʙp2۫'Qȉ6۝8 N&y_Z[L8zJox'!gkx>ڞA=H4`7UjP tt}77iZWq P 6F̳A=Rh_t}&== UF~l4˷ ^1Npa>۰;ƴ2M-q?vԢ͍s\ Bi20㮒SG}~#gZsۡօpuvk%wHk?sC[{b]]l=plBr˛sS+묆fYeyEl"*3hÈ7m~dD70T+x6V$h#>@_l@% jkv֋G}[Hr\C桖~RN՝JUver܄oFp 1i{7 *8=:n<.&fuD?q CU^`ƃS]! v֙BVj` j5O ,X-'c̑mt =d[N:)AEU1ez`L$<+KvD#2)o-셠cZ?> phyyVHyZ7S QEtG4s9;]5tOӚ"ivΒqaQ&`VA>}q=侺J0 #uh:Ͽhseʛ;k#m+>fX3[$F LNVcwd^8BH3dTwlZmXR.oiቫ8fƘ‰=v^N\@&a]P>|KiDY 'LrDx@. qŮ#"KX[nBt3p͐]Hnh#lqt!j?̲Aɱ]U莡=c' Cs݂ޔcDȦMXS kdM(mЋ|\ ;\ h"@g 2f%vhR4W^%wOeP BPװs} -|/@~!4~ m,%G7nîldѢ|&Iz 4y9@f]9knD@ Rnr9̊j86&]6lUj;k-/h(61gf 3iS9cu-ShS5h =%1pQ , ,iFT l#qDĨ6l6nuH Ͳ o!%'2u At?qtsVZ[Iq׿-Dvo ᮮ"/],E9h4ǡs2űORQSK =XWar^IARȜFgB>;i䒤AS/DIÀw7EwAyxVzC_N0+8ʺ],etkjve Ҙ^X2ޞT t>}jbOJlUb$;ڇtZ.2ےqufD5LY`􈀥v3d3"x\[)iqCC)4CFJ\H./FפHH}jcSK;BT,11 ķ|ˬ->%C$w=J$1#z8W}pvfDHl<7zGʈyGFaZW ;%OJc4.%@Yk Xh#X"xa`<-Q  vi[S+9Ysiê )ORFFG,x){< hEmڒ,KG9 P`Nܞ,0:Ϝ٨ssӎ!sOZ2.##"Onʲ[)uV.V'=h~ 1'8 ;\zLPHs:--/K|; 1nBp$n< 6TGY]4:f* ɥR(~ 9ikRQܱQGY>`L#ay01(2c\"ɹ+aĎ{CUM{ 8ponv q@F%%fp9u&g!f8tBE} ]V/Ec@D"(ru E@Fl{#Ҝ3k'MvjOyp L$C.oKX@r_H8.Z\C} Sӊiga<"ƣlHZ0bԌK!A- 25bs<.SR%anQȈ re^ !oњD}-|ƚn2?2?&VDz3(]Uu ҙôvA}j>Oi9;DZ pZ>]jI~hk=fM9<N.^X1V# ~avG8;I%I{FEpMmj[lrcPZ˜m4mFvψ`+^0oWIɍ\ܷ$V tpFb'2W<:)񐂉itB%hdC`*Jd}('3IGFDZ A1 q\ ߠ `dX!cMfO70S=)*܆uwf ډ7@$v]ZE-*ΙW99=]&6].rzGA88]:ΖM݈"RDr`<82"!/q\,]t*(k԰nEixۨ*2'TG Su<H-@ѥ _vniCF"kN0c!a^]} X.ЫgOmZw׾`cؽ0b63Y9FHl-=hX#FU̯µY_KuFw \9u\o ~& 35r#7gǎo702] 0QDxKXPALIHڣUܒ,L+qwMl4GGGBu>Lŧexӟ ;zowˣB{<_Pm`oDk1њGȕQP!/ VHaK$?B+&gG_k>χZV'h.gn ms\;۴]w Evt$k=L-TꃙwCS{vI8GßdCkӖdהq9zG˶cl@eT.n#{EQH#ݢ 6eppx#Vc7p?y e~Q'YIGg+63i?sS(DXLJ0ԣ5B,EA%FpY%p 4)XgNH0 8bt9P/~EF"Z\qSkh,HFJsdYL,X"jL 3bN /#ƴ2cd١c:ےsB5X}o=g*d++<3W RWLd&Wh8Lqvcǩx.2V\kQ!Ç#O=kNg ]ݟl|FuIpǐGZֳg;6 cʝL֗'ks4휌\:Wz^.tji5x_--5 >2u[=oLzvbGC,Jҙ5H"& X؎By NJ v J1j븏n RA'b:>5+7JD xeGWcv/`5)^VFMҦ9 lN+Ջd.4"FPW*d([Z#ΉЯZ»4d`$ȴ*Wn5لt?iVHb:\;ɿpzOޛ/!83 ?n _QA[Wz!uM1\, rڥKm9IwzN= stAV̟#)d_[e".$Ou=[ڨoR*QY2ٱ\܀O On"[ 8WNaYĪ :} @Mm&Z0J|۲€b>y[fBDl)yh,DTz HcmaZ4NUS|ؾ>8+6muUxFʛEsP# c^)ƂpjGb'P*u^u@t2Y9nn߿ܹYsrwSM<,3^IҮd{Ew *=)យlZ cfeyܜxz \mM&p Nm'+0/Rd5`EY9yo%+?lK;ݣH3"@U-PBIR; SzExNĘΞ69tVԅ,.4iuB ôI DxRG9Ȁ5U.ӅKz) k8FA"áT2|ETQ`W̅co;lF D,lua EV@n##^j u u t6'v$%e}j–z Akb[o1pP]7=DrEWZb9gרicfJQ rj䛴]]}e`LϷLc=K,L.|Eֽ+?΀i3o>~j|4͛<9<3W>p?V'H!euܸ>/{8VTyܝn8Hrw޺IV%ypi |-{=h'`tn H2D>a ;AX[tH:;Qyi9]$}4P8gD 'ucM"v!tm*=6T'PR\$ @V,)i:I^`(A#]w᛹KJI˻ܙ&FL00R02EQIh!tXbTxw5ޅOr_ş*f3ﻫN,t],hv 2N 3Twе |vVCMZ+TC`4Z6RFp\1*;\u zqB T97B㊋yH2_m'"$T &8s'Bnk3!HQbf08UR`7-1 +<^[5k$g/dڗĭ]*-uNd_"ɒ3Ѧum끿NdYs#8ٳrN`Ǔo~xUB-тx?xZ;|~sM2)Lc"C(e*`u |)yԻ?6SNw9Vm~O~wD󤵠9l:dcRM43frhws8I(aa;M[HDN85<Ԇ:6`#EDሦ>6v[am4gR1&:urajžF #-^5 w7O̻"TgRx8ڹGZCG"Nmkݓ颅b|t$RT S;VbX °_0Ĺc݃{lrZxAU@! .GnZРsY*f|"\Tk c[Ut[a3˨$jmiLKp?qsp>LJGk^[j1Wӓ+W/ ?}|7/2t|q/Gt5f޶[>Rb.,-ECW1P**G`%ָ5[W<px,Lo0kYK,QH9h0 ,t(jxtZeHnq:b:nA0;kR:>Djت$l؊ۢ#8ѷ]f/ $u DnUctMŷ \s V.Q;36Qc薙 S@<S8ԊV," 96|] }1?N$7Fs~KO:!/?n~|Ve&';G'Ν ?1|{0{.Yf{n7f g3ƪ>ad}-p4lPƑZX`{,bzEÖIn E5C*ZCj!bܢGp#F/^T툹n)G,dk ? :%݊gءIY(N.X0~Tw8!0xU%5IDAT>C(9r1YUȜs\@=!syV|)NO+@&6G?Kh!7b19~pyn7l.^L+CO̿:]QPNfV:7mKZEi{地[jC^!,+&, @q0xe|49xɪC@Gt9t}S֐Y N7003wkp 0ɗ^R6[>o.eD/*2]8[CC_D(+a6lÖ&;ƒ urw j0LE!a"ԂL#mlv\K^2(I 0dFbn422c4y _;7o2̎7/nf郝+W^`߀lr,Ѽkŋ:sz Ɔ/ހ3|JL`pcei#8.g8A.XZ' Nj`9֐s,#Ƴ5!p i[:tô*5'u30Mt&0,m0iZ!^l2D.i͙t/C Σ$VN#lS5J[vrs؜P=BE< 2:[57n{}W -6nN0ۼ@ɉ6ݼLtM}O pd* 1m 8ate.`} FS 'Ց1PÅ X%?e zXN˾ptb2u2$d\ ܨP|w]wE/6!sIJ +6(Q-dTN2v 3L,tw\u.ED5k3MM`wlHVRyzFSF2`A RѓIBCZx%eda }8b%h Ujhx1%0pkɝMl'4_"EoK@ě3‚r*m8E '9C;gK32 8N޶; 8ጐ !3<2\\;wG^^q$-\&PѾ7ͻxd.. ի=/@ױ4rt|ܽV׾\|EGUz^ZFg vhDw ajBdA23Bj<7Q z@bt> 98ƱT] x1~X2?+7h1 c2Mn0o' `\|vzfgHL~j 416Ȉj7chZ=s3Sz((Є!d=z o-,LVqT0]4&7cs :qaPb0syB|hblX@:>\+t;(*ڟGZMe'{~ܺ{.f^Ŵஷ~;_/uw^A  W5+zvh &Y4, [aLf=pcMU[$mG6fk2{0έ'4ώc:_ ̮\yt̿k ֝N}J'>iv` x(~x1F4D 4^aU+YhԳo;]$AA2Х(IhOjX5uCDMh9ڀдSJ5XАu FO?sy: 4286M% 1>!4KnucDy%' EXG=_cI9.Z쇬tNѻS d,|N04ZQ'E W֝P(o-ۃo7޼7x. |[v~^k0ꫯퟞN~{+;g {N 8i66W0 Z`%ZEPM3^ ;խp0ۣ3E$X !2S߂%Т1V* *^ZMY8FFÞ-.2'=K>Q<טUTikb1XKs8A՘Y4zQzYu:YQ]L]L.+jyכdvqiCJ]CıKK- p4I&x*qBEƮ3w0(I9` y5T0VXcFp@/ܾh&AD|Lyֺ+'`8eyk>Rd5FflIU\*b0vl[I 0P|*|*1`8ܲcpsH0 '/nhƵTTr]Q:14s0?vG_sg$mzن,;&qj;67܆{CPW釖oO2k*X#dM";KG516 Q*7 ŎJ_[t@ h* :V_YFJh8^+kx4| LDLmPzraɍ;?A-w4yos"6O5‰),||3mskkOɺjgw.]zx2~\XOӭA in9g6!7(*W)!֔sKy8} 9 q`e:d1E(8rJ!_ D"JOs 솅輧A2/dAf^yv!"=dt;|Z1dהgA- yf]'ZX?8;(vSnxYVpfڨLob#}.I:On"d U!uZP!Ta1͍ll%r TlVu,UTMp] !gOwCdvd:{ۿ~p~ `׿_:dּCܾ|H>+[oua69pI!,؀\ W^Zs \޲2sl\"]P gdOrpb`w PZZZZZZZZZZZZZZ6O|\Ƹ w6t2A"F@@@@@@@@@@@@@@^6vvOzOUytko\g|p., .X׵gpd>5o:;Ç?BN=zt~ڵG[.4 QZZZZZZZZZZZZZZYlL~<][އCVf!$w_ګpE-]+"YjjjjjjjjjjjjdspMeЯ/N01rE߽gGߟNWYu@@@@@@@@@@@@Y|M _N?}.v۹~ʵf>'|p!~1.+_ɤy i3սz<dYW??;p!߂"4\bո LGpA?kkΝY9xsoֽ^NIswL.W"eZ;˫9sֻk7.\͛o f##S׿G&?9]?&C.Ezm Lno7|&Zkfկ~jY Vu??_o_5?w\ Fws Q;9;ETO?L'Ь睝Uy.N|ͭk_:6m:k< : +ZZZZZZZZZZZ%V~io^h~wmm>p x [N7p/7uTA'R8 x~:lOuN!#, _xŧ׷.N&pN'k?;7? (y9s35ZZZZZZZZZZZ'fd>ਙdGހsz/ύ7߼w맧5xf 35pe:  .=xp!hK'UկZZZZZZZZZZZZ+0o\ O6vOAd:߃N&'O\o}x9dlէTn]/ݱ 0ФT,:?)(EPA&tzqpK~|rmۮ}߄ұzlrU)BsiJ+3 @Kgե<~!rcucS_H @ @ @ @ @ @o% bIENDB`ic11PNG  IHDR szzsRGBSIDATX W[o\Wm̌Ƿ4ō&irDIJQ>PU+/*!PU(*RI!!8-)Nv8o9osƕ*`콾o{<0 yvypm> Z%z9m7+\ڲ-1meMnwx۶䖱>b De!Sx9,lo@|(| #`qT#;S`U8`S#R 418C.2V*5Я"[ ɞoZH_5=Z˄+lB5Դe^ƶZڀս ΢G@B2' mT.fi Yor ɭ/@ .F$buqKjG똞|f؉_Хص΍_B-}Iv{8P*Q|ں){i?n6jy#HS p{=⦭ؼ)F6Ε9Y8o{T&G@,KhCTFG1ۊ$ s Y3 ZH""SYL*~Y^yN$J;H,|ɄXUHoіŭu݄z[fQAE$'U;QHX*V6 94ڹoy]δA(|G@ /I?1;9F@1&ds9 h$$tGU4^ƤbK=7' a"Vwk K}Q!B|;7N#L 8Vv A!knѱ kcb.v3.prNg3kF>va-ޕۘk!+kXXZc,,/]ҬөO3&?5mN (~ _|ׯM#qq{"fdfK7N`$g1ۼZFIqye>8*đLl9`\sR7 'r_93?|:uchCPqя?+:}\H!E*| D7bUHY7޼S{3DܱOxnk^ QZIic_ı32v{GtD#P"D媬*B6đTA jFv[Pė>{vnoҊ] v^mUf#1O>sͶ^^`_]Ϥe| fUEӡvƖMu,@J wndHBdDiηH $p`fD R$VQ˵u$ 13K *]Z  ZgA"w7(9cGT?< N U*oō>T\WGO8="zW2+_C1畩0AL0$BPe R vō=\ZZ2;H4K:H'n?薑Ok@ 1 #tXF0P !0jT9{۫h4RsfG*'$(#օqhMT3U-_Ͷ{cd-ܜ^±C\[XDF[%~&V<}?~?KXG*$.`-xswß_#g0^[&wD| h7]f0OIqpq=> jU8<8O.߅]pc+#QZ ܀8q ˄w_l_=ܵ wi$<> #WZ,{0!v4T51g \H"ŪG?UZ`;<eg ;Ofc8?%-IENDB`info bplist00X$versionX$objectsY$archiverT$topU$null WNS.keysZNS.objectsV$class TnameTiconZ$classnameX$classes\NSDictionaryXNSObject_NSKeyedArchiverTroot#-27=CJR]dfhjlnsx}napari-0.5.0a1/napari/resources/icon.ico000066400000000000000000004055771437041365600201530ustar00rootroot00000000000000 f (w@@ (B00 %  o hPNG  IHDR\rfIDATxWey&>طs7h4I ԌQgs\/lx-ٞ<[4FCh"f$A@s9g]_W}9jW?T*K_SL=! ^wcK_+ wTL;!^ףd<2\Z?qyHtHDguCe24&*o\%C:C Ơ@0^}=8C:$l/ÂmK_[g!Džj~/ފ$.]~/Ӯ!!9y+KgK_/Ӯ!!|k .=מv)*1.]~@HJ٩LOLZ\с_rHMAN۴?/vRP~0H*'0984L+9Rr:J*b0N! y<+m4JuЀRPC eف@dZ`B`bhS*pZJ|8>đy;]_[Mh+owEL y&t9PNTYC<2;O\..v0ȩiΟxreLM7p=8r;*:Liv M5ErMO(rA`{'A]J;R(bPCUu5ēq0} %þ 9ROML챣Erw:D3.AF_+ȧmc+ߘokVUm 47٩h/8)=/X]8?㏡ hsRHѰOHѝ[ " l:dFs0gc|̵ ^$"hw՟ 4t*H|zзm|ǖqfn1}Հ_dKHo^})Z]`H~4hU@ŖfW/Cm6ctO.[ج#M"~I+Yǘ 0,XQw+5  +*VTc')I:#a_0㏧,^jN^ܼE"Hm޿tLSv-d6(Ox(MQ_/}_7ïFKbϝq= kH-f<͠ F3Oby7P,osTh C v%2Exe;q;Amu:u:h5I¢Rj'&Q+dth`zt6 @ `P5Aa@" ="+\9'cSp]b34 BqsЎ;Y!5vwvOτN*OY(>WW|B ~Kk rJq9m$S,S)+4]=ŭxxo(nZ8|UsJ2~V\"$cN3]鍼$=<>~Xؾ.E zӘxb&0Wÿ++W?~~"huQ+=Boao0!|gřD adKWpaPCobuq¬,Mw> T::dّPE1bTh:wjٿ2͛x@x++C; (GAr;RGPOw5Ah:N3a%R B;`ck *R z8> gPz\Ň ձjbh*UQU1EmX)Pij%o9>1?{hӗf{{X(cr@PJ4[~a̟^3\ ~]s(N̉'.w>N'*4w[ßkleP'fvt6jJ=_{Zn8.:4rg(wvг3EI_jۧaVo.jTtBeF!Kj +{~+ok_o, XU|U Z#SJB5N+_x~Qa{)}(PډįLcU,̓W< '=ΟR#%xktZnW^8P)[ßTߩG_bDOg}۔Est<ڧOaq.<翵}NI*]^5Xi+y(Qb}1n޿ShS+Ww>w6kﴦK؎KOTe1o",|֚[myjV->vnI ӃuC# +ِ q%7@@k/XM6a{ =}gj8s|6~H SQ{M#:v,8 Xt=d1~J> /wh~#45T ,'S[bo}dy[G"{.uܼDsg$0rGX+^W^Mͦd/J2K~cIx0ˁ42Dwv`a?yw_O+|ugpD YT$ P4Dp>M5g ~4.>VYGzP)KXw%sE@%d"6Uf^pCĽ^MG'k=#>7*"f @:,ޥ5H͏15\?6ӐĻթ*.{=VV"Ð$({:`#8(oht'X+~Ԟ)J+nbsk3\Hw>wWߵn4UXLS4TGc>LdH\R3HLzic`qc2*wAY.^Qrk2e r_?řN?owt:m[\ ($i8'> 8{2l6[[ |{+?fl[G'4sK FϟܙC/+PFZL.&I"v#dµ/[ 92J9B+ϐ:mRQ84;!OW_P8]POi *coɾ4\ ̔!ko۽R'WT k7m{e&݃lB{2͸[pvi^Z (0$IdkjyӀ\y7QmP&mWhu; gjϼ,{ܩF,ad μ!0x?o q;VXJR}- D͖{}sE:8S ^WtZEGwN"5: %Ӝ@@ns/@UX k7wgR {=3xeMKL g"+/pV1(eV}lH6ASXnHp2`Z.$G@[W {qkrke|ʊVvΚ(4mHܼh@VO7Rk(5 ۆ/Y]xH^V3ӏ8*}2Q?Py^+s7nb#;]WNBK.t7Ve_<)W)1@I j 9 %" >=׎TsI-<Wn!>/hzt"ygf3XW3DHKDmGu*@t5lv֊ ԏkjR2G^+y@_,BR)5XEH WP?>'|(OH0rcv ﷛ y[vj댙-nN- f"CD@@Vz*)H:ԱFPӈڇc/T5ca+ X$ռI=&'خc֢w0mZs;2cKý~+"AE|0? m c;S3ٜQ+3Tj ݆ N=%uYA1vp@e?(8m2'CmE/K};%TA`1zx04 PHy$3d+ۻ_Ҝ+zZ~U5V敖LtfTuɢpa5@+E40vO򅹼0!9Gg_=bpgo+H?Fcߪ;gFmVseu#^xOͼ .8#f/ N qqBjN|{41CrDOf?3)j{n O)*g'*?q-kGAvHTSwѰ07-~ٔq RO!; 5E{&iё%O>8ҭ,zsNYZ⬻]_q:rx$f@J`- "zƇM+xXɬhy>nC,?#[~`@Q0Sm7"޾_]0?5L4> !Gx$/e6Ze4SF)ʔ;vq)}H4Ff 韙Y%5^XvLKUheU?bD%?GS~!^V8kM~{3 bF%[-a !e>8r"P OB^ҹF&4Z) 1^27>ʥ3SU/Xc-kyYo}]"OA=F79!׌HH{;+Nȇ]3ZR%ZAZGeXm^`R8=\yf|V7D@@afY~)OD^_qx6 `Fΰ;2y-vU^/WoK|֌7ZB R yWes'4oVڰi&!Ei)# mL< \]/Phw={7[8Fk0}$ls?q fPn:Zi"yQ\$?zJp3SU=O]QRԧyJ g=׾$0Սxn±҈Fs)3NX*V"wcfW^r-7GzI5Y07a}&!+EМ I~Bizr?dz9䮓[Ҫۓ'Sp yiŽN M *ONTP)E;3Ugk>g[.e<*1vִb̜;&t|PD O6R0>Hz2;D 1=C4ܦ8bxR󎩮WK V=?6a\=)ӁWP-Ax?X4!dso mg8(dd}1r9?* ş_ ^Q|Oۖ?' 8 ժS ,k Ӂ '_EDlk&Q]Z.HSr`R91 ~ (irYtksoDϾ9<R3 o ^2b.{<av&J̲(ﭷe8{r.ض:h`~jm??"M!WA^\1 GM,P?Pv0 H~?Ub'RT:S̟t&~ٳY|[ .WK=D*[2]d{~G.!G!f <0R'5~3bGjXT@˳g|zMK=_46UӴ pOjgڳyV9F#Bvv?@R3Z@BSpZ.8҅~Yh?4 (?rdʹ$,EP.|(yy')iygs/S8@VHߝQ M<NMb )ïPkij D]M6DaXhZ Hc9X>R|<.@Wi'4Qu^)1F3hF`Ox*'s)4b9*HN ޗҹLWvp5Naшz. 259p 8{cd@S/E@?T*w5:0YIN3-,ae$[c}cS|_zoz0-f aj\RxsFcucQ" N;fƍHt"ߦLimmN2QnPqKA.Lq^,><݀cY ;f| 3*rp|^WǵhӌxS\ġsهgpY!`9"h n7OD8ƽGYy 0xbd?&?`=n9.?K̢V@3W@?vTD=|ͻ!˓8$[}O|LC&O^Q#%F*ZDiwp*b߂iHr l;(iAt0= hGƾ%fg` ZIؔm+KNyi ׀yҋ@&zv9796"V7gX=TX;eCGx16=}/|i< &ujRj ++~N50>Z{ƝǏnNwwşI3# Ol?PG%>mQa݀'A"rfLdKGb|DTWOvQ<<{&5Kr:E~so3ЮL^<)!N ؜=9x <(clx-|x>o`@GA5w_ h ; \~5rRdJth3C Ϝ:GkGwsot/_vb]|f`Dk)QΕri>(sa6Tr=, +,@}gz4[2~hUVU;yʷn@c5T\FZEVE\FZARFJTenjФы5t#C׋t+Xf8Q@\BVC%1zhBe0SQ{ X420=[xfb W{2h |>Ĺs-S1>qߍ#8.@s U-S)OL3,ٝ$~dkW}u ՇeJ5`H/Å37oV1Tn w@FQY\-#d\sUN3/FJJILNbnf 0lSUc%[xil  Te Xj02NE`nfwjnxҲŞЈnfT9n.pH:SaLO蜿mW7#Lq&epqϜV ۻB8(i?K?uqNIOK?/bt:]7ְnZ,Z V8oލO ml`g?x[8:zIG~nAVx0H"m`s8Ο/Nib}_s^]!4̐ІIY~4|Ik%QLXw%tc`l /_>fǝ.wvhm;FߜTmg_jo8 ޓc'191d,Kfn`˃ ֶjܝXeQȌF`*JA 3=GizmApgwq+J]p +4?W^8gNͣiw@LZk4-<^N:enɘ1 @K׷mcnj *6zqELM^(+3O@N(1m)<> P{S=/яglLNIڙRi^3j;BUޗZ SWʳ˘ot{;{tI0?B1çcl^? ?8dA|ffFu1n|K1Q=KS2hWq= ̓QɊt"S,0kjKBgmdi\d`mJ{]h*+F&*8{r\9W^8Gg05QE!nwIԭ 1Ұ0@7mco(RU˨+U#TJ Ւ9hh{h4;k _DCN.;噙oro4݀қ)h7>$z/rn^l;y,84~㍏" k-i@)r@6aЌܽL"ϵ%@''|oOv˸(DNv1]\LU035 L՜Nf&=4ͮFفV ]2PBQPDU*d SUL"TK@E(EEM/tblq.n7hAnt,N{.gUEz˯ {g_9,/,@IyRٚ$J@Wꐹ:߻# (¦m][λ_V ŎPVl IgJ1ǀuҲ pDG=@3M?[?}Fj}*xy!d7eU޿sw=E^];p4'Sdvk?9`b6*u,RgU 77rpQC+2' `vZ4ujԔXRTիQ.pu{]8J}v9~ =3vle_P<E 8f흝܆jwzV@)~sOOs=r/_*`bAS1ZSBaWG*t*}u_[͠RRx,>&6izD3 I'Ng 3/1:yF`@@+# kcwoyxUL(Vf xGg]a>6 /9 @}Ҕ,ix+"lʸݩv{U4uh"g^Zwr<&f& h #$DfZZ2(tPOr o)k׋g_ͭ~NzU3KU}`osw6 Q$;۫CEC#eMIcЅZ )T0ŘzV]CT:Ū^^TATARZR6JP;(w(uJq,ϿxN1}=,Π`|U8@4݀iX.al3n7F`l*+ExRxAnX㯾6ϝC3ɽ<vIg \ C\=ӚmO>2!3Or7ȔNA ;{PPʶNQuĨШe('#DznTFO+UЋ蔪K}fk}(, mEHUz-7Q} 2N=sO urhѨlho$4J"~PP=vo=I)j/>M$ ޟW9 dVCily7YC[\J1xWBz7T~a㒗4h4(Ŕì* K9#G9 <{ vRGo '֨bu6[ӟ=;/z+˘ɾ(ѡt@4,Zlp#vu(9RW\ũ' ֽM#{䷟*e35/kzM\?%胀 Z|ߖlk1+ V .( 4ki߂L`<)$#1NPթOU=Jhs?^{'W#D:q@aͅ!bCAm@E`.Fhf yc+8wf 6GmJ1R 'S> bͥ"4Oc}i2d?3j~e!0{H B9dXZx\ےًtu)(|W!5Wgpdq!Z^-iAנvpXpƜt kvbX.i `%p%\p0Z]?B՟= r&erGrս,o澻A4SB|3aP 93O̠$Zd;ϊ&M!.װܯ|VRZcl+2о94 J@  AE@vh23[,d̆ g_DT`K|C:Or{KpzDcm[%nLe9rA@g.f% ;TsK)b-DB_EGHР(|$lz79/:29W?\sN 0~JcccoY*3%c'c+lӂ5LOM㳟 V[ ?1>即2/{弝5'푶9a*O|&8V،Yb}l mB`C~)1qC!*)(N+uzS.+~9<#Fc?\p4 -[HT lpP 0R"4dJ~U;}spٻx}ID恗$>:[d$m@fZT0OU`TbweDc[}dߒ52}7RWNaq޿ 5clloRަ1\nMA, `~F3&_f6 (˸zJWjwzSsc2pX#Q|ŀఙ& juо8A^# RbJHevm0j=6f"y^>x] Wk^?4݀vcIIڀ޴;,00*ayj4X2 Aʠpi'>o5En?u.W8E2_ՐVXLDwHHQLt? B\ C)'?I4Xo<͠x0&BTFE4_X.;h;g-0=g,n`\c?eDŷ|@E+cM]^X/~07;[^?uCyRKgƧ`g~ |& 1d.HԑT&^vː lX\ gj;iJXvp`g\ZY^ Býy 9tgo13HE1@wLML೯~'(TN7Oq|Az,ͬ;Ά3ݧU^G`PP0꼘 L򫌊fǤc;۫Ϣ7dE Nk)M7PFSq'2 0#mmt(8zR29(ӳ\ ǭ+x八xN װ)43<)w^]|A-!s* JX#enslVƸ=o$=UdVy"s W k54[ϳGϫ ;YLT m*1bx ))֘+ȭkA|n}p|lP [QiY".2'f͙i V3۟M B)e@ 佩©c'ɫ(= ̼tj ՟O^D_Gx*p'وHH0 P|ʹ6#ƞƧØj PĒrg6%-D1&ogti-FiQbf`wOmfO*8;lw@&;8NGG$ PAؑt\`ß~w Tsb'{d-:ڒ`*$q~󽃎uیBJ,!(5*A.gZZۙH݈cQ[%1AOBhpoZ)h`)eLOiuJ}@;Ϟ $ T2@@>P큱 =y 9[ߗ +ǃI\"Xar V ii~IC}wޮ3G{q!`'%50¾XZ5mbzJKG`Ƥm K~i#p R :;A)/\#+7xw>{LV Z!*ʗOԤN`RPi\o[:(cY 9RNTϠxR i@Jqm (gLN&whQ":P#ہ9і.L Hgez6ǜMJ`fn];njr*&'6\_x>|\:>iozg/c&K8qd TP8uUf}$TS{)C _ Lz=a t+em@NiH h݀tv(p3 7w0WYtV!$[CRP Zp5 \<0mo=JPK12R8hRD Έ1|8`;K*mrx& 4{΂F̘;i HB[~1$,\ S9mc(,Gqf7ov[JTdMc[{`wπl_Wϗ)'sۇZ$Nf$]WM@cA!rfjFMFamBdBviڤ 8G[f?);>0]ͫV&qF@@͇j"%eYcƩ ~0Q6$DW<0Y rc1hƺGNhߖIŘl0ďe*VAiD:DD6&ܸ{l :Ν>lH|vzIRr+ůB`3}-U£rRn"f SZ,۹N.ueHk"k)$ V Xkqjy!i w᫬ Ff@}J8FC_JH ,\>,͠a~(t٩3VaLHTPf̅sCy0% 9Eg3 R?SeN%rm 2gRZJNjRLf}HHKoLih eF070zFG̰m6D?cr_`>J4 ՝;hOhC좤%mc3yH$Sjˏ;_%yoX6)X)bL0q5m-." x~RpܧEc8@ 3فɌD]8 @3Je 7-Xٳ炃JkySBy_V$/0GW@4,/:-QcbLѝTK|tְg1-g 8SIm5 0w*P :8#03 |U7+bvg,m efoC3DtTɟ]iɋĐ ;C=1pQK=Yo??&e}1l0"@!P>GaXx8(AgFcq.Q[XE8wlŴx]ye!NQiŸldX궤9dR^0K5W'Nf [捴mw,,FPB b#mBu'CJQf}5w7Jb|ܫ4 2 ,w3By'/Y&H}o)Jna^ѾܷcR JGF r # v< lD\ZL̵;p\}V^gO6@?*ũ `fh;|!g$=t4XPo]j"HBǼ:s r}wV(3LOL=#3 @|aLDoS0MIS@ G_<B[w6אO9>KAVYϳ&}ZlRKC ` A 0<dc0,lThP1}w9}1FuS/c ƾPhYa;ZA23SI$ -? iNmIEw]<Μ jmy62 !͵ɁJZ٧8ߧ& ?{%C>Ȟ LUs}hghep?Olk޼+;ST*טz4u'O+@a|ZdЫD OJ^i+1D2gNs}crnje x)8eI18N<5n]vV8P]2a;RJ.6(d1 Pw-h8ZK)"`-A0,D(?7s?,͒\>곤pQWf[@i )өAޙWPpd8`:rgm|xoW|vlmO; GZ/6,x:'?\Y@`;k+V.9g[5$}0:8>OW}6>OI-_୵tꃟEzm ,0hor<|@D0j}`x|~! "  sx> 30`RyX zg 4L&@5~[e#Me~3_-r<8, P|g758<-9@0Q흈|~4E 7!ڕo~j*fg`t [d<l Ie8`@UY{=qӴB eQ̡%逶7eYY:7u쵅-wT}"MS+ ex\ɷ$>Ӿvhq]#8x)Ig\#КoMTO5ڭ77p+4?rq(cȨRO*xaH8.8 Jq 4 G9=i=aҨ( íYpF$8/_46Yqp^o ?Q|R W^]sAHg FiG^w {lyII0#`14F3HYˁ|Lٛ*8,w|-#?&?OZ޼APk,LF2 sJ67NZ,QR:Ve0 ֬L8Wl[(=aȆ MsDk b7Eͷu)k5,9#ظi|Ӏl,[2lO;s@4˥OyR0Q~d@jd BTaVV5l 3bZc (cv Ϝf~҇⚖X -0P&tzs'N3)X4ڝyw}pY=Vmtf 4R nl )<y@(Oۘ l1 Y 6.nܓ7֘$R|HG(h1aPAPOO.g>;ZHo8Y@3eQzw,6wZxGa_":>EJjq&f*gO+ٻ?- ͛ ؔ3xy-T@̉1~rcp~QRHDx`e}I^|݉w?䙨uh)se 'k-4F#h;oc5o}xFXSǏazr)u8i 1Goz^64,u}C:ɞnY2U CZt (`Hr]nS w&>Zz䌱R.Ae|YWfXfd@#gzyvuSwxsUZ Ϟ9MZ c1(cQP @#0V1 p<}b"N\{,4YQs.Zu{c4r^\%A}*8Ln3?{r1 F@ʄNmOvAg1y]+7~ nxşR /Cyt92gH~eL "@T( @₀Ye5*<$%s 3OlP :.6m?޽// 4,w)'OڮYf}NRJ;kxX5zf34_@dVο:;ٻo/+GplyYjM;!}'wE&! -L+7Ho?|6SV$( dCPy&p}x>m ],Nļ<ʗukۿTpRmL="k tcc 4#uCOD􅽼8ZsCH&]=A/ /_K߿w)reF@hU[eVLH &v+-ܡ{kb%é?q _nwm>A)g1?3@y>4#\ /Wn̛ӆGqM0cRť-U˽*e+;h(\Sɗ_m^}<ޓVxHfPEcV5lAXN%AY Eb-wP?B%G6 H~8w$NJm04O`L-"V`/ :(PU]مx2;$W 0gCx@vK+jn@3pرv~|!C4<Wڈlަ@$YQ6zG('Z<1?-f]W{871a<~w🽅|;;L Q cڹ((dE6<="3 DQב2@\CP{@&C'cfpM ((l!TgP.s[?݃X,ma 6C%dGCp0rR@&G geC|MT4u?Z|o?Y\KY:?D J#;"Q@!sۼ Q='$Og1]ȶ"S@ ܱFA,-ի/t;x?S]\^AT:8JHvOn|-+TkX={:=_|՗E۟ґ|+(#Y5AT!Q"4YkT8R "-kp0 f@# wC<# #^@W|}':xn-ٓh8Y{Tv;Qk\v='#AYwk[P,,+ϛݟJbOGk(zq@ ʹgxsƤߙC,xV;͟8 M/ D~)P}] M-^-6 ]eX*?­H0!N6j`o?k(J+Kx+YgAK[vQ 44? W28~m uN!4X*lfLWɘ0LFtp۷3Bxweҵ>̀_w&*~~ڄr*@cw دb+߹?co/|'GW WP}H*ǹ-5&7NȖ>o`I@")mvmt^"+wߗTSہHmnbҰ1Sp8{4^yBMzױ珳PKD:)VddvpS-!;tkA?RƱoOm;x;s/ӧ˗QJbZiU3a0a4fWUԣsTh {[H}T0`bvvsdMF_ƹ3g ;]́rWq|Gr,$76i[N<'Ϣ'x6R_cɇ?dc=7w?pFGQ.^Λimq\k%t2W*ٶKgEI4V)d54"h'N#B `o14fPQVYRx>@IAOW*OR_װǫ`ןqrA>r4k`{;h#'m TҤQۑ e%{ic~>Uzjo޾?EJ\yGzg*|sf4@RTR4PN~T]'hDH ?s"R"yqpej/9fC0iyV^` r0?7W6oa{XO-bqL[pI:גLExHl|eO]c 177[xGb⫯Xc'K#j*`)j9tֆS+-}lz*)JC#JncM4&IK5i.Q D$HꩴN$3 #y* wZ. KmC)`jr+Ug{;#=O^z)Ƌ{XkWzt0c5(8»t0l{ Ժb/Lhk/}#sc׋:v />w娔Վ Sx4|L48" إr+jd̩$E¼*UUxH3MLS5"!1~yPP:qis feHj"r)>7%} [m|?ƣsOG-лK][5: LS\*Rc\g@JFRfK5;(:< kYO %5ZB'C!'2-bqns_[Hбo|{Y,XAhZ uXVQ^+4H-:.1PcZn.}ĕ1H@*" SdsqVO0sڔ0Gqe_ RWZ 0 2w&ݑA N-d 4@[=)] ^ 3DLْYQ@}h_|-5gʥsg@-T:XtUWB =D!"O H=e5*6꺅zB-nC݁GJ쩓x…. (jb85jmmNLg1Z*" @߈H;C[̙zSP)XeyHf 2kLsGC!R1d>^G~~`csP4m/|,w0]bdE>tt.]((A 8PV{ J3zI(2bXGO[7h^嬌!ƵA d)H{0 h jKla t&v&AUkX N)hd~>p| $9TcN֣k5m SFB4I$ί½;67{n᳟>zubT>yY^ɍUgU Õg/R)'UPˑYT 5Xo2 8UAA Jo@e:Dj3`VNg@9Ƞ SdUrba~ot nQ}|?߸Ot/?٩JOV7yw {۹WoZgϞŹSU}wܰi #;.u&[:;(m/\EA҃3Eh@:4zӲPF=U$6- fSD6E|[(4MHi{{_wn99&166[x{vq. T*ܩ8 9'5S矙ΥO'U;<"1u^2 cP[@T F*(ip̩?hV@A 5qȦ(Tu̦ 9C/2A'Voo~޼9p;]p 7>oyؑ)]$&0=YDl8h4hkcs&6Xhb}16.Q\36tj Y>'( &wTd$oLD:cpqϣ.Al h[@CekA$>DjLZ yFAS/Q*CԜ 2 4 aL b) sc|7o!RۍhvKORXٓ'pb娵_Rѕ;qpTd|ƶآKR k0P+g?40d1(Dfh`j|tX\,lJ>6'_x ww&6h|i^ɣGqqNO!lhpimH|J5koBZ tL +'~\l̒[MaTaPqtSf0$lLڦGsI҅QRE8} >~;; Vi^e8z(e}c߲}.6  3h @ {3 ld~|o$~5ܷK!hD#M2yA g(h[i1#9ZBTƕg/ٳp)GgʥfqtyG1?3MqvQ>eik>_ß><+g/[6 ÃCMcTg)daa@~R4d miJTlvmFR sŝpqC.}T*07=y,/.bav6qa6#O=Z$h!Yx@0&'pI?Mu0pu8l $MiigipQ ֳ:&Bl 0[QfR gO™h6p}{h4v,sPE*2&&073,bvzQ,Hd:!f>9Rah&im#+7?Ǵ 8C)LnSqLu3Oh+O^g43C<]`& HN'p9<{h6vwwf}4-tڝrzzzS LNN`zrSj}=HTڭxmrgq{igsӌ AeeszL0MfwtRq5FzA?b8+(Zi ) k4q#5+]*M3QcV4jjnBkvrN@%Ҽ\.|jYV{BVxd8]!O3v1 Eb 46 FY~y&'pt[ Qf]ĿMɥ4(#-שׂ4e'(l2)tO*a>a./Ig|R?lJ䞕WOO1,;.Q8@ iq'Qދ30GUrrC#3xҔI le 4"p Y;%3%lIPc'_X.v]-oIzUd>PX*̰xBIykϟw7*a_C@e6rO83> =޵\O)/L T"" -kn;E [7>5*!'ib u.ݗ:;*4 !4mjN5H {ù饌2ytK1lGw˩Š,~+lF~$B[/ UIZ,/SӅ'qq>(9"9Z^(], nk$-MkL~j‘ *0}A~&vvszxt<nox~?`=߫s<3PVTTHʙ 9.7@qM8mY\of2AaFE5!ڝpÏ^3x-,-?D,&~q)Y^6D9n,> %]3N y'1 πVҽ*{}}"$ASHF&B:5ZL0WY{٠sRɋgWm{lF$&Z[En6ﯭoJɻKgo KxV oXX fB@]-/1XbA} % /4h8R bz 4uwx2+1d}ز Z(ZOXy7+;~VB.]~"˥2N<El,)6) QBRtY2+=ny;-wv{kׯ]SMsHtHOvkk/MgkvHtHAϮ_{Ky oJ=!z+HB@JkKgq9pHq}z .Ov ׯ RZ[ \¸N:C:Ah }A_&YOf^=Gv!5' vvhl6|~/x 3L=:CMC_^C:C:C:C:C:C:C:C:C:C:C:C:C:CJ8IENDB`( #.#.=(&=(&2=(&a=(&t=(&v=(&v=(&v=(&v=(&v=(&v=(&v=(&v=(&v=(&v=(&v=(&v=(&v=(&v=(&v=(&v=(&v=(&v=(&v=(&v=(&v=(&v=(&v=(&v=(&v=(&v=(&v=(&v=(&v=(&v=(&v=(&v=(&v=(&v=(&v=(&v=(&v=(&v=(&v=(&v=(&v=(&v=(&v=(&v=(&v=(&v=(&v=(&v=(&v=(&v=(&v=(&v=(&v=(&v=(&v=(&v=(&v=(&v=(&v=(&v=(&v=(&v=(&v=(&v=(&v=(&v=(&v=(&v=(&v=(&v=(&v=(&v=(&v=(&v=(&v=(&v=(&v=(&v=(&v=(&v=(&v=(&v=(&v=(&v=(&v=(&v=(&v=(&v=(&v=(&v=(&v=(&v=(&v=(&v=(&v=(&v=(&v=(&v=(&v=(&v=(&v=(&v=(&v=(&r=(&\=(&*=(&=(& =(&v=(&=(&=(&=(&=(&=(&=(&=(&=(&=(&=(&=(&=(&=(&=(&=(&=(&=(&=(&=(&=(&=(&=(&=(&=(&=(&=(&=(&=(&=(&=(&=(&=(&=(&=(&=(&=(&=(&=(&=(&=(&=(&=(&=(&=(&=(&=(&=(&=(&=(&=(&=(&=(&=(&=(&=(&=(&=(&=(&=(&=(&=(&=(&=(&=(&=(&=(&=(&=(&=(&=(&=(&=(&=(&=(&=(&=(&=(&=(&=(&=(&=(&=(&=(&=(&=(&=(&=(&=(&=(&=(&=(&=(&=(&=(&=(&=(&=(&=(&=(&=(&=(&=(&=(&=(&=(&=(&=(&=(&=(&=(&=(&b=(&=(&)=(&=(&=(&=(&=(&=(&=(&=(&=(&=(&=(&=(&=(&=(&=(&=(&=(&=(&=(&=(&=(&=(&=(&=(&=(&=(&=(&=(&=(&=(&=(&=(&=(&=(&=(&=(&=(&=(&=(&=(&=(&=(&=(&=(&=(&=(&=(&=(&=(&=(&=(&=(&=(&=(&=(&=(&=(&=(&=(&=(&=(&=(&=(&=(&=(&=(&=(&=(&=(&=(&=(&=(&=(&=(&=(&=(&=(&=(&=(&=(&=(&=(&=(&=(&=(&=(&=(&=(&=(&=(&=(&=(&=(&=(&=(&=(&=(&=(&=(&=(&=(&=(&=(&=(&=(&=(&=(&=(&=(&=(&=(&=(&=(&=(&=(&=(&=(&*=(&=(&=(&=(&=(&=(&=(&=(&=(&=(&=(&=(&=(&=(&=(&=(&=(&=(&=(&=(&=(&=(&=(&=(&=(&=(&=(&=(&=(&=(&=(&=(&=(&=(&=(&=(&=(&=(&=(&=(&=(&=(&=(&=(&=(&=(&=(&=(&=(&=(&=(&=(&=(&=(&=(&=(&=(&=(&=(&=(&=(&=(&=(&=(&=(&=(&=(&=(&=(&=(&=(&=(&=(&=(&=(&=(&=(&=(&=(&=(&=(&=(&=(&=(&=(&=(&=(&=(&=(&=(&=(&=(&=(&=(&=(&=(&=(&=(&=(&=(&=(&=(&=(&=(&=(&=(&=(&=(&=(&=(&=(&=(&=(&=(&=(&=(&=(&=(&=(& =(&=(&=(&=(&=(&>)'W??kRTmTVmTVmTUmTUmTUmSUlSUlSTlRTkRTkRTkRSjQSjQSiQSiPRiPRhPQhOQgOQgNPfNPfNPeMOeMOdMNdLNcLNcKMbKMbJLaJLaJL`IK`IK`IK_HJ_HJ_HJ^GI^GI]GI]FH]FH\FH\FG\EG[EG[EF[DFZDFZDFZDEYCEYCEYCEXCDXBDXBDXBDWBCWBCWACWACVABVABVABVABV@BV@BU@BU@AU@AU@AU@AT?AT?AT?AT?AT?AT?@T?@T?@T?@T?@S?@S?@S>@S>@S>@S>@S>@S>@S>@S>@S>@S>@S>@S>@S>@S>@S>@S>@Q<>F21=(&=(&=(&=(&=(&=(&=(&=(&{=(&=(&=(&=(&]EFٵصش״׳ֲֳղԱүѮЭϭάͫ˩ʨȧǦƥģ¢~~}|{zyyxxwvuu~u~t~t}s|s|r{r{q{qzpypypypypypyoxoxnwnwnwnwnwmvmwmwmvmvmvmvlululululululululululunZaF21=(&=(&=(&=(&=(&W=(&=(&=(&=(&=(&_GHӯٵٵشش״׳ֲղԱ԰үѮЭά̫ͬ˩ɨȧǦŤģ¢~}||{zyyxwwvuu~u~t}t}s|r{r{r{qzqzpypypypyoxoxoxoxnwnwnwnwnwmvmvmvmvmvmvlululululululululululukukuyenF10=(&=(&=(&=(&=(&=(&==(&=(&=(&?*(ٵٵشش״׳ֲֳղԱӰүѮЭά̪ͫ˩ɨȧƦŤã¢~}||{zyywwvvuu~t}t}s}s|r{r{r{qzqzpypypyoxoxoxoxoxnwnwnwnwnvmvmvmvlvmvmvlululululululululululukukukukW]=(&=(&=(&=(&=(&=(&l=(&=(&=(&]EFٵٵشش״׳׳ֲղԱԱӰҮЭϭάͫ˩ʨȧǦƥģâ~}|{zzyxwwvvu~t~t}t}s|s|r{r{q{qzpypypypyoxoxoxoxnwnwnwnwnwmvmvmvlvlvlvmululululululululululuktktktkt~jsC.-=(&=(&=(&=(&G=(&=(&=(&=(&tZ\ششش״׳׳ֲֲԱԱӰүѮЭϬ̪ͫ˩ɨȧǦŤģ¢~}}|{zyxwwwvu~u~t}t}s}s|r{r{r{qzqzpypypypyoxoxoxoxnwnwmvmvmvmvmvmvlvlvlvlulululululululululuktktktktktktK66=(&=(&=(&=(&Y=(&=(&=(&=(&v\]شش׳׳׳ֲֲղԱӰӰѮЭЭάͫ˩ʨȧǦƥĤâ~}|{{zyxwwvv~u~t~t}t|s|s|r{q{qzqzpzpyoxoxoxoxoxoxnwnwnwmvmvmvmvmvlulvlvlulululululululululuktktktktktkt~ktK77=(&=(&=(&=(&Z=(&=(&=(&=(&v\]׳׳׳ֲֲֲձԱӰӰүЭЭάΫ̪˩ɨȧǦŤģ¢~}|{zyvpwks~go{dkzdjzdk}fniqmurzr{r{qzqzqzpypyoxoxoxoxnwnwnvnwmvmvmvmvmvlulululululululuktktktktluktktktktkt~kt~kt~ktK77=(&=(&=(&=(&Z=(&=(&=(&=(&v\]׳׳ֲֲֲԱԱ԰ӰүЭЭϬΫ̪˩ʨȧǦťģâ~t{s]acMOV@AK65B-+=(&=(&=(&=(&=(&=(&=(&=(&=(&=(&>)'D/.L77V@B`KNlW]zdlnwoxoxoxnwnwnvnvmvmvmvmvmvmvlululuktktluluktktktktkt~kt~kt~kt~kt~kt~kt~kt~kt~ktK77=(&=(&=(&=(&Z=(&=(&=(&=(&v[]ֲֲֲձԱԱԱӰүѮЭϬΫͫ˩ʨɧȧƥŤã¢ycgS==>)'=(&=(&=(&=(&=(&=(&=(&=(&=(&=(&=(&=(&=(&=(&=(&=(&=(&=(&=(&=(&=(&=(&B-+Q<=cNRwbhnvnwnvmvmvmvmvlulululululuktktktktktktktkt~kt~kt~kt~kt~kt~kt~kt~kt}jt}jsK67=(&=(&=(&=(&Z=(&=(&=(&=(&v[]ֲձԱԱԱӰӰүѮЭϬΫ̩ͫ˨ʧȧǦŤģâ~glM87=(&=(&=(&=(&=(&=(&=(&=(&=(&=(&=(&=(&=(&=(&=(&=(&=(&=(&=(&=(&=(&=(&=(&=(&=(&=(&=(&=(&=(&F11]HKu`gmulumvlulululululuktktktktktktktkt~kt~kt~kt~kt~kt~kt~kt}jt}js}js}jsK67=(&=(&=(&=(&Z=(&=(&=(&=(&u[]ԱԱ԰ӰӰүүѮЭϬϬ̪ͫ˨ʧȧǦťĤâ¡x[EF=(&=(&=(&=(&=(&=(&=(&=(&=(&>)'@-,E56I>?KBDLDFLDFLCFJ?AG:;C22?,*=('=(&=(&=(&=(&=(&=(&=(&=(&=(&=(&=(&=(&L77hSXjslulultluluktktktktktktktkt~js~js~js~js~js~js}js}js}js}js}js}jsK67=(&=(&=(&=(&Z=(&=(&=(&=(&uZ\ӰӰӰӯҮѮЭЭϬά̪ͫ˨ʨɧǦƥŤģ¢~glE0.=(&=(&=(&=(&=(&=(&C21SPT`iqk}swxxxxxxxxxwrk}bmvY[aMFIB10=(&=(&=(&=(&=(&=(&=(&=(&=(&E00ePU~jrktktktktktktjs~js~js~js~js~js~js~js~js}js}js}js}js}js}js}js|jsK67=(&=(&=(&=(&Z=(&=(&=(&=(&tZ\ҮҮҮѭЭЭϬϬΫ̪ͫ˨ʨɧȦƥŤģ¢kTW>)'=(&=(&=(&=(&=(&I==eqywxxxxxxxxxxxxxxxxxxxxwn_gnNGJ?,+=(&=(&=(&=(&=(&=(&=(&G32lX^ksksks~kt~jsjs~js~js~js}js}js}js}js}js}js}js}js}js}js|js|js|jsK67=(&=(&=(&=(&Z=(&=(&=(&=(&tY[ѭѭЭЭϬϬΫΫ̪̩˨ʧɧȦƥŤģ¢]GH=(&=(&=(&=(&=(&C1/clrxxxxxxxy|~}zxxxxxxxxxtbmvLCE>)'=(&=(&=(&=(&=(&=(&U@Bzfm~kt~js~js~js~js~jr}js}js}js}js}js}ir}ir}ir}ir}ir|ir|ir|js|jsK67=(&=(&=(&=(&Z=(&=(&=(&=(&sY[ϬϬϬΫΫΫ̩ͪ˨˨ʧɦǦƤŤâ¢VAA=(&=(&=(&=(&=(&QJJtxxxx}wnjjjklmopprtxzxxxxxxnSQU>*)=(&=(&=(&=(&=(&D0/mY_~jr~js~js}ir}ir}ir}ir}ir}is}ir}ir}ir}ir}ir|ir|ir|ir|ir|iqK66=(&=(&=(&=(&Z=(&=(&=(&=(&sY[ΫΫ̩ͪͪͪ˨˨ʨɧȦǥƤţâ¡V@@=(&=(&=(&=(&>*'`cfxxxx}rffghijjklmoppqstuuvwyxxxxxoPKO=(&=(&=(&=(&=(&>)']IL|iq}ir}ir}ir}ir}ir|ir|ir|ir|ir|ir|ir|ir|ir|ir|iq|iq|iqK66=(&=(&=(&=(&Z=(&=(&=(&=(&rXZ̪̩˩˨˨˨ʧɧȦȦƥŤģâ¡[EF=(&=(&=(&=(&A-*itxxxxyydeffgghijklmnppqrsuuvwxyz|{xxxxguD44=(&=(&=(&=(&=(&O:wdmwdmwdmwdmwdmwdmwdnwdnJ56=(&=(&=(&=(&Z=(&=(&=(&=(&jQS~}jp=(&=(&=(&=(&p~xxy{p\|q\|r\}t]~u]v^w^x^y^z^|_}_``abbcdefgijkmoprstvxy{|~¿Êŋȍʎː͑ϒГЕxxaks=(&=(&=(&?*(r_gwdmwdmwdmvdmvdmvdmvdmI56=(&=(&=(&=(&Z=(&=(&=(&=(&jPS~}|fOR=(&=(&=(&G53xxxztf{p\{q\|r\}s\}t\~u]v]x^x]z^|^}_~_``aabddeghiklnpqstvwy{|~ÿĊƋɍʎ̐͑ϒДѕšxxvB0/=(&=(&=(&`MQvdmvdlvdlvdlvdlvdlvdlI55=(&=(&=(&=(&Z=(&=(&=(&=(&iPR}}|zJ54=(&=(&=(&YVYxxxzn[zo[{p\|q\|r\}s\}t\~u]w]x]z^{]|^~_``aabcdefgijlmoqrtuwyz|~ˆŊNjɌˎ͐ΒГєҕӗxxUSX=(&=(&=(&P;=vclvclvdlvdlvclvclvclI55=(&=(&=(&=(&Z=(&=(&=(&=(&hOR~}||{lr=(&=(&=(&=(&lzxxy~yn[zn[{p[{p[|r[}s\}t[~u\w\x]y]{]|]~^__`aabdeeghjkmnprtuwxz|}ÈŊȋʍˎ͐ϒєҔӕԖxxft}=(&=(&=(&B-,uclucluclucluclucluclI55=(&=(&=(&=(&Z=(&=(&=(&=(&gOQ~}|{zygPS=(&=(&=(&D31wxxyreym[yn[zo[zp[{q[|r[}s[}u\~v\w\y\z]|]}]^_``abcdefhijlnoqsuvxz|}ÈƊȋʍ̏ΐВѓҔԖԖ̚xxs>)(=(&=(&=(&n[ctbltblucluclucluclI55=(&=(&=(&=(&Z=(&=(&=(&=(&gNP~}||{zyxK55=(&=(&=(&WVYxxxxlZylZynZzoZzpZ{q[{q[|s[}t[~v[w[x[z\{]}]~^^_``abcdfgijkmoqstvxy|}ĈƊɋˍ̏ϑВѓҔԖ՗՘xxE66=(&=(&=(&eRXtbktbktbktbktbktbkI55=(&=(&=(&=(&Z=(&=(&=(&=(&fMP~}}|{zyyxhn=(&=(&=(&=(&k|xxx}zxlZxlZymZynZzoZ{pZ{qZ|rZ}tZ~uZ~w[x[y[z\|]~]^^__aacdefhijmnprtuwy{}¿†ĈNJɋˍ͏БђғҔԖ՗֘xxMFH=(&=(&=(&^JOtajtajtajtajtbktbkI55=(&=(&=(&=(&Z=(&=(&=(&=(&eMO~}|{zzyxwvbKM=(&=(&=(&F77wxxwnaxkZxlYymZynYznYzpY{qZ|rZ|sZ}tZ~vZwZy[z[|\}]]]^__`bcdegijlnoqsuwx{|~¿†ňNJʋ̎ΏБђғӔԖ՗֘xxSQV=(&=(&=(&YEHtajtajsaksaksaksakI45=(&=(&=(&=(&Z=(&=(&=(&=(&eLO~}||{zyxxwvs{E/.=(&=(&=(&[_exxxwjZwkZxkZxlZymYynYzoYzpY{rY|sY|tY}uYwZxZz[{[}\\\]^^``bcefhikmoqstvxz|~Æňȉʋ͍ϏёҒӓՕՖ֗יxxXZ`=(&=(&=(&UADsajsajsajsajsajsajI45=(&=(&=(&=(&Z=(&=(&=(&=(&dLN~}}|{zyyxwvv~u}s\`=(&=(&=(&>*(qxxwwqwiYwjYxkYxlYxmYynYyoYzpY{qY{rY|sY}uY~vYxYyZ{Z}[~[\\]]_`acdegijmnprtvxz{}ÅƇȉˋ͎ϏґӒԓՕ֖טיxxZ^e=(&=(&=(&T@Br`isajsajsajsajsajI45=(&=(&=(&=(&Z=(&=(&=(&=(&dKM~}||{zzyxwvvu~t}s|R<<=(&=(&=(&OILxxxvi[viYwjYwjYwkXxlXxmXynXzpXzpXzqX{sY}uY~vYwXyYzY|Z~Z[[\]^_`bcdfhjlnoqsuwy{}ÅƇɉˋ΍ЏґӒԔՕחטיxx\`g=(&=(&=(&S@Br`ir`ir`ir`ir`ir`iI45=(&=(&=(&=(&Z=(&=(&=(&=(&cKM~}}{{zzyxxwvu~u}t|s{}dk=(&=(&=(&=(&hxxxwvhXviXwiXwjXwkXxlXxmWynWyoWzpWzqX{sX|tX}vX~wXxXzY|Y}YZ[[\]^_acdehikmoqsuwy{}ÅƇʉ̋ύяҐԒՔ֕חטיxx[_e=(&=(&=(&S@Br`ir`ir`ir`ir`ir`iI45=(&=(&=(&=(&Z=(&=(&=(&=(&bJL~}||{{zyxxwvvu}t}s|r{rzXBC=(&=(&=(&H;)'=(&=(&=(&cnwxxwvfXvgXvhXviWwiXwjWwkWwlWxmVynVyoVzpV{rV{sV|tV}vW~xWyW{W|X~XYZZ[\]_`bdfhilnortvxz|~ąLJʉ̋ύяӐԒ֖֔֕טיxxUUZ=(&=(&=(&XEIq_hq_hq_hq_iq_iq_iH45=(&=(&=(&=(&Z=(&=(&=(&=(&aIK~~}}|||{zyyxxwvvu~t}s|s|r{qzqypxT>>=(&=(&=(&F88wxxvogvfXvfXvhXvhXwiWwjWwkWwlWxmVxnVyoUypUzqV{sV|tU}vV~wVyVzV|W~XXYZ[\\^`acegikmoqsvwy{}Ądžʉ̋ΌюӐՒ֖֔֕טיxxQMQ=(&=(&=(&]JNq_hq_hq_hq_iq_iq_iH45=(&=(&=(&=(&Z=(&=(&=(&=(&`HK~~~}}}||{{{zyyxxwvuu~t}s|s|r{rzqypypxrZ_=(&=(&=(&=(&cnwxxwvfXvfXvfWvgWvhWviWwjWwjVwlVwlVxnVxoUypUzqU{sU|tU|vU}wU~xVzV|V}WWXYY[\]_`bdfhjmnqsuwy{}ÄdžɈ̊ΌюӐՒ֖֔֕חיxxLCF=(&=(&=(&bPTp_gp_gp_gq_hq_hq_hH44=(&=(&=(&=(&Z=(&=(&=(&=(&`HJ}}}||||{{zzzyyxxvvuu~t}s}s|r{rzqzpyoxoxjrF0/=(&=(&=(&I?@wxxvohveWvfWvfWvgWvhVviVvjVwjVwkUwlUxnUxnUypUzqUzsU{tU|uU}wU~xUzU{U}U~VWXYZ[\^_bdegilmprtvxz}ÄƅɈˊΌЍҏԑՓ֖֕חטxxF88=(&=(&=(&gV\p^gp^gp^gp_gp_gp_gH44=(&=(&=(&=(&Z=(&=(&=(&=(&_GI|{{{{{zzyyyxxwvvuu~t}s|s|r{qzqzpypyoxnwmvV?@=(&=(&=(&>)'k}xxwvdXveWvfWvfWvgVvgVviVviUvjUwkUwlTxnTxnTypTyqTzsT{tT|uT|vT}xT~yT{T|T~UVWXYZ\]_acdfhkmortvxz|~ƒŅȇʉ͋ЍҏԑԒ֔֕ח՘|xw@-,=(&=(&>)&n]eo^go^go^gp^gp^gp^gH44=(&=(&=(&=(&Z=(&=(&=(&=(&^GIzzzzyyyxxwwwvvu~t}t}s|r|r{qzqzpypyoxownwmvdLO=(&=(&=(&=(&VV[xxxvjbvdXveWveWvgVvgVvgVviUviUvjTwkTwlTwmTxnSxoSyqSzrSzsS{uS|vT}wT~ySzS|T~TUVWXY[\^`bcfgjloqsuwz|}‚ńdžʉ͋όюӐԒ֖֔֕Ιxxq=(&=(&=(&D0.o]fo]go^go^go^go^go^gH44=(&=(&=(&=(&Z=(&=(&=(&=(&^FHyyxxxxwwvvvu~u~t}s}s|s|r{qzqzpypyoxownwnvmujRV>)'=(&=(&=(&F9:uxxw|}vdXvdWveWveVvgVvgUvhUvhTviTvjTwkTwlTwmTxnSxoSypSzrRzsR{tR|vS|wS}xSzS{S}STUVXYZ\]_abegiknpsuwy{}ĄdžɈ̋ΌЎҐԒՓՔ֖œxxhw=(&=(&=(&L88o]fo]fo]go]go^go^go^gH44=(&=(&=(&=(&Z=(&=(&=(&=(&]FHwwwwvvvu~u~t~t}t}s|s|s|r{q{qzpzpyoxownwnvmumuhQT>)'=(&=(&=(&@-,nxxxveZvdWveWveVveVvfUvgUvhUvhTwiUvjTwkTwlTwmSxnSxoSypRyqRzsQ{tR|uR|wR}xR~zR{R}SSTVVXY[\^`bdfhkmprtvxz|ĄƅɈ̊͌ώѐӑՓՔՕxx\`g=(&=(&=(&UBDn\fn]fn]fn]fn]gn]go]gH44=(&=(&=(&=(&Z=(&=(&=(&=(&]EGvuuu~u~t~t~t}s}s}s|r|r{r{q{qzpypyoxoxnwmvmuluks^HJ=(&=(&=(&=(&>)'fs}xxxvlevdWvdWveWveVvfVvfUvgUvhTviTwiTwjTwkSwlSwmSxnRxoRypRyqQzrQ{tQ{uQ|wQ}xR~yR{Q}R~STUVWXZ\]_adfhjloqtuxz|~Ãƅȇˉ͌ύяӑԒՔՕxxMFI=(&=(&=(&_MRn\fn\fn\fn\fn]fn]fn]gH44=(&=(&=(&=(&Z=(&=(&=(&=(&\DGt~t~t~t~s}s}s}r|r|r|q{qzqzpzpyoxoxnwnwmvmulultgnR<==(&=(&=(&=(&=)'ajrxxxwvtvcWvdWvdWveVveVvfVvfUvgUvhTviTwjTwjSwkSwlSwmRxnRxpRyqQyrQzrQztP{uP|wQ}xQ~yQ~{P|Q~RSTUVXY[]_acfgjlnpsuwy|~ÃŅȆˉ͋΍ЏҐӑԓ˗xxu@-+=(&=(&>)'jY`n\en\fn\fn\fn\fn\fn\fH34=(&=(&=(&=(&Z=(&=(&=(&=(&\DFs}s|s|s|r|r{r{r{qzqzqzpypyoxownwnvmvmvlultktzagI32=(&=(&=(&=(&>)'airxxxw~vcWvdWvdVvdWveVvfVvfUvfTvgTwhTwiSwjSwjSwkRwlRwmRxnQxpQyqPyrPzrPztP{uP|vP}xP~yQ{P}Q~RRSUVWYZ\^`begiknprtwy{}‚ńdžʈ̊ΌЎѐӑԒxxdpy=(&=(&=(&H43m\em\em\em\en\fn\fn\fn\fH34=(&=(&=(&=(&Z=(&=(&=(&=(&[CFr{r{r{qzqzqzqzpypypyoxoxownwnvmvmululultksqY^B-+=(&=(&=(&=(&?,*dqzxxxxwdWwdWwdWvdVveVweVwfUvfTwgTvgTwhSwiSwjSwjRwlRwlQxmQxnQxpPyqPyqPzsPztP{uP|vO}xP~yP{P}Q~RRSUVWXZ\^`bdfhjmortvy{}‚ăDžɇˉ͋ύЏҐ̔|xxMEG=(&=(&=(&WDGm[dm\em\em\em\em\em\en\fH34=(&=(&=(&=(&Z=(&=(&=(&=(&[CEpzpypypypypyoyoxnxnxnwnwmvmvmulultktksjshPT?*(=(&=(&=(&=(&C22k}xxxxwdXwdWwdVwdVweUweUweUwfUwgTwgTwhSwiSwiSwjRwkRwlQwlQxnQxnPxpPyqOyqOzsO{tO{uO|vO}xO~yO{P|Q~QRSTUWXZ[]_acehjmoqtvx{}ÃƅȆʈ̊ΌЎАxxk~=)'=(&=(&>)'hV\m[dm[dm[em\em\em\em\em\eG34=(&=(&=(&=(&Z=(&=(&=(&=(&ZBEoyoyoxnxnxnxnwnwnwmvmvluluktktktjsjriq`IK=(&=(&=(&=(&=(&I=>qxxxxxdWxdVwdVwdVweVweUweUwfTwgTwgTwhSwhSwiRwjRwjRxkQwlQxmQxnPxoPyoOyqOzrOzsO{tO|uO|vN}xO~yO{O|P~QRRTUWXY[]_`cehjloqsuxz|~ÂńȆɈˊ͌ΎxxwKAC=(&=(&=(&M99l[dm[dm[dm[dm[em[em\em\em\eG34=(&=(&=(&=(&Z=(&=(&=(&=(&ZBDnwnwnwnwnwmvmvmvluluktktjsjsjririqhp[CE=(&=(&=(&=(&=(&QKNuxxxyyxxeVxeVxeVxeVxeVxeUweUxfTwgTxgSwhSwhRxiRwiRwjQxkRxkQxlQxmPxnPyoPypOyqOzrOzsN{tN|uN}wN}xN~yN{N|O~PQRTUVXY[\^`cegjlnqsuxz|~‚ńDžɇʉxxx\`g=(&=(&=(&=(&cQWlZdlZdl[dm[dm[dm[em[em[em[eG34=(&=(&=(&=(&Z=(&=(&=(&=(&YBDmvmvmvmvlululuktktktjsjsjsiririqhpZBD=(&=(&=(&=(&=(&Z[`wxxxysmyeVyeVyeVxfVxeUxeUxfUxfTxgTxgSxhSxhRxiRxiRxjQxkQxkQxlPxmPynPyoPyoOypOzrNzrN{sN{uN|vN}wM~xN~yN{N|O~PQRSUVW]ky~~xxxxbkt>)'=(&=(&=(&N:;kZdlZdlZdlZdlZdlZdl[dl[em[em[eG34=(&=(&=(&=(&Z=(&=(&=(&=(&YAClulukukukuktjtjtjsjsiririrhqhqgp[CE=(&=(&=(&=(&>*(aiqxxxxznczfUzfUyfUyfUyfUyfUyfTygTxgTxgSxhSxhRxiRxiQxiQxjQxkQxkPxlPymPynOyoOypOyqOzrN{sN{sN{uN|vN}wM~xM~yN{N|N~PPQVk|yxxxxxxxxxxxxxxxxxxxxxxxxxwY[a=)'=(&=(&=(&A-+gV]kZckZdkZdlZdlZdlZdlZdlZdl[el[eG34=(&=(&=(&=(&Z=(&=(&=(&=(&XACktjtjtjtjsisisirirhqhqhqgpgpgoaJL=(&=(&=(&=(&?+*fs|xxxyzj\zfUzfUzfUyfUyfUygTygTygTygSyhSyhSyhRyiRyiQyiQxjQxkQxkPylPymPynOynOzoOzpOzqNzrN{sM|tM|uM|vM}wL~xM~zN{M|N~Pf|yxxxxxxxxxxxxxxxxxxxxxxxxxxxvblsF88=(&=(&=(&=(&>)'_LQkZckZckZdkZdkZdlZdlZdlZdlZdlZdlZeG34=(&=(&=(&=(&Z=(&=(&=(&=(&X@BjsjririririqhqhqhpgpgpgofofnlSX>)'=(&=(&=(&?,+hwxxxy{hW{gU{gUzgTzgTzgTzgTzgTzgSyhSyhSzhSyhRyiRyjQyjQyjQykQykPylPymPymPynOzoOzpNzpMzqM{rM{sM|tM|uL}vL~wL~yMzM{O}nzxxxxxxwmcnw[^dUSURKJPFBPD?QD>OD@QHGQLNVTWY[`^cjaksdpyhvhwhvdpz`hpXY_MDF@,*=(&=(&=(&=(&=(&>)'YGJkYckYckYckYckZdkZdkZdkZdlZdlZdlZdlZdG34=(&=(&=(&=(&Z=(&=(&=(&=(&W@Bhqhqhqhqhphpgpgogogofnfnfnx^dB,+=(&=(&=(&>*)guxxxz|hV{hU{hU{hT{hT{hT{hT{hSzhSzhSzhSzhRziRzjRzjQzjQykQykQykPzlPymPznPznOznOzoNzpNzqMzqM{sL{sL|tL|uL}vL~xL~xL}fyxxxxxo]cjMEH@-,=(&=(&=(&=(&=(&=(&=(&=(&=(&=(&=(&=(&=(&=(&=(&=(&=(&=(&=(&=(&=(&=(&=(&=(&=(&=(&=(&=(&@+)\JNjYbjYbkYckYckYckYckYdkZdkZdkZdlZdlZdlZdG34=(&=(&=(&=(&Z=(&=(&=(&=(&W?BgpgpgpgpgpfofofofnenenemdlO88=(&=(&=(&=(&bktxxxz~{|iU|hU|hT|hT|iT|iT{hS{hS{hS{hS{iR{iRzjRzjRzjQzkQzkQzkQzlQzlPzmPznPznOzoOzoNzpN{pM{qL{rL{sL|tL|tL}vK}vK~yQzxxxxu_fnH<==(&=(&=(&=(&=(&=(&=(&=(&=(&=(&=(&=(&=(&=(&=(&=(&=(&=(&=(&=(&=(&=(&=(&=(&=(&=(&=(&=(&=(&=(&=(&J76dRXiXajYbjYbjYbkYckYckYckYckYdkYdkZdkZdkZdkZdG34=(&=(&=(&=(&Z=(&=(&=(&=(&V?AfofofofoenenenendmdmdmclfNR=(&=(&=(&=(&VV\xxx{|}iT}iT}iT}iT}iT|iT|iS|iS|iS|jR{jR{jR{jR{jQ{kQ{kQ{kQ{lQ{lQ{mP{mP{nP{nO{oO{oN{pN{pL{qL{rL{sL{sL|tK|uK}vK}|[yxxxtZ]cA.-=(&=(&=(&=(&=(&=(&=(&=(&=(&=(&=(&=(&=(&=(&=(&=(&=(&=(&=(&=(&=(&=(&=(&=(&=(&=(&=(&=(&=(&=(&?+(M::_MRiWaiXaiXajXbjYbjYbjYbjYckYckYckYckYdkYdkYdkYdkYdG34=(&=(&=(&=(&Z=(&=(&=(&=(&V?Aenenendmdmdmdmclclck~bk{`hD/-=(&=(&=(&H<=vxxz~jT~jT~jT}jT}jT}jS}jS}jS}jS}jR|jR|jR|jQ|kQ{kQ{kQ|lP{lP{mP{mP{mP{nO{nO{oO{pN{pN{pL{qL|rL|rL|sL|tK|tK}uJ|~axxxxbmvB10=(&=(&=(&=(&=(&=(&=(&=(&A,*F21K76N:;Q>>R>AS@BUBCR?BS@BR?@O<>N:;L88I55H43H43H43H53I54J67O<=UCE^LQgV^hW`iW`iWaiWaiXaiXajXbjXbjYbjYbjYckYckYckYckYdkYdkYdkYdG34=(&=(&=(&=(&Z=(&=(&=(&=(&V>@dldldldlcl~ck~ck~ck~bk}bj}bjaIL=(&=(&=(&>)'k~xxz~kU~jS~jS~jS~kS}jS~kS}kS}kR}kR}kR}kR}kQ}kQ|kQ|lP|lP|lP|mP|mP|nO|nO|nO|oN|pN|pN|pM|qL|rL|rL}sL}sK}tJ}uJ|}_xxxuPKN=(&=(&=(&=(&=(&=(&A,*K77TAB\IM`MR`MR`MS`NSaNTaOUaOUbOVbPWbPWcQXcQYcQYdRZeS[eS[eT\fT\fT]gU]gU^gU^gV_gV_hW_hW`hW`iW`iWaiXaiXaiXbjXbjXbjYbjYbjYcjYckYckYckYckYdkYdG34=(&=(&=(&=(&Z=(&=(&=(&=(&U>@cl~ck~ck~ck~bk~bk}bj}bj}aj|ai{`hF0/=(&=(&=(&ROSxxx~r_lSkSlSkS~lS~lS~lR~lR~lR~lR~lR~lQ}lQ}lQ}mQ}mQ}mP}nP|nP}nO}nO}oO}pN}pN}pN}qM}qL|rL}rK}sK}sK}tK~uJ}yUyxxrG:;=(&=(&=(&=(&=(&E10TAB^KN^KP^LP^LP_LQ_LR_MR`MS`NTaNTaNUbOVbOVbPWcQXcQXcQYdRZdRZeS[eS\fT\fT]fT]gU^gU^gU_gV_gV_hW`hW`iW`iWaiWaiXaiXaiXbjXbjXbjXbjYbjYcjYckYckYckYckYcG33=(&=(&=(&=(&Z=(&=(&=(&=(&U>@~bk~bk~bk}aj}aj}aj}aj|`i|`i|`ikRW=(&=(&=(&=)'nxx||mSmRlRlSlSlSlRmRmRmR~mQ~mQ~mQ~mQ~nQ~nQ~nQ~nP}oP}oO}oO~pO}pN}pN}qN}qL}qL}rK}rK}sK}sK~tJ~tJ~uKyxxrF88=(&=(&=(&=(&A-+TAB]JN]JN^KO^KO^KO^KP_LQ_LQ_LR`MS`MS`NTaNUaNUbOVbPWbPWcQXcQYdRZdRZdS[eS[eS\fT]fT]fU^gU^gU_gV_gV_hW`hW`hW`iWaiWaiXaiXaiXbiXbjXbjXbjXbjXbjYcjYcjYckYckYcG33=(&=(&=(&=(&Z=(&=(&=(&=(&U=?}aj|aj|ai|ai|ai|`i{`h{`h{`hz_hW@A=(&=(&=(&KBCxxypWmRmRmRmRmRmRmRmRmQmQnQnQnQnQ~nQoQ~oP~oP~oO~pO~pN~pM~qM~qM~qL~rL~rK~rK~sK~tJ~tI~tI~uI|qxxwJ@A=(&=(&=(&=(&G32[HK]JN]JN]JN]JN]JO]KO^KP^KP^LQ_LR_LR`MS`NT`NTaNUbOVbOVbPWbQXcQYcRYdRZdS[dS[eS\fT]fT]fU^fU^gU_gU_gV_gV`hW`hW`iWaiWaiWaiXaiXbiXbiXbjXbjXbjXbjXcjYcjYcjYckYcG33=(&=(&=(&=(&Z=(&=(&=(&=(&T=?|`h|`h{`h{`h{`h{_g{_gz_gz_gz^fH21=(&=(&=(&^emxx|nRnRnRnRnRnRnRnRnQoQoQoQoQpQpQoQoPpPpOqOpNqMqMrMrLsLsLsKtKtJtIuIuIwOyxxY[a=(&=(&=(&=(&I54]IL]IM\IM\IM\IM]IN]JN]JO]JO^KP^LQ^LQ_LR`MS`MS`NTaNUaOVaOVbPWbQXcQYcQYdRZdR[dS[eS\fT]fT]fU^fU^gU_gU_gV_gV`hW`hW`hWaiWaiWaiWaiXbiXbiXbjXbjXbjXbjXcjXcjXcjYcjYcG33=(&=(&=(&=(&Z=(&=(&=(&=(&T<>{_h{_g{_g{_gz^gz^fz^fz^fy]ev[b?*(=(&=(&=(&mxxzhoRoQoQoQoQoQoQpQpQpQpPpPpPpPpPpPqPqOrOrNrMrLrLsLsLsLtKtJuIuIuIuHvH|txxo>*)=(&=(&=(&F10\HL]IM\IM\IM\IM\IM\IM]IN]JN]JO]JP^KP^LQ_LR_LS`MS`NTaNUaOUaOVbPWbPXbQXcQYdRZdRZdR[eS\eS\fT]fU^fU^fU^gU_gV_gV`hV`hW`hWaiWaiWaiWaiWaiXbiXbiXbjXbjXbjXbjXcjXcjXcjYcG33=(&=(&=(&=(&Z=(&=(&=(&=(&T<>z^gz^gy^gy^fy]fy]fy]ey]ex\eoU[=(&=(&=(&?*)vxxrUpQpQpQpQpQqQqQqQqQqPqPqPqOqOqOqOrNrNrMrMsLsLsLsLtKtKuKuIuIvHvHvHwIyxxROS=(&=(&=(&?*(ZEH]HL]HL\HL\HL[HL\IM\IM\IN\IN]JO]JO^KP^LQ^LR_LR_MS`NT`NUaOUaOVbPWbPWbQXcQYdRZdRZdR[eS\eS\fT]fT]fU^fU^gU_gV_gV`gV`hW`hWaiWaiWaiWaiWaiWbiXbiXbiXbjXbjXbjXcjXcjXcjXcG23=(&=(&=(&=(&Z=(&=(&=(&=(&S<>y]fy]fy]fy]ey]ex\ex\ex\dw[djPU=(&=(&=(&C33xxyqQqQqQqQqPqPqPqPrPrPrPrOrOrOrOrNrNsNsMtMtLsLtKtLtKuJuJuJvIvHvHwHwH~\xxs?+)=(&=(&=(&O::]HL]HL\HL\HL[HL[HL\IM\IM\IM\IN]JO]JO]KP^KQ^LQ_LR_MS_MT`NU`NUaOVbPWbPWbPXcQYdQZdRZdR[eS\eS\eT]fT]fU^fU^gU_gU_gV`gV`hV`hWahWaiWaiWaiWaiWbiWbiWbiXbiXbjXbjXbjXbjXcjXcG23=(&=(&=(&=(&Z=(&=(&=(&=(&S;=x\ex\ex\ex\dx\dx\dw[dw[cw[cgNR=(&=(&=(&F78xxzrQrQrQrPrPrPrPrPrPrPsOsOsOsNsNsNtMtMuLtLuKuKuKuJuJvJvJvIvIwHwHxGxG}wxxajr=(&=(&=(&?*(\GJ\HL\HL\HL\HL[HL[HL\HL\IM\IM\IN\IN]JO]KP]KQ^LQ_LR_MS_MT`NU`NUaOVaOWbPWbPYcQYcQYdRZdR[dS\eS\eT]fT]fU^fU^gU_gU_gV`gV`hV`hVahWaiWaiWaiWaiWbiWbiWbiWbiWbiXbjXbjXbjXbjXcG23=(&=(&=(&=(&Z=(&=(&=(&=(&S;=w[dw[dw[dw[dw[dw[cvZcvZcuZbgMQ=(&=(&=(&E77xxzsQsQsQsPsPsPsPtPtOtOtOtNtNtNuMuMuLuKuKuKvKuKvJvJvJvIwIwIwHxHxGxGyG{xxQLO=(&=(&=(&J54\HL\HL\HL\HL[HL[HL[HL\HL\IM\IM\IN\IN]JO]KP]KQ^KQ_LR_LS_MT`NU`NUaOVaOWbPWbPYcQYcQYdRZdR[dR\eS\eT]eT]fU^fU^gU_gU_gV`gV`gV`hVahVahWaiWaiWaiWbiWbiWbiWbiWbiWbiWbiXbjXbjXbG23=(&=(&=(&=(&Z=(&=(&=(&=(&S;=w[cw[cw[cvZcvZcvZbuYbuYbuYbiOU=(&=(&=(&C22xxytPtPtPtPtPtPtOtNuOuOuNuNuMuLuLvLvKvKvKvKvJvJwJwIwHxHxHxGxGxGyFyFyFyxxD55=(&=(&=(&S?@\HL\HK\HK\HK[HK[HK[HL\HL\HM\IM\IN\IN]JO]JP]KP^KQ_LR_LS_MT`MU`NUaNVaOWbPXbPYcQYcQYcR[dR[dR\eS\eT]eT]fU^fU^gU_gU_gV`gV`gV`hVahVahVaiWaiWaiWaiWbiWbiWbiWbiWbiWbiWbiWbiXcG23=(&=(&=(&=(&Z=(&=(&=(&=(&R;*(vxxuStPuPuPuOuNuNuNuNuMvMvMvLvLvLwKwKwKxKwJwJxJxHxHxHxGyGyGyGzFzFzF|Kxxv>*(=(&=(&=(&ZFI\HK\HK[GK[GK[GK[HK[HL\HL[HM[IM\IN\IN]JO]JP]KP^KQ_LR_LS_MT_MU`NUaNVaOWbOXbPYcQYcQZcR[dR\dR\eS\eT]eT]fU^fU^gU_gU_gU`gV`gV`hV`hVahVahVaiWaiWaiWbiWbiWbiWbiWbiWbiWbiWbiWbG23=(&=(&=(&=(&Z=(&=(&=(&=(&R:@=(&=(&=(&VAC\GK[GK[GK[GL[GL[GL[GL[HM[HM[HN\HN\IO\IO]JO]JP^KQ^KR^KR_LS_MT_MU`NUaNVaOWbOXbPYcQZcQ[cR[dR\eS]eS]eS]eS^eT^fT^fT^gT_gT_gU_gU`gU`gU`gU`hV`hVahVahVahWahWahWahWahWahWahWahWaF23=(&=(&=(&=(&Z=(&=(&=(&=(&P9;qU^qU^qU]qU]qU]qU]qU]pT]pT\pT\oT\S<==(&=(&=(&I<=xxy|P{K|K|K|J|I|I|I|H}H}H}H~H~G~G~GFEEFEDDCCBABBCyxxA0/=(&=(&=(&ZFH\GK\GK[GK[GL[GL[GL[GL[HM[HM[HN[HN\IO\IO]JO]JP^KQ^KR^KR_LS_MT_MU`NVaNVaOWaOXbPYbPZcQZcR[dR\eR\eS]eS]eS^eS^fT^fT^gT_gT_gT_gU`gU`gU`gU`hV`hV`hVahVahVahWahWahWahWahWahWahWaF23=(&=(&=(&=(&Z=(&=(&=(&=(&P9;qU^qU^pT]pT]pT]pT]pT]pT\oT\oS\oS[cJN=(&=(&=(&=(&nxxp|J|J|J}I}I}I}H~H~HHHGGGFEDDECBBBBAAAALxxs>)'=(&=(&@+)[GK[GK[GK[GK[GL[GL[GL[GL[GM[HM[HN[HN\IO\IO]JP]JP]JQ^KR^KR_LS_MT_MU`NUaNVaOWaOXbPYbPZcQZcQ[dR\dR\eR]eS]eS^eS^fT^fT^fT^gT_gT_gT_gU`gU`gU`gU`hV`hV`hV`hVahVahVahVahVahWahWahWaF23=(&=(&=(&=(&Z=(&=(&=(&=(&P8:pT]pT]pT]pT]pT\oS\oS\oS\oS\nS[nS[mRZD/-=(&=(&=(&WW[xx|}J}I}I}I~I~IHHHHGFFFEDDDCBBBBA@@@@[xxj{=(&=(&=(&E0/[GK[GK[GK[GK[GL[GL[GL[GM[GM[HM[HN\HN\IO]IP]IP]JQ]JQ^KR^KR_LS_LT_MU`MUaNVaNWaOXbPYbPYcQZcQ[dQ[dR\eR\eS]eS]eS^fT^fT^fT^gT_gT_gT_gU_gU`gU`gU`gU`hV`hV`hV`hV`hVahVahVahVahVahVaF23=(&=(&=(&=(&Z=(&=(&=(&=(&P8:pT\pS\pS\pS\oS\oS[oS[oS[nS[nR[nRZmRZV>@=(&=(&=(&A/-vxx_~I~IIIHHHGFFFFFDCCBAAAAA@@@@@kxx`iq=(&=(&=(&J65[GK[GK[GK[GK[GL[GL[GL\GM[GM[GM\HN\HN\HO]IP]IP]JQ]JQ^KR_KS_LS_LT`MT`MUaNVaNWaOXbOXbPYcPZcQ[dQ[dR\eR\eR]eS]eS]eS^fT^fT^gT_gT_gT_gT_gU_gU`gU`gU`hV`hV`hV`hV`hV`hV`hV`hVahVahVaG23=(&=(&=(&=(&Z=(&=(&=(&=(&P8:pS\pS\oS\oS[oS[oS[nS[nRZnRZnRZmRZmQYhMS>)'=(&=(&=(&`iqxxIIIHHHGFFFEEECCCAAAA?????>=~xxVV[=(&=(&=(&P;<[GK[GK[GK[GK[GL[GL\GL\GM\GM\GM\HN\HN\HO]IP]IP]JQ]JQ^KR_KS_LS_LT`MT`MUaNVaNWaOWbOXbPYcPZcQZdQ[dQ[dR\eR\eS]eS]eS^fT^fT^fT^gT_gT_gT_gU_gU_gU`gU`gU`hV`hV`hV`hV`hV`hV`hV`hV`iVaG23=(&=(&=(&=(&Z=(&=(&=(&=(&P8:oS\oS\oS\oR[nR[nR[nR[nRZmRZmRZmQYlQYlQYM77=(&=(&=(&F99wxx`IIGGGEEEEEEDCCAAA@??????==|xxJ@B=(&=(&=(&U@C[FL[GK[GK[GK[GL[GL[GL\GM\GM\GM\HN\HN]IO]IP]IP]JQ^KR^KR_KS_KS_LT`MU`MU`NVaNWaNWbOXbPYcPYcQZcQ[dQ[dQ\eR\eR]eS]eS]fT^fT^fT^fT^fT_gT_gT_gU_gU_gU_gU_gU`hU`hU`hU`hV`hV`hV`hV`hV`F23=(&=(&=(&=(&Z=(&=(&=(&=(&O8:oR[nR[nR[nR[nRZnRZmQZmQZmQZlQYlQYlQYlPXaHM=(&=(&=(&=(&bmvxx|LHGGGEEEEDDCBA@@?>>>>??>)'[FJ[FL[FL[FL[GL[GL[GL[GL[GM\GM\HM\HN\HO]IO]IP]JP]JQ^KR^KR_KS_KS_LT`MU`MU`NVaNVaNWbOXbOYcPYcQZcQZdQ[dQ[dR\eR\eS]eS]eS]fT^fT^fT^fT^fT^gT_gT_gU_gU_gU_gU_gU_hU`hU`hU`hU`hU`hU`hU`F23=(&=(&=(&=(&Z=(&=(&=(&=(&O8:nR[nQZnQZmQZmQZmQZmQYmQYlQYlPYlPYkPXkPXkPXJ43=(&=(&=(&C33uxxGFFEEDDDDCBBA@@>>>>>>>=<\xxk~=(&=(&=(&D/-\FL\FL\FL[FL[GL[GL[GL[GL[GM\HM\HM\HN]IO]IO]IP]JP]JQ^KR^KR_KS_KS_LT`MU`MU`NVaNVaNWbOXbOXbPYcQZcQZdQ[dQ[dR\dR\eR\eS]eS]fS]fT^fT^fT^fT^gT^gT_gU_gU_gU_gU_gU_gU_hU`hU`hU`hU`hU`hU`F23=(&=(&=(&=(&Z=(&=(&=(&=(&O79nQZmQZmQZmQZmQZmQYlQYlQYlPYlPXkPXkPXkOXjOWbHM>)'=(&=(&=(&TRWxxxmEDDDDDDCBAB???==>>>>==<|xx\ah=(&=(&=(&K66\FK\FL\FL[FL[FL[GL[GL[GL[GM\HM\HN\HN]IO]IO]IP]JP]JQ^KR^KR^KS_LT_LT`MU`MU`MUaNVaNWbOWbOXbPYcPYcQZcQZdQ[dQ[dR\dR\eS\eS]eS]fS]fT]fT^fT^gT^gT^gT^gT_gT_gU_gU_gU_gU_hU`hU`hU`hU`hU`F23=(&=(&=(&=(&Z=(&=(&=(&=(&O79mQZmQZmQZmQZmQZlPYlPYlPYkPXkPXkPXkOWjOWjOWiNVQ:;=(&=(&=(&=(&coxxxxdDDDDCCAAAA???=======<@zxxJ@B=(&=(&=(&S>?\FK\FL\FL[FL[FL[GM[GL[GL\GN\GN\HN\HN\HO]IO]IP]JP]JQ^JQ^KR^KS_LS_LT`MU`MU`MU`NVaNWbOWbOXbPXcPYcQZcQZdQ[dQ[dR[dR\eR\eS\eS]fS]fS]fS]fT^gT^gT^gT^gT^gT_gT_gT_gT_gU_gU_hU_hU`hU`hU`F23=(&=(&=(&=(&Z=(&=(&=(&=(&O79mPYlPYlPYlPYlPYlPYlPYlPXkPXkOXkOXjOWjOWiNViNVgMTD.-=(&=(&=(&?,+lxxyfCCCCAAAA?>>><======;ixxp>)'=(&=(&>*'ZEI[FK\FL\FL[FL[FL[GM[GM\GM\GN\GN\HN\HN\HO]IO]IP]IP^JQ^JQ^KR^KS_LS_LT`MU`MU`MV`NVaNVbOWbOXbPXcPYcPYcQZdQZdQ[dR[dR[dR\eR\eS\eS]fS]fS]fS]gT^gT^gT^gT^gT^gT_gT_gT_gT_gT_gT_gT_hU`hU`F23=(&=(&=(&=(&Z=(&=(&=(&=(&N79lPYlPYlPYlPYlOXkOXkOXkOXkOXkOXjOWjOWjOWiNWiNVhMU`GL?*(=(&=(&=(&B11nxxxsDBA@@@@>>>><<<<===JzxxWW]=(&=(&=(&G21[FK[FK[FK[FL[FL[FL[GM\GM\GM\GN\GN\GN\HN\HO]IO]IP]IP^JQ^JQ^KR^KS_KS_LT`LU`MU`MV`NVaNVaNWbOWbOXcPXcPYcPYdQZdQZdQ[dR[dR[eR\eR\eS\fS]fS]fS]fS]gS^gS^gT^gT^gT^gT_gT_gT_gT_hT_hT_hT_hT_F23=(&=(&=(&=(&Z=(&=(&=(&=(&N79lPYlPYlPYkOXkOXkOXkOXkOWjOWjOWjNWjNWjNViNViNVhMUhMUYAD=(&=(&=(&=(&C32nxxxK@@@@?>>>=;;<<<?[FK[FK[FK[FL[FL[FL\GM\GM\GM\GN\GN\HN\HN\HO]IO]IP]IP^JQ^JQ^JR^KS_KS_LT`LT`MU`MV`MVaNVaNWbOWbOXbOXcPYcPYcPZdQZdQZdQ[dR[dR[eR\eR\eR\fS]fS]fS]fS]gS^gS^gS^gT^gT^gT_gT_gT_hT_hT_hT_hT_F22=(&=(&=(&=(&Z=(&=(&=(&=(&N79lOYlOYlOYkOXkOXkOXkOXjOWjOWjOWjNViNViNViMViMUhMUhMUgMUT=>=(&=(&=(&=(&A/.hxxxxzmD@?>>>>=;;;;EwxxxvJ?A=(&=(&=(&B-+\FK\FL[FK[FL\FL[FL\GL\GM\GM\GM\GN\GN\HN\HN\HN]IP]IP]IP]JQ^JQ^JR^KR_KS_LT`LT`LU`MV`MVaNVaNVaNWbOWbOXcPXcPYcPYdQZdQZdQZdQ[dR[eR\eR\eR\eR\fS]fS]fS]gS^gS^gS^gS^gS^gS^gT_hT_hT_hT_hT_hT_F22=(&=(&=(&=(&Z=(&=(&=(&=(&N69lOXlOXlOXkOXkNXkNXkNWjNWjNWjNWiNViNViMViMUiMUhMUhMUgLTgLTR;<=(&=(&=(&=(&>*(\biwxxx{rQA===<=E\}yxxxsLCE=(&=(&=(&=(&Q<=\FL\FL\FL\FL\FL[FL\GL\GM\GM\GM\GN\HN\HO\HO\HO]IP]IP]IP^JQ^JQ^JR^KR_KS_KT`LT`LU`MU`MVaMVaNVaNWbOWbOXbOXcPYcPYdPZdQZdQZdQ[dQ[eR[eR\eR\eR\fS]fS]fS]fS]gS^gS^gS^gS^gS^gS^gT^gT^gT^hT_hT_F22=(&=(&=(&=(&Z=(&=(&=(&=(&N69kOXkOXkNXkNWkNWkNWjNWjNWjNWiNViNViMVhMUhMUhMUhLThLTgLTgLTfKSS;<=(&=(&=(&=(&=(&LCEoxxxxxy{|}|zxxxxxxhwD44=(&=(&=(&=(&F10\FL\FL\FL\FL\FL\FL[FL[FL\GM\GM\GM\GN\HN\HO\HO]IO]IP]IP]IP^JQ^JQ^JR^JR_KS_KS`LT`LU`LU`MVaMVaNVbNWaNWbOXbOXcOYcPYcPYdPZdQZdQ[dQ[eQ[eR\eR\eR\fS]fS]fS]fS]fS]gS^gS^gS^gS^gS^gS^gT^gT^gT^gT^F22=(&=(&=(&=(&Z=(&=(&=(&=(&N68kOWkOWkNWkNWjNWjNWjNWjNVjNViMViMViMVhMUhMUhMUgLTgLTgLSfLSfKSfKSV>A>)'=(&=(&=(&=(&?+)VW\qxxxxxxxxxxxxxmPJN=)'=(&=(&=(&=(&B-+ZDH\FL\FL\FL\FL\FL\FL[FL[FL\GM\GM\GM\GN\HN\HO\HO]IO]IP]IP^IP^IP^JQ^JR^JR_KS_KS`LT`LT`LU`MVaMVaMVbNWaNWbOXbOXbOXcOYcPYdPZdPZdQZdQ[eQ[eQ[eR\eR\eR\fS]fS]fS]fS]fS]gS]gS]gS^gS^gS^gS^gT^gT^gT^F22=(&=(&=(&=(&Z=(&=(&=(&=(&N69kNXkNXjNWjNWjNWjNWjNVjNViNViMViMUiMUhLUhLUgLUgLUgLTgLSfLSfKSeKReJR\CGA,*=(&=(&=(&=(&=(&?+)PKOdpxqvxxxxxtk|\biJ?@=)'=(&=(&=(&=(&=(&D0.YDG\FL\FL\FL\FL\FL\FL\FL[FL[FL[FM\GM\GM\GN\HN\HN]HO]IO]IP]IP^IP^IP^JR^JR^JR_KS_KS`LT`LT`LU`LUaMVaMVbNWbNWbNWbOXbOXcOYcPYdPZdPZdPZdQ[eQ[eQ[eR\eR\eR\eR\fS]fS]fS]fS]fS]fS]gS]gS]gS]gS^gS^gS^gT^F22=(&=(&=(&=(&Z=(&=(&=(&=(&N68kNWjNWjNWjNWjMWjMWiMViMViMViMUiMUhMUhLUhLUgLTgLTgLTfKTfKSeKReKReJReJRaHMJ43=(&=(&=(&=(&=(&=(&=(&=(&?,*B0/D55D55D55A/.>)(=(&=(&=(&=(&=(&=(&=(&>)'L77[FJ\FK\FK\FL\FL\FL\FL\FL\FL[FL[FL[FM\GM\GM\GN\GN\HN]HO]HO]IP]IP]IQ^IP^JR^JR^JR_KR_KS`LT`LT`LU`LUaMVaMVaNWbNWbNWbOXbOXcOYcOYcPYdPZdPZdQ[eQ[eQ[eQ[eR\eR\eR\fR\fR\fR\fS]fS]fS]fS]gS]gS]gS]gS]gS]gS]F12=(&=(&=(&=(&Z=(&=(&=(&=(&N68jNWjMWjMWjMWjMWjMViMViMViMViMUhMUhLUhLTgLTgKTgKTfKTfKSfKSeKReKReJReJQdIQdIQW?BB-*=(&=(&=(&=(&=(&=(&=(&=(&=(&=(&=(&=(&=(&=(&=(&=(&=(&=(&=(&F10WAD\FL\FL\FK\FK\FL\FL\FL\FL\FL\FL[FL[FL\GM\GM\GM\GN\GN\HN]HO]HO]IP]IP]IQ^IP^JR^JR^JR_KR_KS`LS`LT`LT`LUaMVaMVaMWbNWbNWbOXbOXcOYcOYcOYdPZdPZdPZeQ[eQ[eQ[eR[eR\eR\eR\fR\fR\fR\fR\fS]fS]gS]gS]gS]gS]gS]gS]F12=(&=(&=(&=(&Z=(&=(&=(&=(&N68jNWjMWjMVjMVjMViMViMViMViMVhLUhLUhLUhLTgLTgKTgKSfKSfKSfKSeJSeJRdJRdJRdIQdIQcIPbHNR;?\EJ]FL]FL]FL\FL\FL\FL\FL\FL\FL\FL\FL\FL\FL\FM\FM\GM\GM\GM\GM\GN\GN\HN]HO]HO]HP]IP]IQ^IQ^JR^JR_KR_KR_KS`LS`LT`LT`LU`LUaMVaMVaNWbNWbOWbOXbOXcOYcOYcOYdPZdPZdPZeQZeQ[eQ[eR[eR[eR[eR[fR\fR\fR\fR\fR\fR\gS\gS\gS\gS]gS]F12=(&=(&=(&=(&Z=(&=(&=(&=(&N68jMVjMVjMViMViMViMViLUiLUhLUhLUhLUgLTgKTgKTfKSfKSfKSfKSfJReJReJRdJRdJQdIQcIQcIPcIPbHPbHObHOaHOaHN_HL]FIZBGZCEZBF[DH^FK^FM^FM^FL^FL^FL]FL]FL]FL]FL\FL\FL\FL\FL\FL\FL\FL\FL\FL\FM\FM\GM\GM\GM\GM\GN\GN\HN]HO]HO]HP]IP]IQ]IQ^JR^JR^JR_KR_KS`LS`LT`LT`LU`LUaMUaMVaNVbNWbNWbOXbOXcOXcOYcOYdPYdPZdPZeQZeQZeQ[eQ[eR[eR[eR[eR[fR[fR\fR\fR\fR\fR\gS\gS\gS\gS\F12=(&=(&=(&=(&Z=(&=(&=(&=(&N68jMViMViMViMViMUiLUiLUhLUhLUhLUgLUgKTgKTgKTfKSfKSfJRfJReJReJReJRdIRdIQcIQcIQcIPcIPbHObHOaHOaHOaHN`HN`GM`GM`GM_GM_GM^FM^FM^FM^FL]FL]FL]FL]FL]FL]FL\FL\FL\FL\FL\FL\FL\FL\FL\FL\FL\FM\GM\GM\GM\GM\GM\HN\HN]HO]HO]HP]IP]IQ]IQ^JR^JR^JR_KR_KR_KS`LS`LT`LT`LUaMUaMVaNVbNWbNWbOWbOXcOXcOXcOYdPYdPYdPZeQZeQZeQZeQZeQ[eR[eR[eR[eR[fR[fR[fR\fR\fR\fR\gS\gS\gS\F12=(&=(&=(&=(&Z=(&=(&=(&=(&M68iMViLViLViLViLViLUhLUhLUhLUhLTgLTgKTgKTgKTfKSfKSeJReJReJReJReJQdIQdIQcIQcIQcIPcHObHObHOaHOaHO`GO`GN`GN`GN`GN_FM_FM^FM^FM^FM^FL]FL]FL]EL]EL]FL]FL]FL\FL\FL\FL\FL\FL\FL\FL\FL\FL\FM\GM\GM\GM\GM\GM\HN\HN]HO]HO]HP]IP]IP]IQ^JQ^JR^JR_KR_KR_KS`LS`LT`LT`LUaMUaMVaNVbNVbNWbOWbOXbOXcOXcOYdPYdPYdPYdPZeQZeQZeQZeQZeQ[eR[eR[eR[fR[fR[fR[fR[fR\fR\fR\gS\gS\F12=(&=(&=(&=(&Z=(&=(&=(&=(&M68iLViLViLViLViLVhLUhLUhLUhLUgLTgLTgKTgKTfKTfJSfJSeJReJReJReJQeJQdIQdIQcIQcIQcHObHObHObHOaGOaGO`GN`GN`GN`GN`GN_FM_FM^FM^FM^FM]FL]FL]FL]EL]EL]EL]FL]FL\FL\FL\FL\FL\FL\FL\FL\FL\FL\FM\FM\GM\GM\GM\GM\HN\HN\HO]HO]HP]HP]IP^IQ^IQ^JR^JR_KR_KR_KS`LS`LT`LT`LUaMUaMUaNVbNVbNWbNWbOWbOXcOXcOXcOYdPYdPYdPYeQZeQZeQZeQZeQZeQ[eR[eR[eR[fR[fR[fR[fR[fR[fR\fR\gR\F12=(&=(&=(&=(&Z=(&=(&=(&=(&M57iLViLViLViLViLVhLUhLUhLUhKTgKTgKTgKTfKSfKSfJSfJSeJReJReJReJQeJQdIQdIQcIQcHPbHPbHPbHOaHOaGOaGN`GN`GN`GN`GN`GN_FM_FM^FM^FM^FL]FL]FL]FL]FL]EL]EL]EL]FL\FL\FL\FL\FL\FL\FL\FL\FL\FL\FM\FM\GM\GM\GM\GN\HN\HN\HO]HO]HO]HP]IP^IQ^IQ^JR^JR_JS_KR_KS_KS`LT`LT`LTaMUaMUaMVbNVbNVbNWbOWbOXcOXcOXcOXdPYdPYdPYdPZeQZeQZeQZeQZeQZeQZeQ[eR[fR[fR[fR[fR[fR[fR[fR[fR[F11=(&=(&=(&=(&Z=(&o=(&=(&=(&G11iLViLViLVhLUhLUhKUhKUhKUgKTgKTgKTgKSfKSfKSfJSfJSeJReJReJQeJQdIQdIQdIQcHPcHPbHPbHPbHOaHOaGNaGN`GN`GN`GN`GN`GN_FM_FM^FM^FL^FL]FL]FL]FL]FL]EK]EL]EL\EL\FL\FL\FL\FL\FL\FL\FL\FL\FL\FM\FM\GM\GM\GM\GN\HN\HN\HO]HO]HO]HP]IP^IQ^IQ^JR^JR_JS_KR_KS_KS`LS`LT`LTaMUaMUaMVbNVbNVbNWbOWbOWcOXcOXcOXdPYdPYdPYdPYeQZeQZeQZeQZeQZeQZeQZeQZeR[fR[fR[fR[fR[fR[fR[fRZA,+=(&=(&=(&=(&J=(&B=(&=(&=(&>)'cGPiLUhLUhLUhLUhKUhKUhKUgKTgKTgKTfKSfKSfKSfJSfJSeJReJRdIRdIRdIRdIQdIPcHPcHPbHPbHPbHOaHOaGNaGN`GN`GN`GN`GN_GN_FM_FL^FL^FL^FL]FL]FL]FL]FL]EK]EK]EL\EL\EL\FL\FL\FL\FL\FL\FL\FL\FL\FM\FM\GM\GM\GM\GN\HN\HN\HO]HO]HO]HP]IP^IQ^IQ^JR^JR_JS_KR_KS_KS`LS`LT`LTaMUaMUaMUbNVbNVbNWbNWbOWcOXcOXcOXdPXdPYdPYdPYdPZeQZeQZeQZeQZeQZeQZeQZeQZfR[fR[fR[fR[fR[fR[\GM=(&=(&=(&=(&=(&=(& =(&=(&=(&=(&J23hKThLUhLUhLUhKUhKUhKUgKTgKTgKTfKSfKSfKSfJSfJSeJReJRdIRdIRdIQdIQdIPcHPcHPbHPbHPbHOaHOaGNaGN`GN`GN`GN`GN_GN_FL_FL^FL^FL^FL]FL]FL]FL]FL]EK]EK\EL\EL\EL\FL\FL\FL\FL\FL\FL\FL\FL\FM\FM\GM\GM\GN\GN\HN\HN\HO]HO]HO]HP]IP^IQ^IQ^JR^JR_JR_KR_KS_KS`LS`LT`LTaMUaMUaMUbMVbNVbNVbNWbOWcOWcOXcOXcOXdPYdPYdPYdPZdPZeQYeQZeQZeQZeQZeQZeQZfQZfR[fR[fR[fR[dPXD//=(&=(&=(&=(&=(&=(&=(&=(&=(&=(&J24cHPhLUhKUhKUhKUhKUgKTgKTgKTfKSfKSfJSfJSfJReJReJRdIRdIRdIQdIPcIPcHPcHPbHPbHPbHOaHOaGNaGN`GN`GN`GN`GN_GM_FL_FL^FL^FL^FL^FL]FL]FL]FL]EK]EK\EK\EL\EL\EL\FL\FL\FL\FL\FL\FL\FL\FM\FM\FM\GM\GN\GN\HN\HN]HO]HO]HO]HP]HP^IQ^IQ^IR^JR_JR_JS_KS_KS_KS`LT`LTaLUaMUaMUbMVbNVbNVbNWbNWcOWcOXcOXcOXdPXdPYdPYdPZdPZePZeQYeQZeQZeQZeQZeQZfQZfQ[fR[fR[_KRE11=(&=(&=(&=(&=(&d=(&=(&=(&=(&=(&=(&>)'H23O69O7:O7:O7:O79O79O79N69N69N69N69N68N68N68N68M68M68M68M68M68M68M68M67L67L67L57L57L57L57L57K57K56K56K56K56K56K56K56K56K56J56J46J46J46J46J46J46J56J56J56J56J56J56J56J56J56J56J57J57J57J57J67J67J67J67J68K68K68K68K68K68K69K79K79K79L79L79L79L7:L8:L8:M8:M8:M8:M8;M8;M8;M8;M8;M8;M9;M9;M9)'=(&=(&=(&=(&=(&=(&=(&8=(&=(&=(&=(&=(&=(&=(&=(&=(&=(&=(&=(&=(&=(&=(&=(&=(&=(&=(&=(&=(&=(&=(&=(&=(&=(&=(&=(&=(&=(&=(&=(&=(&=(&=(&=(&=(&=(&=(&=(&=(&=(&=(&=(&=(&=(&=(&=(&=(&=(&=(&=(&=(&=(&=(&=(&=(&=(&=(&=(&=(&=(&=(&=(&=(&=(&=(&=(&=(&=(&=(&=(&=(&=(&=(&=(&=(&=(&=(&=(&=(&=(&=(&=(&=(&=(&=(&=(&=(&=(&=(&=(&=(&=(&=(&=(&=(&=(&=(&=(&=(&=(&=(&=(&=(&=(&=(&=(&=(&=(&=(&=(&=(&=(&=(&=(&=(&=(&!=(&9=(&=(&=(&=(&=(&=(&=(&=(&=(&=(&=(&=(&=(&=(&=(&=(&=(&=(&=(&=(&=(&=(&=(&=(&=(&=(&=(&=(&=(&=(&=(&=(&=(&=(&=(&=(&=(&=(&=(&=(&=(&=(&=(&=(&=(&=(&=(&=(&=(&=(&=(&=(&=(&=(&=(&=(&=(&=(&=(&=(&=(&=(&=(&=(&=(&=(&=(&=(&=(&=(&=(&=(&=(&=(&=(&=(&=(&=(&=(&=(&=(&=(&=(&=(&=(&=(&=(&=(&=(&=(&=(&=(&=(&=(&=(&=(&=(&=(&=(&=(&=(&=(&=(&=(&=(&=(&=(&=(&=(&=(&=(&=(&=(&=(&=(&=(&%=(&=(&=(&=(&=(&=(&=(&=(&=(&=(&=(&=(&=(&=(&=(&=(&=(&=(&=(&=(&=(&=(&=(&=(&=(&=(&=(&=(&=(&=(&=(&=(&=(&=(&=(&=(&=(&=(&=(&=(&=(&=(&=(&=(&=(&=(&=(&=(&=(&=(&=(&=(&=(&=(&=(&=(&=(&=(&=(&=(&=(&=(&=(&=(&=(&=(&=(&=(&=(&=(&=(&=(&=(&=(&=(&=(&=(&=(&=(&=(&=(&=(&=(&=(&=(&=(&=(&=(&=(&=(&=(&=(&=(&=(&=(&=(&=(&=(&=(&=(&=(&=(&=(&=(&=(&=(&=(&=(&=(&=(&=(&=(&=(&=(&{=(& =(& =(&K=(&y=(&=(&=(&=(&=(&=(&=(&=(&=(&=(&=(&=(&=(&=(&=(&=(&=(&=(&=(&=(&=(&=(&=(&=(&=(&=(&=(&=(&=(&=(&=(&=(&=(&=(&=(&=(&=(&=(&=(&=(&=(&=(&=(&=(&=(&=(&=(&=(&=(&=(&=(&=(&=(&=(&=(&=(&=(&=(&=(&=(&=(&=(&=(&=(&=(&=(&=(&=(&=(&=(&=(&=(&=(&=(&=(&=(&=(&=(&=(&=(&=(&=(&=(&=(&=(&=(&=(&=(&=(&=(&=(&=(&=(&=(&=(&=(&=(&=(&=(&=(&=(&=(&=(&=(&=(&=(&t=(&A=(&??(@ @#.#.=(&=(&%=(&;=(&;=(&;=(&;=(&;=(&;=(&;=(&;=(&;=(&;=(&;=(&;=(&;=(&;=(&;=(&;=(&;=(&;=(&;=(&;=(&;=(&;=(&;=(&;=(&;=(&;=(&;=(&;=(&;=(&;=(&;=(&;=(&;=(&;=(&;=(&;=(&;=(&;=(&;=(&;=(&;=(&;=(&;=(&;=(&;=(&;=(&;=(&;=(&;=(&;=(&;=(&;=(&4=(& =(&B=(&=(&=(&=(&=(&=(&=(&=(&=(&=(&=(&=(&=(&=(&=(&=(&=(&=(&=(&=(&=(&=(&=(&=(&=(&=(&=(&=(&=(&=(&=(&=(&=(&=(&=(&=(&=(&=(&=(&=(&=(&=(&=(&=(&=(&=(&=(&=(&=(&=(&=(&=(&=(&=(&=(&=(&=(&=(&=(&C=(&=(&D.-U>=U>>U>>U>>T==T==T==S<*'dlrx|iefhjlnprtvxz~xfs}B10=(&B-+lX^|hq|hq|hq{hr{hq{hq{hqcOT=(&=(&=(&@=(&W??ɦȦǥŤĢ¡|ch=(&=)&elpx}}bcdfgikmoqsvxz}zrKAB=(&>)(fSX{hqzgp{hqzhqzhqzgqbOT=(&=(&=(&@=(&V??ţĢâ¡{D.-=(&[XYx|za|abdeghjloqsvxz}wTPS=(&>)'hU[zgpzgpzgpzgoygobNS=(&=(&=(&@=(&V>>fOQ=(&I97wzw`y`|a~bcdfgjlnpsux{}xUSV=(&A,*q^fyfoyfoyfoyfobNR=(&=(&=(&@=(&U>>{A,*=(&isxx}yiv^x_z`}abcefhkmpruwz}¾ÊƍȐwLDF=(&N:;xenxenxenxenaMR=(&=(&=(&@=(&T==v]b=(&G75xz}s]~u^w^y_|_`bcegiloqtwz}ÿËǍː˓q?,*=(&kW^wdmwenwdn`MR=(&=(&=(&@=(&S<=}YBC=(&[\`xzvh|q\}t]v]x^{^~_abcehjnqtwz}ŋɎ̑ϓ™xUUZ=(&S?Bvdmvdmvdm`MQ=(&=(&=(&@=(&S<<~|w@+*=(&mxzn[{p\|r\~u\w]z]}^_abdfilpsvy}ƊʍΑєӖk}=(&C.-ucluclucl_LQ=(&=(&=(&@=(&R;;}|zv^b=(&E66xx~ymZzoZ{q[}t[v[y\|]^`acehknruy|‡NjˎϑҔԖw?,*=(&o\dtbltbl_LP=(&=(&=(&@=(&Q;;~|{ywWAB=(&Y\bxxpdxlZznZ{qZ|sZ~uZx[{\~]^_adgjmqtx|Çȋ͎ёҔՖxG::=(&gT[tajtak^KP=(&=(&=(&@=(&Q::}{zxvnv?*(=('oxwjYxlYymYzoY{rY}tYwZzZ~[\^`behlosw{ĆɊΎҒԔ֗ƜxKBD=(&cQVsajsaj^KP=(&=(&=(&@=(&P9:}{zywv~t}fOR=(&LDFxwxtwiXwkXxlXyoXzqX|tX~vXyY}Y[\^acgjnrvz~ņʊώӑՔחȜxLDF=(&cPVr`ir`i]JO=(&=(&=(&@=(&O99~}{zywvt}s|nvD/.=(&fuxvhZvhXwjWwlWxnVzpV{sV}uWyW|XYZ\_beimquy}ņˊЎԑ֔חƝxJ@B=(&dQXq_iq_i]JO=(&=(&=(&@=(&O88~~}|{zxwvu~s|rzpycLO=(&I>?xw||vfXvhWwiWwkWxmVyoUzrU|uU~xV{V~WY[]`dhlptx|ŅˉЍԑ֔חxF89=(&hV\q_hq_h]JO=(&=(&=(&@=(&N88||{{zyxwvt~s|r{qyoxzah?*(=(&iyxvg\vfWvgVviVwkUxmUyoTzrT{tU}wUzU}UWY[^bfjnsw{ĄɈόӐՓ֖x@-,=(&m\dp^gp^g\IN=(&=(&=(&@=(&N77yyxxwvu~t}s|r{qzpxnwgnG10=(&TRWxwxvveWvfVvgUviUvjTwmTxoSyqS{tS|wS~yS|TUWZ]`dhmrvz~ÃȇΌҏՓ֕r=(&C.-o]fo^go^g[IN=(&=(&=(&@=(&M77vvuu~t}s}r|r{qzpynwmv|cjH22=(&H<=uwvdXveVvfVvhUviTwjTwlSxoSyqRzsQ|vR}yR|RTVX[_cglpuy}‚dž̋ЏԒՔfu~=(&L88n\fn]fn]f[HM=(&=(&=(&@=(&L66s}s}s|r|r{qzpyoxnwmvltrZ_B-,=(&F99rxvh^vdWveVvfUvhTwiSwkSwmRxoQyqQzsP{vP}yQ|QRTWZ^bfjotx|ƅˉύҐɖxUUZ=(&WDHm\en\fn\f[HM=(&=(&=(&@=(&L66qzqzpypyoxoxnvmvluktgOS>)'=(&KACsxwlcwdVweVwfUwgTwhSwjSwkRwmQxoPyqPzsO|vO}yP|PRTVY\`einsw|ńʈΌяuA/.=(&fT[m\em\em\eZHM=(&=(&=(&@=(&K55nxnxnwmvmvluktjsir_GJ=(&=(&SPTvxxkaxdVweVweUwgTwhSwiRwjRxlQxnPyoOyqO{sN|vN~yN|OQSVY\_dimrv{ăȆȌxWW]=(&J67l[dm[dm[em[eZGL=(&=(&=(&@=(&K55lvlukuktjtjsirhq]EH=(&=('\agxyyi[yfUyfUxfTxgTxhSxiRxjQxkQxlPynOypOzrN{tN}vM~yN|NPSf|~|{}{x\ah=(&>)'cQXkZdlZdlZdl[eZGL=(&=(&=(&@=(&K44jsjsirirhqgpgocKO=(&>)'aksxy{hWzgUzgTzgTygSyhSyiRyjQykQylPymOzoOzqN{sM|tM}wL~yM~V}|yxxphwdovdmrdntfs|j{mpoj{^fmI=>=(&=(&XEIkYckZdkZdlZdlZdZFL=(&=(&=(&@=(&J44hqgpgpgofoenrX^>)'=(&`hpxz}|hU|hT{hT{hSzhSziRzjRzjQzkQzlPznPzoOzpN{qL{sL|uL}wM{wxwft}QNRB10=(&=(&=(&=(&=(&=(&=(&=(&=(&=(&=(&=(&=(&@,*ZHLjYbkYckYckYdkZdkZdYFL=(&=(&=(&@=(&J33foenendmcl~bkI33=(&TSXx{}iT}iT}jS|jS|jR{jR{jQ{kQ{lP{mP{nO{oO{pN{qL|rL|tK}wPyxdqzE77=(&=(&=(&@,*E0/G33I44H44G32E10C.-C.,C.-E10K88WDHgU]iXajXbjYbjYckYckYdkYdYFL=(&=(&=(&@=(&I33clcl~ck~bk}bjhOS=(&B21uzkTkS~kS~kS~lR}kR}lQ|lQ|mP|nP|nO|oN|pN|qL|rL}sK}vMyvROS=(&=(&E1/R>@[IL_MR`MSaNTaOUbPWcQXdRZeS[eT\fT]gU^gV_hW`iW`iWaiXajXbjYbjYckYckYcYFK=(&=(&=(&@=(&I33}bj}aj|ai|`i{`hO89=(&\agxs^mRmRmRmRmQmQ~nQ~nP~oP~oO~pN~qM~rL~rK~sJ~tJ{|xPKN=(&@+)T@A]JN]JO^KP_LQ_LR`MSaNUbOVbQXcQYdRZeS\fT]fU^gU_gV`hW`iWaiXaiXbjXbjXbjYcjYcYFK=(&=(&=(&@=(&I22{_h{_h{_gz^fy]e@+)=(&o{oRoQoRoQoQpQpPpPpPqOqMrLsLsKtJuI~zUx`hp=(&?*(XDF\IM\IM]IN]JO^KP^LR`MS`NTaOVbPWcQYdRZeS\fT]fU^gU_gV`hW`hWaiWaiXbiXbjXbjXcjXcXFK=(&=(&=(&@=(&H22y]fy]fy]fx\erW^=(&?+*w~zqQqQqQqQqPqPqOrOrNsMsLtLtJuJvHvH|ywC32=(&Q<>]HL\HL\HL\IM\IN]KP^LQ_LS`NTaOVbPWbQYdRZdR[eT]fU^gU_gV`hV`hWaiWaiWbiWbiXbjXbjXcXEK=(&=(&=(&@=(&H22x\dx\dw[dw[coTZ=(&A//xtsQsPsPsPsOsNtNtMuLuKuKvJvIwIxHxGzhx=(&A,*\HK\HL[HL[HL\IM\IN]JO^KQ_LS`MT`NVbOWbPYdQZdR\eT]fU^gU_gV`gV`hVaiWaiWbiWbiWbiWbjXbXEK=(&=(&=(&@=(&H11v[cvZcvZbuYbpU\=(&?+)wytPtPuOuNuNuMvLvKwKwJwIxHxHyGyFzGx\bh=(&J55\HK[HK[HK[HL\HM\IN]JO^KQ_LR_MT`NVaOWbPYcRZdR\eS]fT^gU_gU_gV`hVahVaiWaiWbiWbiWbiWbXEK=(&=(&=(&@=(&G11uYatXatXatX`sW_@+)=(&p}vOvNvNwMwLxLxKxJyJyIyHzHzG{F{F~PxTSX=(&O;;[GK[GK[GK[HL[HM\IN]JO^KQ_LR_MT`NVaOXbPYcRZdR\eS]fT^fU^gU_gV`hVahVahWahWaiWbiWbiWbXEK=(&=(&=(&@=(&G11sW`sW`sW_rW_qV^I33=(&cnwzxNxMxMyKyKzJzJzJ{I{H{H|G}F}E}E[xMFH=(&S?@[GK[GK[GK[HM\HN\IN]JO^KQ^KR_MT`NVaOXcQYcR[dR\eS]eT^fT_gU_gU`gU`hVahVahWahWahWbiWbXEK=(&=(&=(&@=(&G11rV_rV_rV^qU^pU]V>A=(&RNRxazKzK{J{J|J|I}H}G}F}F~FEDCgxE77=(&WCE[GK[GL[GL[HM\HN\IO]JO^KQ^LS_MT`NVaOXbPYcR[dR\eS]eT^fT^gT_gU`gU`hV`hVahVahWahWahWaWEJ=(&=(&=(&@=(&G00qU^qU]pT]pT]pT\eKP=(&@-,u|K|J|I}H~H~HGFEECCAAuw>*(>)'[GJ[GK[GL[GL[HM[HN\IO]JP^KQ_LS_MTaNVaOXbPYcQ[dR\eS]eS^fT^gT_gT_gU`gU`hV`hVahVahWahWaWEJ=(&=(&=(&@=(&G00pT\pT\oS\oS\nS[nRZE/.=(&aksyO~I~IHGFFDCBBA@@o=(&B.,[GK[GK[GL[GL[GM\HN\IO]JP^KR_LS_MTaNVaOWbPYcQZdR\eR]eS]fT^fT^gT_gU_gU`hV`hV`hV`hVahVaWDJ=(&=(&=(&@=(&F00oS\oS[oS[nR[mRZmQYX?B=(&H<=xvIHGEEECAA???={dqz=(&H33[GK[GK[GL\GL\GM\HN]IO]JQ^KR_KS_LT`NVaNWbOYcPZdQ[eR\eS]fS^fT^gT_gT_gU_gU`hU`hV`hV`hV`WDJ=(&=(&=(&@=(&F00nR[nRZmQZmQZlQYlQYiNU@+)=(&eq{yVGEEDCB@>>>>FxXY_=(&N9:[FL[GL[GL[GL\HM\HN]IO]JQ^KR_KS_LT`MUaNWbOXcPYcQZdQ[eR\eS]fT^fT^fT^gT_gU_gU_hU`hU`hU`WDI=(&=(&=(&@=(&F00mQZmQZmQYlPYkPXkPXjOWV>A=(&C32s|LDDCA@?=>==exH<==(&V@C\FL[FL[GL[GM\HN\HN]IO]JQ^KR_KS_LT`MUaNWbOXcPYcQZdQ[dR\eS\eS]fS]fT^gT^gT_gT_gU_hU`hU`WCI=(&=(&=(&@=(&F//lPYlPYlPYkOXkOXjOWjOWhMUH22=(&KACv}OBAA>><<=@|n=(&@+)[FJ[FL[FL[GM\GN\GN\HN]IO]JP^JR^KS_LT`MUaNVbOWcPXcPYdQZdR[dR\eS\fS]fS]gT^gT^gT_gT_gT_hT_WCI=(&=(&=(&@=(&F//lPYlOXkOXkOXjOWjNViNViMUdJQC-,=(&LCEtzfA?>>;)'=(&=(&>)'@-,A.->*(=(&=(&=(&F10XBF\FK\FL\FL\FL[FL\GM\GM\HN]HO]IP^IP^JR_KR`LS`LT`LUaMVbNWbOXcOYdPZdPZeQ[eR[eR\fR\fR\fS]fS]gS]gS]WBH=(&=(&=(&@=(&E//jMVjMViMViMVhLUhLUgLTgKSfKSeJSdJRdIQcIP^EKN78B,+=(&=(&=(&>)'C.,K56V@C]FL\FL\FL\FL\FL\FL\FL\GM\GM\GN]HO]IP]IQ^JR_KR`LS`LT`LUaMVaNWbOXcOXcOYdPZeQZeQ[eR[eR\fR\fR\fR\gS\gS]WBG=(&=(&=(&@=(&E//jMViMViMViLUhLUgLUgKTfKSfKSeJRdJRdIQcIPbHPbHOaHO_GL]EJ]EJ^FL^FL^FL]FL]FL\FL\FL\FL\FL\FL\GM\GM\GM\HN]HO]HP]IQ^JR_KR_KS`LT`LUaMVaNVbOWbOXcOYdPYdPZeQZeQ[eR[eR[fR\fR\fR\gS\WBG=(&=(&=(&@=(&E//iLViLViLVhLUhLTgLTgKTfJSeJReJRdIQcIQcIPbHOaHOaGO`GN`GN_FM^FM^FL]FL]EL]FL\FL\FL\FL\FL\FL\FM\GM\GM\HN]HO]HP]IQ^JR^JR_KS`LS`LTaMUaNVbNWbOXcOXdPYdPYeQZeQZeQ[eR[fR[fR[fR[fR\WBG=(&=(&=(&<=(&D.-iLViLVhLUhKUgKTgKTfKSfJSeJReJQdIQcIQbHPbHOaGOaGN`GN`GN_FM^FM^FL]FL]EL]EL\FL\FL\FL\FL\FL\FM\GM\GM\HN]HO]HP]IQ^JR^JR_KS`LS`LTaMUaNVbNWbOWcOXcOYdPYdPZeQZeQZeQZeR[fR[fR[fR[U@E=(&=(&=(&=(&=(&_DKhLUhKUhKUgKTgKTfKSfJSeJRdIRdIQcHPbHPbHOaHOaGN`GN`GN_FL^FL^FL]FL]FL]EL\EL\FL\FL\FL\FL\FM\GM\GN\HN]HO]HP]IP^IQ^JR_KS_KS`LTaMUaMVbNVbNWcOXcOXdPYdPZeQZeQZeQZeQZfR[fR[eQZF22=(&=(&{=(&=(&@+)T<@[AG\AG[AGZAFZ@FZ@EY@EY@EY?DX?DX?DW?CW>CV>BV>BU>BU=AT=AT=AT=AT=AS=@S=AS=AS=AS=AS=AS=BS>BS>BS?CT?CT?DT?DT@EU@EUAFUAFVBGVBGWBHWCHWCIXDIXDJYDJYDJYDKYEKYEKYEKZEKXCIH34=(&=(&=(&=(&=(&=(&=(&=(&=(&=(&=(&=(&=(&=(&=(&=(&=(&=(&=(&=(&=(&=(&=(&=(&=(&=(&=(&=(&=(&=(&=(&=(&=(&=(&=(&=(&=(&=(&=(&=(&=(&=(&=(&=(&=(&=(&=(&=(&=(&=(&=(&=(&=(&=(&=(&=(&=(&=(&=(&=(&=(&=(&=(&K=(&=(&c=(&=(&=(&=(&=(&=(&=(&=(&=(&=(&=(&=(&=(&=(&=(&=(&=(&=(&=(&=(&=(&=(&=(&=(&=(&=(&=(&=(&=(&=(&=(&=(&=(&=(&=(&=(&=(&=(&=(&=(&=(&=(&=(&=(&=(&=(&=(&=(&=(&=(&=(&=(&=(&=(&=(&=(&!(0` $#.#.=(& =(&m=(&=(&=(&=(&=(&=(&=(&=(&=(&=(&=(&=(&=(&=(&=(&=(&=(&=(&=(&=(&=(&=(&=(&=(&=(&=(&=(&=(&=(&=(&=(&=(&=(&=(&=(&=(&=(&=(&=(&=(&=(&=(&!=(& =(&=(&I32O88O88O88N87N87N77M76M66L66L65K55K54J44J44I43I43I33H32H32H22G22G21G21G11F11F10F10F10F10F10F10E00E00E00E00E00E00E00D0/>)'=(&=(&2=(&o=(&{ش׳ղӰЭ̫ɨŤ~{ywvu~s|r{qzpyoxoxnwmvmvmvlulululut`hB-,=(&=(&=(&L65ش״ֳԱүϬ˩Ǧģ}{xwu~t}s|r{pypyoxnwnvmvlvlulululuktktXDG=(&=(& =(&R;;׳ֲձӰЭͫɨƥ¢}lru_dmW[iSWhRVkUZq[azelowoxnwnvmvmvlulultktktkt~kt[GJ=(&=(& =(&R;;ձԱӰЭΫʨǦãoX\E0/=(&=(&>*(?+*?+*>)'=(&=(&@+*R=>hSW}iqluluktktkt~kt~kt~kt}jsZFI=(&=(& =(&R;:ӯѮЭΫ˩Ȧģv~P::=(&KABaktmsttrlcnwVV[F88=(&B-,`KO}iqkt~js~js~js}js}js}jsZFI=(&=(& =(&Q::ϬΫ̪ʨȦģsyF1/@-+bksx{uuw}xj|NHK=(&I44r^e}ir}ir}ir}ir|ir|irZFI=(&=(& =(&Q:9˨ʧȦƤâyF10B0.m{legiloqtwz}er{B00@+)gSX|hq|hq{hq{hq{hqYEH=(&=(& =(&P99ǥƤĢ¡T>>?+)k{{}ebdfhknqtwz}qI=>>)'dPUzgpzgpzgpzgpYEH=(&=(& =(&P98 zbf=(&^`cyzdza~acegjmpsvz}uJ@A?*(kX_yfoyfoyfoXDG=(&=(& =(&O88O99E43w|}qv^y_}abdfhkosvz~ËȎsC44G22wdmxenxenXDG=(&=(& =(&N77pw>)'[]ay|r]~u]x^{_`bdfjnruy~¿ŋʏΒbmv=(&dQVwdmwdmWCG=(&=(& =(&M77}lUX=(&ny~y{p\}s\v]z]~^`behlpuy}Nj̏ГɘwA0/R=?vclvclWCF=(&=(& =(&M66~|yO99F77xxpazoZ|r[~u[y\}]_`cgjosx}†ȋ͐ѓԗNGJF11tbktbkVCF=(&=(& =(&L65|zxip>)'Z^dxxkZynY{pY}tZwZ{[]^adhmrw|Æʋϐӓ֗UTYA,+s`isajVBE=(&=(& =(&K55}{ywu}aJL>*)pwwqwjYxlXyoX{rX~vXzY~Z\_bfkpu{ĆˋѐՔטWX^@+*q_hr`iUBE=(&=(& =(&K54~|{ywu~s|ksA,*QMQxvhYviXwkWxnVzqV|uVyW}XZ\`diotzą̋ҏՔטUUYA,+q_hq_iUAE=(&=(& =(&J44~}|{zywu~s|rzpxZDF>+)nwyxvfWvhWwkVxmUypU{tU~xU|VWZ^bgmsx~ÄˊяՓחPLOF11p_gp_gUAD=(&=(& =(&J33zyxwvu~s|r{pyowlTX>)'XZ_xvfZvfVvhUvjTwmTypS{sS}wT{TUX\`ekqw|‚ɈЍԒՖI=?M9:o^go^gTAD=(&=(& =(&I32vut~s}r|qzpyowmuhPT>)'KCEvvoiveVvfUvhTwjTwmSxpRzsQ|vQ~zRSVY^ciou{ȇΌӑʗu@-+VBFn\fn]fS@D=(&=(& =(&H22r|r{qzpyoxnwmvjr\EG=(&LDFtwxwvdVveVvgTwiSwkRwmQypPzsP|vP~zP~RTX\bhntzƅ̋яeq{=(&bPVm\en\fS@C=(&=(& =(&H21oxnxnwmvluktgoR<<=(&TRVvxxvxdVweUwfTwhSwiRxkQxnPypOzsO|vO~zO~QTW[`fmsy~ńʉxH=>E00lZdm[dm[eS@C=(&=(& =(&G11lukuktjsirfnO99>)'\bhxyskyfUyfUxgTxhSxiRxkQxlPyoOzqN{tN}wMzNQe~|z{rOHK>)'^KQkZdlZdl[dS?C=(&=(& =(&G11irhqhpgpfnW@A=)'_hox{ob{gTzgTzhSzhRyjRykQylPynOzpN{rM|tL}wM}kztdpyVV\NGILA@LA@MFHQMQTSWSQULDF@,+>)'WDGkYckYdkZdlZdR?B=(&=(& =(&G10foenendmkRW=(&XY_y}o_}iT|iS|iS{jR{kQ{kQ{mP{nO{oN{qL|sL|vNzsUUZ@-,=(&?*(C/.E10E11D0/B.,A-+A-+E10O;=aOUiXajYbkYckYckYdR?B=(&=(& =(&F00cl~ck}bj|aiJ44D66v}ufkS~kS~lR~lR}lQ}mP|nP}oO}pN|qL}rK}uLzk}B21?*(M99YFH_LQ`MSaNUbPVcQXdRZeS\fT]gU^gV_iW`iXaiXbjXbjYckYcR>B=(&=(& =(&F0/|ai|`h{`hrW^=(&\ah{nRnRnRnQoQoQoPpN~qMrL~sK~tI|mqA//B.,YEH]JN]JO^KP_LR`NTaOVbQXdRZdS[fT]gU^gV_hW`iWaiXbjXbjXbjYcR>B=(&=(& =(&E//z^gy]fy]efLQ=(&izrpQpQqQqPqOqOrNsLsKtJuIwIySPT>)'XDF\HL\IM\IN]JP^LQ_MSaOUbPWcQYdR[eT]fU^gV_hV`iWaiWaiXbjXbjXcR>B=(&=(& =(&E//x\dw[dv[cbIM=(&l~lsPsPsOtOtNuLuKuKvIwIxH|TvA.-I44\HL[HL\HL\IM]JO^KQ_MS`NVbPXcQYdR[eT]fU^gU_gV`hWaiWaiWbiWbjXbQ>B=(&=(& =(&E/.vZbuYbtYaeKP=(&gvsuOuNvMvMwLxKxJxIyHyGzFao=(&P<=[GK[GK[HL\IM]JO^KQ_LS`NVbOXcQZdR\eT]fU^gU_gV`hVahWaiWbiWbiWbQ>A=(&=(& =(&E/.tX`sW`rW_lQX=(&[`g~xNxMyLyKzJzJ{H{H|G|F}Elgw=(&T@B[GK[GK[HM\HN]IO^KQ_LS`NVbOXcQZdR\eT]fT^gU_gU`hVahVahWahWbiWbQ=A=(&=(& =(&D..rV_rV^qU^pT]E0/J@Ay{P{K{J|I|I}H}G~FEDCx`hp=(&YEH[GL[GL[HM\HN]JO^KQ_LS`NVbOXcQZdR\eS]fT^gT_gU`gU`hVahWahWahWaQ=A=(&=(& =(&D..pT]pT]oT\oS[U=?=)'pj}I~I~HGFDCBA@~WX^?*([GK[GK[GL[HM\HO]JP^KR_LS`NVaOXcPZdR\eS]eS^fT^gT_gU`hV`hV`hVahVaQ=A=(&=(& =(&D.-oS\oS[nR[nRZfKR>)'WY^}JHFEEBA@?>{MEHD0/[GK[GK[GL\GM\HO]IP^KR_LS`MUaOWbPYdQ[eR\eS]fT^gT_gU_gU`hV`hV`hV`Q=A=(&=(& =(&D.-nRZmQZmQZlQYkPXL56@-,ppFEDB@>>>HwB10K66[FL[GL[GL\HM]IO]JP^KR_LT`MUaNWbPYcQZdR\eS]fS]fT^gT^gU_gU_hU`hU`Q=A=(&=(& =(&D.-mPZlPYlPYkPXjOWcIP?*(KCEwdCBA?===lj|=(&R=?\FL[FL\GM\HN]IO]JP^KR_LT`MUaNWbOXcPYdQ[dR\eS]fS]gT^gT^gT_gT_hU`Q=@=(&=(& =(&D.-lPYkOXkOXjOWjNViMVZAE>)'OJMvuD?><<`yNGJ@+)ZEJ[FL\GL\GM\HN\HO]IP^JR_KS`MUaNVbOWcPYdQZdQ[eR\fS]fS]gS^gT^gT_hT_Q<@=(&=(& =(&C--kOXkNWjNWjNViMVhMUgLTW>B>)'H<=lzy}}rQMP=(&O:<\FL\FL\GL\GM\HN]IO]IP^JR_KS`LUaMVaNWbOXcPYdQZeQ[eR\fS]gS]gS^gS^gT^P<@=(&=(& =(&C--kNWjNWjNViMVhMUgLUfLTeKRZAFA,+>)(MEGZ^d]dk[`gQLP@.,>)'N9:\FK\FL\FL[FL\GM\HN]IP^IP^JR_KS`LTaMVbNWbOXcOYdPZeQ[eR\fR\fS]fS]gS]gS]P=(&=(& =(&>)'cHPhLUhKUgKTfKSfJSeJRdIRcIPbHPaHOaGN`GN_FM^FL]FL]FL]EL\EL\FL\FL\FL\FM\GN\HN]HO]IP^JR_JR_KS`LTaMUbNVbOWcOXdPYdPZeQZeQZeQZfR[fR[F22=(&=(&=(&+=(&B,,R:=T;?S;?S:>S:>R:=R:=Q9O;>P;?PBR>BR>BR>BH34=(&=(&r=(&B=(&=(&=(&=(&=(&=(&=(&=(&=(&=(&=(&=(&=(&=(&=(&=(&=(&=(&=(&=(&=(&=(&=(&=(&=(&=(&=(&=(&=(&=(&=(&=(&=(&=(&=(&=(&=(&=(&=(&=(&=(&=(&=(&x=(&=(&=(&=(&=(&=(&=(&=(&=(&=(&=(&=(&=(&=(&=(&=(&=(&=(&=(&=(&=(&=(&=(&=(&=(&=(&=(&=(&=(&=(&=(&=(&=(&=(&=(&=(&=(&=(&=(&=(&=(&=(&=(&( @ #.#.=(& =(&=(&=(&=(&=(&=(&=(&=(&=(&=(&=(&=(&=(&=(&=(&=(&=(&=(&=(&=(&=(&=(&=(&=(&=(&=(&=(&=(&`?*(I32I32I32H21H21G21G10F10F0/F0/E0/E/.D/.D/.D/.D.-C.-C.-C.-C.-C.-C.-C.-B.,B.,B-,?*(=(&=(&=(& ?*(״ձЭ˪ƥ}zwu~s|qzpyoxnwmvmvluluzfoE0/=(&8=(& K54׳ղүάɧãovzciu^du_ezdlnvoxnwmvlulultktktQ<>=(&V=(& K54ԱүϬ˨Ť|diI33D54MEHOJNNGJG;<@,+N9:gRW~jsktkt~kt~kt}jsP<==(&V=(& K43ЬΫ˨ƥiQTD54hvgu~MFIF11o[a}js}js}ir|irP<==(&V=(& J43ʧȦģpX\G:9tpehlpuyeqzB/.cOT{hq{hqzhqP;==(&V=(& I32át{B.,q}j}adgkoty~tC32eQWyfoyfoO;<=(&V=(& I32bKMYZ]{|v^{`beinsyŌnB-,tajwenO:<=(&V=(& H21|E/.n{q_}t\y]_bfkrx¿ȌϒOHKaMRvdlN:;=(&V=(& G11{}djF89xynZ|rZw[}]`ciow~ʌѓǚ]dkT@CtbkN:;=(&V=(& G10|yvZDF\biwunxlYzpX}uY|Z\`fmt|„̌ӓϚbluP<>r`iM9;=(&V=(& F0/~|ywt}gnB/.svhXwjWyoV|tVzWY]cjr{ƒ͌Փ͚`iqQ>@q_hM9:=(&V=(& E0/{zxvt}qzmvO9:\biwurvgVvjUxnTzsT~xTUY_gpẙԑŚZ]dWDGo^gL8:=(&V=(& E/.u~t}r|qzoxhpQ;;OJMwveWvgUwjTxnRzrQ|wQ~RV\dmvɈѐMFI`MSn\fL8:=(&V=(& D/.pyoxnwlu|biH22TSWwwfYwfUwhSwkRxnPzrO}wO}QUZbku~DžővA.,kYbm\eL89=(&V=(& D.-ktjsir{ahE0/]biyzgVygTyhSxjQxlPyoO{sN}xM\{rpv~ft}E67YFKkZdlZdK79=(&V=(& C.-fofndmM77Z^e{}|iT|iS{iR{kQzlPzoO{qM|uM{~ft}LDG?+)A-,C.-A-,@+)@,*G33[HMjYbkYckYdK79=(&V=(& C-,~bk}bjlRXF99zlS~lR~lR}mQ}nP}pN}qL}sKzUUZC/.TAC^KO`MSbOVcQYeS[fU]gV_iWaiXbjXbjYcK79=(&V=(& C-,z^gy^fZBEY[a{jpQpQqPqOrMsLuJ{XdqzC.,[GK\IM]JO_LRaNUbPXdR[fT]gU_hWaiWaiXbjXcK79=(&V=(& B-,w[cvZcV>A\ah{ctPtOuMvLvJwIxG~qPJNQ<=[HK\HL]JO^LR`NUbPXdR[eT]gU_hV`iWaiWbiWbK78=(&V=(& B,+tX`sW`[CFSQVpwMxLyKzJzH{G|E~}G:;VBE[GK[HM]IO^KR`MUbPXdR[eT]gU_gU`hVahWaiWbJ68=(&V=(& B,+qU^qU]gMSC32}{K|I}H~G~EDB}@,+ZFI[GL[HM]IO^KR`MUbPXdR[eS]fT^gU`hV`hVahWaJ68=(&V=(& B,+pS\oS[nRZF0/gt}VHFDB@?sA,+[GK[GL\HN]IP^KR`MUbOXcQ[eR]fT^gT_gU`hV`hV`J68=(&V=(& B,+nQZmQZlPYZBFH=>}GDA?>IdpyG22[FL[GL\HN]IP^KS`MUaOWcQZdR\eS]fT^gT_gU_hU`J68=(&V=(& A,+lPYkOXjOWiNVK45ROS~J?=@MFIP;=[FL\GM\HN]IP^KR`LUaNWcPYdQZeR\fS]gS^gT_hT_J67=(&V=(& A,+kNXjNWiNVhMUeKRJ34JACl|pQLPD/.[EK\FL\GM\HO]IP^JR`LTaMVbOXdPZeQ[eR\fS]gS^gT^J57=(&V=(& A,+jMWiMVhLUgLTfKSdJQT*(>*)B-+M89[EJ\FL\FL\GM]HO]IP^JR`LTaMVbNWcOYdPZeR[fR\fS\gS]J57=(&V=(& A+*iMViLUhLTfKSeJRdIQcIPaHO_FL^FL^FL]FL\FL\FL\FM\GM\HN]IP^JR`LSaMUbNWcOXdPYeQZeR[fR[fR\J56=(&V=(&?)(fJShKUgKTfJSeJRdIQbHPaGN`GN_FM]FL]EL\FL\FL\FM\GM\HN]HP^JR_KS`LUbNVcOWdPYeQZeQZfR[fR[E01=(&I=(&D.-L47L46L46K46K45J35J34I34I34I33H23H24H33H34H34H34H45I46I46J56J57J68K68K68K78K79F22=(&=(&=(&=(&E=(&c=(&c=(&c=(&c=(&c=(&c=(&c=(&c=(&c=(&c=(&c=(&c=(&c=(&c=(&c=(&c=(&c=(&c=(&c=(&c=(&c=(&c=(&c=(&c=(&c=(&c=(&S=(&(  #.#.=(&C.,H20G10F0/E0/D/.D/-C.-C.,B-,B-,B-,B-,@+*=(&$E/-ЭѮǦrzjrmvoxmvlu~jsH33I32Ѯ˨y\QTegemoiefcZRVmX^~js}jsK77H21ţZQSsfnwrwrbOSzgpK67G10v^cospx^bjuǐijgq^fJ66F0/z^QTxvk}tZ]erϐcPUI56E/.}zu~y`gbcfviWzqU|V^m~ЎbPVI45D.-r{oxt[a[W[wmdwiSypQzQXhzʋkmhiX`H45C-,hqsY`^\_{o`ziRylP{rMw}iac`YY[]]^[Y[YGLkZdH34B-,|`hYJNxfnQpO~rLwzgN>@[HL`NTdRZgU^iWajXbG34B,+uYbXMQz\vMxJzGee^WCF\IN_MScQZfT^hVaiWbG34A,+pT]W@D~j~HEA\[Z[GK\IO_LScPZfS^gU`hVaG33A+*lPYfLSYTTW?RRIM[GL]IO_LTbOXeR\fT^gT_G23A+*jNWhMU^EKSIL_^YTMOV@D\GM]HO_KSaNWdPZfR\gS]G23@*)hLUgKTdJQbHO_FM]FL\FL\GM]HO_KRaMVcOXeQZfR[E00=(&'E//H12G01F00F00E00E00E00E01F11F12G23G23F11=(&7napari-0.5.0a1/napari/resources/icons/000077500000000000000000000000001437041365600176205ustar00rootroot00000000000000napari-0.5.0a1/napari/resources/icons/2D.svg000066400000000000000000000010341437041365600206040ustar00rootroot00000000000000 napari-0.5.0a1/napari/resources/icons/3D.svg000066400000000000000000000016331437041365600206120ustar00rootroot00000000000000 napari-0.5.0a1/napari/resources/icons/add.svg000066400000000000000000000014201437041365600210660ustar00rootroot00000000000000 napari-0.5.0a1/napari/resources/icons/check.svg000066400000000000000000000003431437041365600214160ustar00rootroot00000000000000 napari-0.5.0a1/napari/resources/icons/chevron_down.svg000066400000000000000000000012651437041365600230400ustar00rootroot00000000000000 napari-0.5.0a1/napari/resources/icons/chevron_left.svg000066400000000000000000000012651437041365600230230ustar00rootroot00000000000000 napari-0.5.0a1/napari/resources/icons/chevron_up.svg000066400000000000000000000012521437041365600225110ustar00rootroot00000000000000 napari-0.5.0a1/napari/resources/icons/circle.svg000066400000000000000000000003201437041365600215750ustar00rootroot00000000000000 napari-0.5.0a1/napari/resources/icons/console.svg000066400000000000000000000015201437041365600220010ustar00rootroot00000000000000 napari-0.5.0a1/napari/resources/icons/copy_to_clipboard.svg000066400000000000000000000011071437041365600240330ustar00rootroot00000000000000 napari-0.5.0a1/napari/resources/icons/debug.svg000066400000000000000000000001041437041365600214220ustar00rootroot00000000000000napari-0.5.0a1/napari/resources/icons/delete.svg000066400000000000000000000013201437041365600215770ustar00rootroot00000000000000 napari-0.5.0a1/napari/resources/icons/delete_shape.svg000066400000000000000000000011131437041365600227570ustar00rootroot00000000000000 napari-0.5.0a1/napari/resources/icons/direct.svg000066400000000000000000000013611437041365600216140ustar00rootroot00000000000000 napari-0.5.0a1/napari/resources/icons/down_arrow.svg000066400000000000000000000006241437041365600225240ustar00rootroot00000000000000 napari-0.5.0a1/napari/resources/icons/drop_down.svg000066400000000000000000000006241437041365600223360ustar00rootroot00000000000000 napari-0.5.0a1/napari/resources/icons/ellipse.svg000066400000000000000000000025421437041365600220010ustar00rootroot00000000000000 napari-0.5.0a1/napari/resources/icons/erase.svg000066400000000000000000000007051437041365600214420ustar00rootroot00000000000000 Artboard 1 napari-0.5.0a1/napari/resources/icons/error.svg000066400000000000000000000007111437041365600214710ustar00rootroot00000000000000napari-0.5.0a1/napari/resources/icons/fill.svg000066400000000000000000000012151437041365600212660ustar00rootroot00000000000000 Artboard 1 napari-0.5.0a1/napari/resources/icons/grid.svg000066400000000000000000000014611437041365600212700ustar00rootroot00000000000000 napari-0.5.0a1/napari/resources/icons/help.svg000066400000000000000000000024241437041365600212730ustar00rootroot00000000000000 napari-0.5.0a1/napari/resources/icons/home.svg000066400000000000000000000013441437041365600212730ustar00rootroot00000000000000 napari-0.5.0a1/napari/resources/icons/horizontal_separator.svg000066400000000000000000000014631437041365600246160ustar00rootroot00000000000000 napari-0.5.0a1/napari/resources/icons/info.svg000066400000000000000000000007371437041365600213030ustar00rootroot00000000000000napari-0.5.0a1/napari/resources/icons/left_arrow.svg000066400000000000000000000006241437041365600225070ustar00rootroot00000000000000 napari-0.5.0a1/napari/resources/icons/line.svg000066400000000000000000000014541437041365600212740ustar00rootroot00000000000000 napari-0.5.0a1/napari/resources/icons/logo_silhouette.svg000066400000000000000000000035021437041365600235460ustar00rootroot00000000000000 napari-0.5.0a1/napari/resources/icons/long_left_arrow.svg000066400000000000000000000010201437041365600235150ustar00rootroot00000000000000 napari-0.5.0a1/napari/resources/icons/long_right_arrow.svg000066400000000000000000000007201437041365600237060ustar00rootroot00000000000000 napari-0.5.0a1/napari/resources/icons/minus.svg000066400000000000000000000006301437041365600214730ustar00rootroot00000000000000 napari-0.5.0a1/napari/resources/icons/move_back.svg000066400000000000000000000014401437041365600222660ustar00rootroot00000000000000 napari-0.5.0a1/napari/resources/icons/move_front.svg000066400000000000000000000013671437041365600225260ustar00rootroot00000000000000 napari-0.5.0a1/napari/resources/icons/new_image.svg000066400000000000000000000016321437041365600222760ustar00rootroot00000000000000 napari-0.5.0a1/napari/resources/icons/new_labels.svg000066400000000000000000000012701437041365600224540ustar00rootroot00000000000000 napari-0.5.0a1/napari/resources/icons/new_points.svg000066400000000000000000000011061437041365600225240ustar00rootroot00000000000000 napari-0.5.0a1/napari/resources/icons/new_shapes.svg000066400000000000000000000006411437041365600224760ustar00rootroot00000000000000 napari-0.5.0a1/napari/resources/icons/new_surface.svg000066400000000000000000000007331437041365600226450ustar00rootroot00000000000000 napari-0.5.0a1/napari/resources/icons/new_tracks.svg000066400000000000000000000015001437041365600224750ustar00rootroot00000000000000 napari-0.5.0a1/napari/resources/icons/new_vectors.svg000066400000000000000000000025751437041365600227100ustar00rootroot00000000000000 napari-0.5.0a1/napari/resources/icons/none.svg000066400000000000000000000001041437041365600212730ustar00rootroot00000000000000napari-0.5.0a1/napari/resources/icons/paint.svg000066400000000000000000000016501437041365600214560ustar00rootroot00000000000000 napari-0.5.0a1/napari/resources/icons/path.svg000066400000000000000000000025361437041365600213030ustar00rootroot00000000000000 napari-0.5.0a1/napari/resources/icons/picker.svg000066400000000000000000000043721437041365600216240ustar00rootroot00000000000000 napari-0.5.0a1/napari/resources/icons/plus.svg000066400000000000000000000010241437041365600213210ustar00rootroot00000000000000 napari-0.5.0a1/napari/resources/icons/polygon.svg000066400000000000000000000021771437041365600220370ustar00rootroot00000000000000 napari-0.5.0a1/napari/resources/icons/pop_out.svg000066400000000000000000000012461437041365600220310ustar00rootroot00000000000000 napari-0.5.0a1/napari/resources/icons/rectangle.svg000066400000000000000000000023701437041365600223070ustar00rootroot00000000000000 napari-0.5.0a1/napari/resources/icons/right_arrow.svg000066400000000000000000000006241437041365600226720ustar00rootroot00000000000000 napari-0.5.0a1/napari/resources/icons/roll.svg000066400000000000000000000027351437041365600213200ustar00rootroot00000000000000 napari-0.5.0a1/napari/resources/icons/select.svg000066400000000000000000000012661437041365600216250ustar00rootroot00000000000000 napari-0.5.0a1/napari/resources/icons/shuffle.svg000066400000000000000000000013771437041365600220050ustar00rootroot00000000000000 Artboard 1 napari-0.5.0a1/napari/resources/icons/square.svg000066400000000000000000000006311437041365600216410ustar00rootroot00000000000000 napari-0.5.0a1/napari/resources/icons/step_left.svg000066400000000000000000000010111437041365600223170ustar00rootroot00000000000000 napari-0.5.0a1/napari/resources/icons/step_right.svg000066400000000000000000000007041437041365600225120ustar00rootroot00000000000000 napari-0.5.0a1/napari/resources/icons/transpose.svg000066400000000000000000000012421437041365600223560ustar00rootroot00000000000000 napari-0.5.0a1/napari/resources/icons/up_arrow.svg000066400000000000000000000006241437041365600222010ustar00rootroot00000000000000 napari-0.5.0a1/napari/resources/icons/vertex_insert.svg000066400000000000000000000016421437041365600232450ustar00rootroot00000000000000 napari-0.5.0a1/napari/resources/icons/vertex_remove.svg000066400000000000000000000015721437041365600232400ustar00rootroot00000000000000 napari-0.5.0a1/napari/resources/icons/vertical_separator.svg000066400000000000000000000014631437041365600242360ustar00rootroot00000000000000 napari-0.5.0a1/napari/resources/icons/visibility.svg000066400000000000000000000012421437041365600225270ustar00rootroot00000000000000 napari-0.5.0a1/napari/resources/icons/visibility_off.svg000066400000000000000000000014241437041365600233630ustar00rootroot00000000000000 napari-0.5.0a1/napari/resources/icons/warning.svg000066400000000000000000000012221437041365600220030ustar00rootroot00000000000000napari-0.5.0a1/napari/resources/icons/zoom.svg000066400000000000000000000013331437041365600213250ustar00rootroot00000000000000 napari-0.5.0a1/napari/resources/loading.gif000066400000000000000000013022201437041365600206110ustar00rootroot00000000000000GIF89ar2WIDZv*IDl=Tbߥb`'RX!-<,X^u6. E:gôIw,W>6˄-3kM)_eͨ9@U4\d6lThX3=k Bt8|pp^l ZCpǨBBK&dƎ ?_e3 Ql@O{YY@}}<-7dG%7&H_9/"Df쀓; 8"vqAIјΊ7H{0` cv0&3{HB.9 >AJJ\ ]@Z W!4Yf$L6dpxgITv:J0EQgc pGdѧs4S+,u# W EÁ~K7;љsPx z:-DȠ!GWQ"k[,1}F HԢfk*\qEj䰒; +)88N(F(f(T' YSԋ!F;pĺSFJ4XܯNX4 k*`/OoʙeEA3f ^(C;E ?<]E&QcIE=8-4D;"I)MjT4;72JOr6 d7EMs6$ gI<0TT '?[nޚ9栛$ӥ~z}4:s˰WNܘRۘ'qR뚇3&о >(P=ljOK5E7 OIO-8t!v %a Mh߈E !pyL% ሔe,RI BfD3/$90!T 5HfJ~@%_;@V$J pX-L#0Ap`۟J@-BK#G]I cIا+#BC 8ՒT`H81x"-Y%иDV&jȑ6Q I#-P"& p" ,#"L1 \I U nK4b5y,gU /(f`!A8M^S@3E4ei@ ytFnn ,/lOA I4p 07!Lڌk2I@GA "Oj 碼AO :g 0rV x&`CP0%񯟬#1)f.ʁ8Xl>8( V֨3$rIVN c!@-*ֶf (x ~p[c'@(p "0 (Bp` |x 0O WJA^!B riV 8Z~JigKͭnw pKMr:ЍtKZͮvz xKMz|Kͯ~L(FL  An;s(l0hcJw80:Z%8}f1)!ō`0 ",)BnS. EH)O6b(*9P@6F19Lf Ag@  C4l%D=Y-4д'@w`ϐ1ڥ$h(q:ׄuf$fdCD!z70!! &O9$28x_L@Kputy)a=akDLߴ1X"*.N#=#pƠ;.>,ecFpc(GWPd;A*;iDmZ6a-bHŵ$`'&pA>'Qv[dX",%Z{Z-fO$_WxzOQ$%Qs`)0Wʼn?SP\N3TߺNh'39'):1i%1(ـ 3z;€<=&AOu(-h'y%;&Ҡ}q(1E|%A&d` )=3\s4I  &|7D zupħu)#!1v&!y;e'f/)E8Os8PR x!2PF`x,.c@0m҅r(uFF?/pF-/c.(rNA,PR.Q +d,F WD-'`m,u  !E͢+PN2,#$ 8!Q)GÔNR()"O#PM6qش(Gx. P>0" $`Ofr0# d$hb#gBO5t$ wPt:Pؑ XPi` IЇAq9p,P`)$P9  0O D J)o i$ 9%0ٝ9Yy虞깞ٞ9Yyٟ:Zz ڠ #0p9[0*@pD(P0 ~p/D>CL` (l( + [AqpfTU@ >D`U. +  ~ѥzxR;rQ ;:PRopuoc "ȕUUl6A k@&p sg6^/5j'6^+öp1Q +^;0j(g(H)] As0%-7ٕ't@{]ЎTʢ]70‚! J-.@յt)`+]|BJ4~uO}(~g]2(Q&ٰB%C7]C8PKGM9h9[]3ÓZRųB(eBF+Rb `]ꖄ'!L(4Qc]`I) Džw]Я 2NiUK]:-*v]- D@*$x]0Pi0zh׵*aJ`غ]:040zAe(. 9?].o,JZ,G3 ]@Pq%m'q/ 'B(h   ;T ]@&%Gsi/re`4. <>"8P/sLpFUw_PPV!U `|E!S)?w_XP T`0 t@@H 'n0u< Q 5_>Ȑɒ<ɔ\ɖ|ɗ! , H*\ȰÇ#JHŋ3jȱǏ CIɓ(S\ɲ˗0cʜI͛8sɳϟ@ Jѣ$!drZB`'RX!-<,X^u6. E:gôIw,W>6˄-3kM)_eͨ9@U4\d6lThX3=k Bt8|pp^l ZCpǨBBK&dƎ ?_e3 Ql@O{YY@}}<-7dG%7&H_9/"Df쀓; 8"vqAIјΊ7H{0` cv0&3{HB.9 >AJJ\ ]@Z W!4Yf$L6dpxgITv:J0EQgc pGdѧs4S+,u# W EÁ~K7;љsPx z:-DȠ!GWQ"k[,1}F HԢfk*\qEj䰒; +)88N(F(f(T' YSԋ!F;pĺSFJ4XܯNX4 k*`/OoʙeEA3f ^(C;E ?<]E&QcIE=8-4D;"I)MjT4;72JOr6 d7EMs6$ gI<0TT '?[nޚ9栛$ӥ~z}4:s˰WNܘRۘ'qR뚇3&о >(P=ljOK5E7 OIO-8t!v %a Mh߈E !pyL% ሔe,RI BfD3/$90!T 5HfJ~@%_;@V$J pX-L#0Ap`۟J@-BK#G]I cIا+#BC 8ՒT`H81x"-Y%иDV&jȑ6Q I#-P"& p" ,#"L1 \I U nK4b5y,gU /(f`!A8M^S@3E4ei@ ytFnn ,/lOA I4p 07!Lڌk2I@GA "Oj 碼AO :g 0rV x&`CP0%񯟬#1)f.ʁ8Xl>8( V֨3$rIVN c!@-*ֶf (x ~p[c'@(p "0 (Bp` |x 0O WJA^!B riV 8Z~JigKͭnw pKMr:ЍtKZͮvz xKMz|Kͯ~L(FL  An;s(+~ PG[kgРl4F0:#^A%eʓ(@+( 6@A_9L՞ Ag@ 7G f %22PeqΌl^MDsb,<;Es8$tЈJrkb X̪IE#^墿'gau^I&01@=!̘Q9:a캃ޑ2z$5aDSodZ-ƒb2Xt n40%},@]DA4M#จ*u1(GiPVwطA)8`Z\u?D HlEݰ=-}(jOhIJQge#ֽвQYK•=]W:Rg-uU!\}Vu>v완xKtY0uj;{!iFI.)S{Z} ;"vs!7=%SL {()IGxWwAK8ΘCKnvXג2j]vhb߯1p`=N`0v ٯٿ$h~IjAa. U@u7:Y;GT@tr,|0 ( )<0,LDÔNR(З0(lQI )Pq dWM2l w cq@|v>0"$`Oh7X A#:r( A&6r;!YN8 fR @+|*g ng`q8 0q6m' _F|P09 -'G*A<eB!@x~9!B*1[@7@m5V MPX` )k.Ae|` C{h}H O < +FP/D|`'AX*0G.le0i2r)Q*)Fippg9S@xvBIC3@ )W) T a> , 4 14s<Ó;;@#4DYqCkԙQ+ I!jy /Ěi!'h}R =y:`h Qh0jc#Dϙ>` j 8 "pי@dFvRY 0# W  fAP`y5( +k7D(P0 ~py>CL` Jl`js aa0۰.8Aq`QU@ ?D;q^[ H "@p;8@Nh I^/Ez֩%@u0М(H ^ K(J G^Qh›ig6^/w5j'6^1Q +^;0Ή(f(("~JZt/%Lڕz9?#. .z -}"mI&kJ-.7n + 22E> ]T`2.*-;# U]qI*AIoE#y]tU 4s]\xȢ?(11G3c]6'4K$1bi'~EwOt(Att%v4J|PZw=cv%cuc}&avc>gÃhRuեBs(R&wwB(+Zb `]x'!'4Qc]0yI) m@re*!`< Sgԥܢ+aG(} |2)E,FifŬ)RJ@͗J"zAI _) I)F .]Ya,ߠF3 Ӻ]@%̷+A6^ث*h pݥ| NZ( 8peq0qzm/ ePl5 PP+1c^ 728QA `DjQPpG% lX4W"Qv_XP T`0 t@F n[< Q "_>Yv|xz|~_! , H*\ȰÇ#JHŋ3jȱǏ CIɓ(S\ɲ˗0cʜI͛8sɳϟ@ Jѣ$!drZB`'RX!-<,X^u6. E:gôIw,W>6˄-3kM)_eͨ9@U4\d6lThX3=k Bt8|pp^l ZCpǨBBK&dƎ ?_e3 Ql@O{YY@}}<-7dG%7&H_9/"Df쀓; 8"vqAIјΊ7H{0` cv0&3{HB.9 >AJJ\ ]@Z W!4Yf$L6dpxgITv:J0EQgc pGdѧs4S+,u# W EÁ~K7;љsPx z:-DȠ!GWQ"k[,1}F HԢfk*\qEj䰒; +)88N(F(f(T' YSԋ!F;pĺSFJ4XܯNX4 k*`/OoʙeEA3f ^(C;E ?<]E&QcIE=8-4D;"I)MjT4;72JOr6 d7EMs6$ gI<0TT '?[nޚ9栛$ӥ~z}4:s˰WNܘRۘ'qR뚇3&о >(P=ljOK5E7 OIO-8t!v %a Mh߈E !pyL% ሔe,RI BfD3/$90!T 5HfJ~@%_;@V$J pX-L#0Ap`۟J@-BK#G]I cIا+#BC 8ՒT`H81x"-Y%иDV&jȑ6Q I#-P"& p" ,#"L1 \I U nK4b5y,gU /(f`!A8M^S@3E4ei@ ytFnn ,/lOA I4p 07!Lڌk2I@GA "Oj 碼AO :g 0rV x&`CP0%񯟬#1)f.ʁ8Xl>8( V֨3$rIVN c!@-*ֶf (x ~p[c'@(p "0 (Bp` |x 0O WJA^!B riV 8Z~JigKͭnw pKMr:ЍtKZͮvz xKMz|Kͯ~L(FL  An;s(+~ PG[kgРl4F0:#^A%eʓ(@+( 6@A_9L՞ Ag@ 7G f %22PeqΌl^MDsb,<;Es8$tЈJrkb X̪IE#^墿'gau^I&01@=!̘Q9:a캃ޑ2z$5aDSodZ-ƒb2Xt n40%},@]DA4M#จ*u1(GiPVwطA)8`Z\u?D HlEݰ=-}(jOhIJQge#ֽвQYK•=]W:Rg-uU!\}Vu>v완xKtY0uj;{!iFI.)S{Z} ;"vs!7=%SL {()IGxWwAK8ΘCKnvXג2j]vhb߯1p`=N`0v ٯٿ$h~IjAa. U@u7:Y;GT@tr,|$^>p,L41L$%}ߥPq dWM2l ^>0 "$`OhB^$hb#gBO+^:P2 X4   asBDZНoX`4XndA!AwOt(Kt8 0 Ku#!v4J|h {$@C;X'w$3v`7]D:V:ZtgtsV;+ãaRuŲB(p%wwB|FS4 k+)DxB54)$] 2fio]K]:-*Kv]- D.2$]!2Pi0z`fe/)T+@#9C.A]>`(.2]m.>.QzJay':^-`G1 *^@k2Û%'@'((^j !j 0qc % 8 fR Gjo/le+. \>1^ 28QA `%KQpG%l{X4W0(cllUv_XP T`0 t@5H n}< Q _>5]yɘɚɜɞɠ^! , H*\ȰÇ#JHŋ3jȱǏ CIɓ(S\ɲ˗0cʜI͛8sɳϟ@ Jѣ$!drZB`'RX!-<,X^u6. E:gôIw,W>6˄-3kM)_eͨ9@U4\d6lThX3=k Bt8|pp^l ZCpǨBBK&dƎ ?_e3 Ql@O{YY@}}<-7dG%7&H_9/"Df쀓; 8"vqAIјΊ7H{0` cv0&3{HB.9 >AJJ\ ]@Z W!4Yf$L6dpxgITv:J0EQgc pGdѧs4S+,u# W EÁ~K7;љsPx z:-DȠ!GWQ"k[,1}F HԢfk*\qEj䰒; +)88N(F(f(T' YSԋ!F;pĺSFJ4XܯNX4 k*`/OoʙeEA3f ^(C;E ?<]E&QcIE=8-4D;"I)MjT4;72JOr6 d7EMs6$ gI<0TT '?[nޚ9栛$ӥ~z}4:s˰WNܘRۘ'qR뚇3&о >(P=ljOK5E7 OIO-8t!v %a Mh߈E !pyL% ሔe,RI BfD3/$90!T 5HfJ~@%_;@V$J pX-L#0Ap`۟J@-BK#G]I cIا+#BC 8ՒT`H81x"-Y%иDV&jȑ6Q I#-P"& p" ,#"L1 \I U nK4b5y,gU /(f`!A8M^S@3E4ei@ ytFnn ,/lOA I4p 07!Lڌk2I@GA "Oj 碼AO :g 0rV x&`CP0%񯟬#1)f.ʁ8Xl>8( V֨3$rIVN c!@-*ֶf (x ~p[c'@(p "0 (Bp` |x 0O WJA^!B riV 8Z~JigKͭnw pKMr:ЍtKZͮvz xKMz|Kͯ~L(FL  An;s(+~ PG[kgРl4F0:#^A%eʓ(@+( 6@A_9L՞ Ag@ 7G f %22PeqΌl^MDsb,<;Es8$tЈJrkb X̪IE#^墿'gau^I&01@=!̘Q9:a캃ޑ2z$5aDSodZ-ƒb2Xt n40%},@]DA4M#จ*u1(GiPVwطA)8`Z\u?D HlEݰ=-}(jOhIJQge#ֽвQYK•=]W:Rg-uU!\}Vu>v완xKtY0uj;{!iFI.)S{Z} ;"vs!7=%SL {()IGxWwAK8ΘCKnvXג2j]vhb߯1p`=N`0v ٯٿ$h~IjAa. U@u7:Y;GT@tr,|$^>p,L41L$%}ߥPq dWM2l ^>0 "$`OhB^$hb#gBO+^:P2 XCL` Ll( + [:1o(!w[(Q4J @(PoO8ߢ0Gp  z/0P$/#A%%z0 <pC q0jc"H>`r5QhP6A p ~.c &p s hPi-4)w5&+@xj &0Rj-AtOϠ;#i(f(,ĩ!=,"ua;!JZt/%L(:BA9?Р! .Ѕ3:6:Ѱo,);5<1mimfX `}R!n+ z,Q*)2)avOs71* Z2cu*4jTP2.*-;# e)4c`4}4fipCA#y]Pr&I3ꀝ,#s43>x#a`󪺲YvX@AwOt(ѧct'A/eibJ| )+9p93v`gf90ZКuV:* Z,8gnt`~vhRu*rP҂BC( &w(:'4Lg$Bc 9 ,wxB5oR4)$vpv pa`< c-!& {z:-*fKv$PP B${ʚ@xJ3r`4!2תi0z`f3  #ᣄ)9B8 @yI6I*.JZ;"zA; 0 Ku&j_)BI)A!?@$km.>.QzJatǡ:^-G1 Hʞ#]@kٛ89^2|2{`Y^Ok!P+ 0qc % 80 fR Gio/le +. L>1(^ n28QA `KQpG%l{X4W0(_lUk_XP T`0 t@p/H npy< Q _>%9ɔ\ɖ|ɘɚɜl! , H*\ȰÇ#JHŋ3jȱǏ CIɓ(S\ɲ˗0cʜI͛8sɳϟ@ Jѣ$!drZB`'RX!-<,X^u6. E:gôIw,W>6˄-3kM)_eͨ9@U4\d6lThX3=k Bt8|pp^l ZCpǨBBK&dƎ ?_e3 Ql@O{YY@}}<-7dG%7&H_9/"Df쀓; 8"vqAIјΊ7H{0` cv0&3{HB.9 >AJJ\ ]@Z W!4Yf$L6dpxgITv:J0EQgc pGdѧs4S+,u# W EÁ~K7;љsPx z:-DȠ!GWQ"k[,1}F HԢfk*\qEj䰒; +)88N(F(f(T' YSԋ!F;pĺSFJ4XܯNX4 k*`/OoʙeEA3f ^(C;E ?<]E&QcIE=8-4D;"I)MjT4;72JOr6 d7EMs6$ gI<0TT '?[nޚ9栛$ӥ~z}4:s˰WNܘRۘ'qR뚇3&о >(P=ljOK5E7 OIO-8t!v %a Mh߈E !pyL% ሔe,RI BfD3/$90!T 5HfJ~@%_;@V$J pX-L#0Ap`۟J@-BK#G]I cIا+#BC 8ՒT`H81x"-Y%иDV&jȑ6Q I#-P"& p" ,#"L1 \I U nK4b5y,gU /(f`!A8M^S@3E4ei@ ytFnn ,/lOA I4p 07!Lڌk2I@GA "Oj 碼AO :g 0rV x&`CP0%񯟬#1)f.ʁ8Xl>8( V֨3$rIVN c!@-*ֶf (x ~p[c'@(p "0 (Bp` |x 0O WJA^!B riV 8Z~JigKͭnw pKMr:ЍtKZͮvz xKMz|Kͯ~L(FL  An;s(+~ PG[kgРl4F0:#^A%eʓ(@+( 6@A_9L՞ Ag@ 7G f %22PeqΌl^MDsb,<;Es8$tЈJrkb X̪IE#^墿'gau^I&01@=!̘Q9:a캃ޑ2z$5aDSodZ-ƒb2Xt n40%},@]DA4M#จ*u1(GiPVwطA)8`Z\u?D HlEݰ=-}(jOhIJQge#ֽвQYK•=]W:Rg-uU!\}Vu>v완xKtY0uj;{!iFI.)S{Z} ;"vs!7=%SL {()IGxWwAK8ΘCKnvXג2j]vhb߯1p`=N`0v ٯٿ$h~IjAa. U@u7:Y;GT@tr,|$^>p,L41L$%}ߥPq dWM2l ^>0 "$`OhB^$hb#gBO+^:P2 XCL` l( + [D#cQ(Q4 @(P=O Ђ,  z8A Ah9/Ez%E@|80( f60@8 H:j](`h&!ijɖ ~3qy/ &p sg&e@k)"i!xW}i2ks &0Rj1l;(f(/Q.c|,(0  ΡH_lӦ079?P#.'!npѰo,*a'{? F&k..`& '!9z2z,Q*)2)3l/E)3*A 4pTУ 2.*-;# e+@x4}46tl' A#y]PBC?#b#\xȢ?(11G3sua;asBYvX:AwOt(Atts#bG3(y{; :=cv$& 8rG^wa9gf9sj4/cZRu&$c`BC($w# V:D_Z'4Lg$Oc СKj``x'!'4Q790UP4)$C t 2joZzܢ+гaG@n$_ - D)za2)E,F fep(R#q.pG1p;#H4*mjf_)I)/qF .'ra G) -lG1 $:ii'6 *Q)p0Nh8@!'hd6&`Rfu}B!ɻ!޻"`@s' 0qc N+( 83! &16@b&%0qm/?5y|t ;"5 PP8`@p u 6/sL8 pF@U)| ?PGVU D!S1`%VU }m@@8t :P ~ ȓn [̎ɜɞɠʢ,! , H*\ȰÇ#JHŋ3jȱǏ CIɓ(S\ɲ˗0cʜI͛8sɳϟ@ Jѣ$!drZB`'RX!-<,X^u6. E:gôIw,W>6˄-3kM)_eͨ9@U4\d6lThX3=k Bt8|pp^l ZCpǨBBK&dƎ ?_e3 Ql@O{YY@}}<-7dG%7&H_9/"Df쀓; 8"vqAIјΊ7H{0` cv0&3{HB.9 >AJJ\ ]@Z W!4Yf$L6dpxgITv:J0EQgc pGdѧs4S+,u# W EÁ~K7;љsPx z:-DȠ!GWQ"k[,1}F HԢfk*\qEj䰒; +)88N(F(f(T' YSԋ!F;pĺSFJ4XܯNX4 k*`/OoʙeEA3f ^(C;E ?<]E&QcIE=8-4D;"I)MjT4;72JOr6 d7EMs6$ gI<0TT '?[nޚ9栛$ӥ~z}4:s˰WNܘRۘ'qR뚇3&о >(P=ljOK5E7 OIO-8t!v %a Mh߈E !pyL% ሔe,RI BfD3/$90!T 5HfJ~@%_;@V$J pX-L#0Ap`۟J@-BK#G]I cIا+#BC 8ՒT`H81x"-Y%иDV&jȑ6Q I#-P"& p" ,#"L1 \I U nK4b5y,gU /(f`!A8M^S@3E4ei@ ytFnn ,/lOA I4p 07!Lڌk2I@GA "Oj 碼AO :g 0rV x&`CP0%񯟬#1)f.ʁ8Xl>8( V֨3$rIVN c!@-*ֶf (x ~p[c'@(p "0 (Bp` |x 0O WJA^!B riV 8Z~JigKͭnw pKMr:ЍtKZͮvz xKMz|Kͯ~L(FL  An;s(+~ PG[kgРl4F0:#^A%eʓ(@+( 6@A_9L՞ Ag@ 7G f %22PeqΌl^MDsb,<;Es8$tЈJrkb X̪IE#^墿'gau^I&01@=!̘Q9:a캃ޑ2z$5aDSodZ-ƒb2Xt n40%},@]DA4M#จ*u1(GiPVwطA)8`Z\u?D HlEݰ=-}(jOhIJQge#ֽвQYK•=]W:Rg-uU!\}Vu>v완xKtY0uj;{!iFI.)S{Z} ;"vs!7=%SL {()IGxWwAK8ΘCKnvXג2j]vhb߯1p`=N`0v ٯٿ$h~IjAa. U@u7:Y;GT@tr,|$^>p,L41L$%}ߥPq dWM2l ^>0 "$`OhB^$hb#gBO+^:P2 XB# `:[gus4CK}&av3&V2#7^1M0&0yI) m@r(7O#8a `< $ ٪%=g ܢ+аaG(ۙwp-[@*TZɇ0 4 E,F ffBP;I< F@$q)|1G;s./40zA*QK&>(.9?CPm.p-QzJ-˘rZ7g:GN&iV) jz@k<8 #):ZГ!j'hA }B!v+!L 0qci,:2+fZ# fR G6gio/l7Ζ)R@ A#л3 PP+1Zܰ 28QA `XxUUwZPVU @sE!S1@Q4gKO`( `PCeH P<| CAp h8k[D̡e0e3<0mPϑ FcK @y0 opX Bdž|ȈȊ! , H*\ȰÇ#JHŋ3jȱǏ CIɓ(S\ɲ˗0cʜI͛8sɳϟ@ Jѣ$!drZB`'RX!-<,X^u6. E:gôIw,W>6˄-3kM)_eͨ9@U4\d6lThX3=k Bt8|pp^l ZCpǨBBK&dƎ ?_e3 Ql@O{YY@}}<-7dG%7&H_9/"Df쀓; 8"vqAIјΊ7H{0` cv0&3{HB.9 >AJJ\ ]@Z W!4Yf$L6dpxgITv:J0EQgc pGdѧs4S+,u# W EÁ~K7;љsPx z:-DȠ!GWQ"k[,1}F HԢfk*\qEj䰒; +)88N(F(f(T' YSԋ!F;pĺSFJ4XܯNX4 k*`/OoʙeEA3f ^(C;E ?<]E&QcIE=8-4D;"I)MjT4;72JOr6 d7EMs6$ gI<0TT '?[nޚ9栛$ӥ~z}4:s˰WNܘRۘ'qR뚇3&о >(P=ljOK5E7 OIO-8t!v %a Mh߈E !pyL% ሔe,RI BfD3/$90!T 5HfJ~@%_;@V$J pX-L#0Ap`۟J@-BK#G]I cIا+#BC 8ՒT`H81x"-Y%иDV&jȑ6Q I#-P"& p" ,#"L1 \I U nK4b5y,gU /(f`!A8M^S@3E4ei@ ytFnn ,/lOA I4p 07!Lڌk2I@GA "Oj 碼AO :g 0rV x&`CP0%񯟬#1)f.ʁ8Xl>8( V֨3$rIVN c!@-*ֶf (x ~p[c'@(p "0 (Bp` |x 0O WJA^!B riV 8Z~JigKͭnw pKMr:ЍtKZͮvz xKMz|Kͯ~L(FL  An;s(+~ PG[kgРl4F0:#^A%eʓ(@+( 6@A_9L՞ Ag@ 7G f %22PeqΌl^MDsb,<;Es8$tЈJrkb X̪IE#^墿'gau^I&01@=!̘Q9:a캃ޑ2z$5aDSodZ-ƒb2Xt n40%},@]DA4M#จ*u1(GiPVwطA)8`Z\u?D HlEݰ=-}(jOhIJQge#ֽвQYK•=]W:Rg-uU!\}Vu>v완xKtY0uj;{!iFI.)S{Z} ;"vs!7=%SL {()IGxWwAK8ΘCKnvXג2j]vhb߯1p`=N`0v ٯٿ$h~IjAa. U@u7:Y;GT@tr,|$^>p,L41L$%}ߥPq dWM2l ^>0 "$`OhB^$hb#gBO+^:P2 X(.om.>.QzJ-Q|6 &P.Ω.A/q[Z- lG1 ڙK.C֙ *Q)TK:BI2|}6q/ P/ }B!+hP (;+!87:ﴂ5;d@bvG>rio/l8Q 䛁 pa`4. ;>"8v !kLB/sLpFUw0כ/%j{X4W0(l/,}m?qpCh1A +` u0P %pMė0 |  ^@pK &U <0fU8V. pz g@ 0 0 a| >ɞɠʢ! , H*\ȰÇ#JHŋ3jȱǏ CIɓ(S\ɲ˗0cʜI͛8sɳϟ@ Jѣ$!drZB`'RX!-<,X^u6. E:gôIw,W>6˄-3kM)_eͨ9@U4\d6lThX3=k Bt8|pp^l ZCpǨBBK&dƎ ?_e3 Ql@O{YY@}}<-7dG%7&H_9/"Df쀓; 8"vqAIјΊ7H{0` cv0&3{HB.9 >AJJ\ ]@Z W!4Yf$L6dpxgITv:J0EQgc pGdѧs4S+,u# W EÁ~K7;љsPx z:-DȠ!GWQ"k[,1}F HԢfk*\qEj䰒; +)88N(F(f(T' YSԋ!F;pĺSFJ4XܯNX4 k*`/OoʙeEA3f ^(C;E ?<]E&QcIE=8-4D;"I)MjT4;72JOr6 d7EMs6$ gI<0TT '?[nޚ9栛$ӥ~z}4:s˰WNܘRۘ'qR뚇3&о >(P=ljOK5E7 OIO-8t!v %a Mh߈E !pyL% ሔe,RI BfD3/$90!T 5HfJ~@%_;@V$J pX-L#0Ap`۟J@-BK#G]I cIا+#BC 8ՒT`H81x"-Y%иDV&jȑ6Q I#-P"& p" ,#"L1 \I U nK4b5y,gU /(f`!A8M^S@3E4ei@ ytFnn ,/lOA I4p 07!Lڌk2I@GA "Oj 碼AO :g 0rV x&`CP0%񯟬#1)f.ʁ8Xl>8( V֨3$rIVN c!@-*ֶf (x ~p[c'@(p "0 (Bp` |x 0O WJA^!B riV 8Z~JigKͭnw pKMr:ЍtKZͮvz xKMz|Kͯ~L(FL  An;s(+~ PG[kgРl4F0:#^A%eʓ(@+( 6@A_9L՞ Ag@ 7G f %22PeqΌl^MDsb,<;Es8$tЈJrkb X̪IE#^墿'gau^I&01@=!̘Q9:a캃ޑ2z$5aDSodZ-ƒb2Xt n40%},@]DA4M#จ*u1(GiPVwطA)8`Z\u?D HlEݰ=-}(jOhIJQge#ֽвQYK•=]W:Rg-uU!\}Vu>v완xKtY0uj;{!iFI.)S{Z} ;"vs!7=%SL {()IGxWwAK8ΘCKnvXג2j]vhb߯1p`=N`0v ٯٿ$h~IjAa. U@u7:Y;GT@tr,|$^>p,L41L$%}ߥPq dWM2l ^>0 "$`OhB^$hb#gBO+^:P2 XCL` l( + [HQW`Xl'[(Q4J @(P!O[@UV=[ HZ "@p;8@@h 38"%Q/#A%z0 ;>84']x60@8 HP% ]9`h&i I_g#BQ0ioR =7q0hPiτ$i!xW}i41(!jh&0Rj2 $()'P P(aRk,q,h`'. H_l_)*.#.Ж)AuѰo,+InӦ0җ" ..`'a z!n z,Q*)2*{32E:')D`2@zT@2.*-;# uJ3gqI*AIo&ECA#y]PBC?(Q'`,#s43>'۳36u+KЛ%1o'~'j'St4AD9Mgo<4(ᨘ#;!v#w$z8`G#4*:V:3:8gf9Zj;r3M0<&Ug4J*4Aa }7p3pS'4L#ު% 'q6O3 3B4)!'4QsG@C`0yI) m@r( LA-h 2iioZ!3w:-*"vprB${ :|)Cѱ E,F fv!K#U@*DI7_|I.a A, ,/nRI|֠.m.>.QzJ/Qm &0.܉/A -lG1 R@kOP&4+F0!Xk'hA6r#% '*hp ,0r@+ N+( 82%Л$ fR G^m/l<`C*al 4 PP+1 !! vm/sL pFWUpG@ 1`U'U1%l{X4W0(-3lU }0 Pz5 LWp*@@t lP \œn`^iPv #= [<\Ȇ|ȈȊȌ! , H*\ȰÇ#JHŋ3jȱǏ CIɓ(S\ɲ˗0cʜI͛8sɳϟ@ Jѣ$!drZB`'RX!-<,X^u6. E:gôIw,W>6˄-3kM)_eͨ9@U4\d6lThX3=k Bt8|pp^l ZCpǨBBK&dƎ ?_e3 Ql@O{YY@}}<-7dG%7&H_9/"Df쀓; 8"vqAIјΊ7H{0` cv0&3{HB.9 >AJJ\ ]@Z W!4Yf$L6dpxgITv:J0EQgc pGdѧs4S+,u# W EÁ~K7;љsPx z:-DȠ!GWQ"k[,1}F HԢfk*\qEj䰒; +)88N(F(f(T' YSԋ!F;pĺSFJ4XܯNX4 k*`/OoʙeEA3f ^(C;E ?<]E&QcIE=8-4D;"I)MjT4;72JOr6 d7EMs6$ gI<0TT '?[nޚ9栛$ӥ~z}4:s˰WNܘRۘ'qR뚇3&о >(P=ljOK5E7 OIO-8t!v %a Mh߈E !pyL% ሔe,RI BfD3/$90!T 5HfJ~@%_;@V$J pX-L#0Ap`۟J@-BK#G]I cIا+#BC 8ՒT`H81x"-Y%иDV&jȑ6Q I#-P"& p" ,#"L1 \I U nK4b5y,gU /(f`!A8M^S@3E4ei@ ytFnn ,/lOA I4p 07!Lڌk2I@GA "Oj 碼AO :g 0rV x&`CP0%񯟬#1)f.ʁ8Xl>8( V֨3$rIVN c!@-*ֶf (x ~p[c'@(p "0 (Bp` |x 0O WJA^!B riV 8Z~JigKͭnw pKMr:ЍtKZͮvz xKMz|Kͯ~L(FL  v&bCȷ]7)Z,4< à ym+ p',`]$jd#  9ie֐2E&GN9ӆEluрHbs!ztyBh)qh 'o O48y GK (ru7}+-$p4BY n}| YһҹF췳h{ 2әjfp1LbϚHo 8$d}[ @;xq!)5D7v߰hO1Ucd-~Ka.3n~1&ƽ$p@7q,e-dA1(G ;xfFh5R2p/OhUmp+x8ԡ6-> y6lOKR~̉(FhMiਃm/G" E~+.pe)#؞pj2}gW?Jm+0֐g ׻'-&ظTR-=UkDu, [v "[>p,L 8+1jP M (1 86"[0v2P` A&6rU pGǥ@z*%\[\$c` \n6"c 0WEd/z\ =vU1q$][@7@m5V L\@ *PmV^phEe|` C{\+C | [؊8Xx؋8XxȘʸ،8Xxؘڸ؍ #0p [0(k;Z%Y[@ob0 ~p/D>CL` l( +pFEXVC([(IOQ4U @(@mP]UL[ Hd "<8@Dh 7@zPT-xRo J=:PR[9 ԧ$CRw60< {$ 1 %8p6A 7hh5q&dQ0IvRz6q00i-h0jD}!~W@i4NBM"( 6j2q,;̤c(TZJ,|h0)0~ȢII _.L+~ Io$I-pR4bG .z - ao,z#~mȷm+ ..'a}cEm z,Q*)2@}CD2ɧE.z)y3J A'!ٲ3W'1s44}DZEt$0-INA%3\PBC?(? @+fw#4;3>'>B>`Si@vXp4Sz4C% Ng 4<4,|` {;!3h:u:%:Bѩ:&:4'$A;ccTC Kj;r#M0<*)aW4J*4zAP DG#4V QxBXc+ }b pZӥ^Sy'uO5'q444)(.}r,m.>.z)FHk)%)G:Rk-չKa+))+q@ l> + Dk%%"!Gp(q/ 0d21H(ERfs zY:&-J% pc N2( 82 8H!"g@fq:Q 6o Y8"5 PP+1I`S ܑ,@c LpFU<!Nt4%ly迡K0(!\V5cu0ėXP T`0 t@T nC< Q uzZWPg`P? [d hjlnp ! , H*\ȰÇ#JHŋ3jȱǏ CIɓ(S\ɲ˗0cʜI͛8sɳϟ@ Jѣ$!drZB`'RX!-<,X^u6. E:gôIw,W>6˄-3kM)_eͨ9@U4\d6lThX3=k Bt8|pp^l ZCpǨBBK&dƎ ?_e3 Ql@O{YY@}}<-7dG%7&H_9/"Df쀓; 8"vqAIјΊ7H{0` cv0&3{HB.9 >AJJ\ ]@Z W!4Yf$L6dpxgITv:J0EQgc pGdѧs4S+,u# W EÁ~K7;љsPx z:-DȠ!GWQ"k[,1}F HԢfk*\qEj䰒; +)88N(F(f(T' YSԋ!F;pĺSFJ4XܯNX4 k*`/OoʙeEA3f ^(C;E ?<]E&QcIE=8-4D;"I)MjT4;72JOr6 d7EMs6$ gI<0TT '?[nޚ9栛$ӥ~z}4:s˰WNܘRۘ'qR뚇3&о >(P=ljOK5E7 OIO-8t!v %a Mh߈E !pyL% ሔe,RI BfD3/$90!T 5HfJ~@%_;@V$J pX-L#0Ap`۟J@-BK#G]I cIا+#BC 8ՒT`H81x"-Y%иDV&jȑ6Q I#-P"& p" ,#"L1 \I U nK4b5y,gU /(f`!A8M^S@3E4ei@ ytFnn ,/lOA I4p 07!Lڌk2I@GA "Oj 碼AO :g 0rV x&`CP0%񯟬#1)f.ʁ8Xl>8( V֨3$rIVN c!@-*ֶf (x ~p[c'@(p "0 (Bp` |x 0O WJA^!B riV 8Z~JigKͭnw pKMr:ЍtKZͮvz xKMz|Kͯ~L(FL  A}04.PDhk- AE:a@n} Z<وD@zƦ$cۂ)gڰ~ HBd@BZGLxQFLCU3vAf|(]hWG7Ymt\hH L zɞ5I̙1Ѱjt}kg}:q/#e"i 2d߰+dg#k=/6q-b2KpK]7&]mc4[OkYl (+r0I&4i妑fFt-!kP`Z7-bX..ҤiOrWQg&a{Z[#k  6"vOfr9eͅu&Kr1Y5B1Ξ\a.q2G:=ꙺMX vg%ТG=M%5>\+r{׏&B?L+iI.&+;SG&lrˍ^u-erd);A,;ǔa7aGȋcCt~Lϭ}@_n+jAԂJϓg /NRIDԹ'k4KW('u_Ǔ=Lz@BP`M *(>0D#]"X$hb#g]NX:P2 Xޅ((% DT M]6"oTA7a!eG.DpVcplU `U9p^X`("[$P9  0O @sXw{o`؈8Xx؉8Xx؊8Xx_ 0# Wm( vZUY%[:D(P0 ~p/D>CL` l( +@GEXV,([(AOQ4;( @(PP!]ULD[ HK "Б;8@"h ?T@E/#%z0 $qR*qic6 BK2PQ(QY` h dg GW`OhO&&p sgF H# "a &)w5u'L)$Rs2Q +&KL +R(R(V,_G/ktIgQ @_.L+| I 0R(i@N(EHI#v~ lʲddFll+ ..}(Q|cE2z,Q*)2@$D ZD2E IT02.*-;# uG3(jpI*@7D'$pq;E) U 4@T\xrr'1H3z=V#46J:+0wXp4Sl45-9@g 4<4(\t{;ȹ"3Zw8XGoPxCq$:Bӧ񧨳9wj':cTo 2j;rsigtl"7OQB(&`s$!w;cPv'4L ~%ArOc pZ#^S &x'!'4QsGAKSxI) mq2/[Pa{*!`l'22$$sݷ3ܢ+ RE {)13 :o E,qp]2o`$q|!E{mq.AA,+!I_)I)6*F .i)l%),ߠ1!(f(fiV@&Q;$q@@}(@(WE]"2pzmt 2H(ERP}B!x ,""T7;"hPJbb+'e08qc /k1I`J Q+ /sLpF ߧpGADAP VU E!S1qmPS0{9@@t ܋P nua pvP E@ >ý8D\F|HJK! , H*\ȰÇ#JHŋ3jȱǏ CIɓ(S\ɲ˗0cʜI͛8sɳϟ@ Jѣ$!drZB`'RX!-<,X^u6. E:gôIw,W>6˄-3kM)_eͨ9@U4\d6lThX3=k Bt8|pp^l ZCpǨBBK&dƎ ?_e3 Ql@O{YY@}}<-7dG%7&H_9/"Df쀓; 8"vqAIјΊ7H{0` cv0&3{HB.9 >AJJ\ ]@Z W!4Yf$L6dpxgITv:J0EQgc pGdѧs4S+,u# W EÁ~K7;љsPx z:-DȠ!GWQ"k[,1}F HԢfk*\qEj䰒; +)88N(F(f(T' YSԋ!F;pĺSFJ4XܯNX4 k*`/OoʙeEA3f ^(C;E ?<]E&QcIE=8-4D;"I)MjT4;72JOr6 d7EMs6$ gI<0TT '?[nޚ9栛$ӥ~z}4:s˰WNܘRۘ'qR뚇3&о >(P=ljOK5E7 OIO-8t!v %a Mh߈E !pyL% ሔe,RI BfD3/$90!T 5HfJ~@%_;@V$J pX-L#0Ap`۟J@-BK#G]I cIا+#BC 8ՒT`H81x"-Y%иDV&jȑ6Q I#-P"& p" ,#"L1 \I U nK4b5y,gU /(f`!A8M^S@3E4ei@ ytFnn ,/lOA I4p 07!Lڌk2I@GA "Oj 碼AO :g 0rV x&`CP0%񯟬#1)f.ʁ8Xl>8( V֨3$rIVN c!@-*ֶf (x ~p[c'@(p "0 (Bp` |x 0O WJA^!B riV 8Z~JigKͭnw pKMr:ЍtKZͮvz xKMz|Kͯ~L(FL  &bCe7)Z,4< à ym+ pO,?$jd#  9ie֐2&GN9ӆEl5р!pȂQI $Q@V 4/8eAM2ቆGz9Bo`bA*\FSvksG*K4+P:ĥ(d&DzW `KjfK1LBߍ-v˻T)q/<$e􂼆(0l\x?P=$d%o)$ DpVcplU `@V`BAe|` C{t_X0 `8Xx؊8Xx؋8XxȘʸ،^ 0# W0ֈ ZUY%[:D/l5/D>CL` 8l( +GEXV;([(IOQ4M @(bP]UL[ HZ "<8@6h )@zPT-xR;rQ J=:PRMP;@(.},.Љ-z肜)FH`K2̩1!(F/G1 S˚b&fK#q@l1+ 7[%x!!'{ 0d3*H(ER0 h}lٟ:&-J% `lc N+( 8Б3 |I!!W@eq`:Q@Vo m6"5 PP+1I`S ,`Y LpF0U4!Nt4%lnH;0(\rf5cu0ėXP T`0 t@`T np9< Q hzZWPg`P@ [Z<^`b`'RX!-<,X^u6. E:gôIw,W>6˄-3kM)_eͨ9@U4\d6lThX3=k Bt8|pp^l ZCpǨBBK&dƎ ?_e3 Ql@O{YY@}}<-7dG%7&H_9/"Df쀓; 8"vqAIјΊ7H{0` cv0&3{HB.9 >AJJ\ ]@Z W!4Yf$L6dpxgITv:J0EQgc pGdѧs4S+,u# W EÁ~K7;љsPx z:-DȠ!GWQ"k[,1}F HԢfk*\qEj䰒; +)88N(F(f(T' YSԋ!F;pĺSFJ4XܯNX4 k*`/OoʙeEA3f ^(C;E ?<]E&QcIE=8-4D;"I)MT4;72JOr6 dADMs6$ gI<0rRT '?PBgy~r{}`I.?Mrx(y'%yL9 H(i:o rM}kB[MDM Y4q &XPP$N0&-pW4T6 Lh1MZZ$$P5wtk0%y8o-K S8cB%v 'Lcrr6cǚLR(;cCȓH5/|Г3H2L>^@f XpLi'@ vr0i0p& p'UD5 !ɟ@S%q+AjQ5^@#L_`[<2Gy#hQխ.@2`S `!|"W  ,"R8oVDPPKͭnw pKMr:ЍtKZͮvz xKMz|Kͯ~L/@1mdM ̐\aM Gp( "` uKp   `ض6 ` dWH⪖)O6b_ N3u LPr ׁ 3^DSIcBM*⣒LxQF-ϙZ|Mi HeIJB`p@iV.} Lia#w0@ =9k s{})ge9@"z ;*T4^ {H|W7,ʃ+1g/Zyxl_L[{=kia&Ä cw] E AfE/3/L"ͨ6j>,io-ŋ8a-X(cx"M_Ы S4zS=w= tN-@BIiVjỊKtG &< XpO7\KErg4MA# zqF)YGR4^ V*08z_B$} o$ (PYip84 ':P2 XQz+` qgځ{S:E#Ё` ^dzR`[mdqLy[@7@n5V ;(`4pnuVm 3Ae|` C{{x` oo}bx؊8Xx؋8XxȘʸ،8Xxؘ 0# Wl xZeY5[:D(P0 ~p1D>CL` ȏl( + GUXVB([(Q4R @(P! P!_VN 0 `*e <\Fh 3A5T+bTH1lzmvRX9 e&ERg60@( .$c 0&e"QYp i bWOhO&d'p sgPov2P j4A r04 ( !wWPjTNbM"Lp2Ajj2q,;PjR 'axJ%_G1h,bHzI_.0L+}ŷHC~".0TD4G .z -`WF~wږ|ݶgi70X))2+#D0FEAP+V1U`[0($gmUqmPSЪ6@@t \P Fēnua pvP E@  npr`'RX!-<,X^u6. E:gôIw,W>6˄-3kM)_eͨ9@U4\d6lThX3=k Bt8|pp^l ZCpǨBBK&dƎ ?_e3 Ql@O{YY@}}<-7dG%7&H_9/"Df쀓; 8"vqAIјΊ7H{0` cv0&3{HB.9 >AJJ\ ]@Z W!4Yf$L6dpxgITv:J0EQgc pGdѧs4S+,u# W EÁ~K7;љsPx z:-DȠ!GWQ"k[,1}F HԢfk*\qEj䰒; +)88N(F(f(T' YSԋ!F;pĺSFJ4XܯR4 k*`/OoʙeEA3f ^(C;3E ?<]E&QcNE=8-4D;"I)MsyN4;72JOr6 dEEMs6$ gIMk7$;{G!,zA #FC4wfr˺h QѐX!po @>"4!2h/e{8/!÷7IC필$E<1e'|=g"#y!l\C' SD~߯ʇ$"~ݞFWB;JR '/2^|!" L`YH!ft_,B"j%RJP~*Mi&>0 m_y $hb#_|h{*L_> 6PQ `c#'`%C` ^zdp9ׁTjҗ[@7@h5qV  ``VgqV7se|` C{uh%0 9PGXx؊8Xx؋8XxȘʸ،8Xx #0p5&[(pmFAZZZ@oUlcx>CL` l( +F}WuV9[(dP4I( @(PO#YUd@[ GZ "p98@<h /;S+TB+#!Ez0 i e?UR3hh~06 BK2P+P(QY~ 4A 04q&dQloR=7qG(Pi4A r4 ( !}W}iMM"L01Q +&KK +l(h(ęZJ ,xmHzqI|p0~5݇.PH?P&.ЅN$4"G .z -70j*FbӦ0ei70w))2o,=CAt< p71*Aq#I-Ԣ0GT2.*-;AD£4}G 4-I@fr;E) 5ss@>S\xr?(AwO>'>B>qsBIvr&88wO3t(Qn*)Mw*!ţ!4ZJ3$s:`-*0 +Q -C@*Sʠ̗33o9u;0Le0zpf(a.).! *PqR([tG/40zA,+7;,6[I_)mPٟ*F '~'. )%)'(;4-~RG1 V+)e++q@k3((X2+\22P{R*q/ n2*H(ER0}B!i 1X#"' oc N+( 84aB!r@c&%0m}ɼ/l1I`Qо+/sLpFU!Jt4%pgo[0(`d)3gUqmPS |9@@t P 6Ón^qa pvP E@  ^`b`'RX!-<,X^u6. E:gôIw,W>6˄-3kM)_eͨ9@U4\d6lThX3=k Bt8|pp^l ZCpǨBBK&dƎ ?_e3 Ql@O{YY@}}<-7dG%7&H_9/"Df쀓; 8"vqAIјΊ7H{0` cv0&3{HB.9 >AJJ\ ]@Z W!4Yf$L6dpxgITv:J0EQgc pGdѧs4S+,u# W EÁ~K7;љsPx z:-DȠ!GWQ"k[,1}F HԢfk*\qEj䰒; +)88N(F(f(T' YSԋ!F;pĺSFJ4XܯR4 k*`/OoʙeEA3f ^(C;3E ?<]E&QcNE=8-4D;"I)MsyN4;72JOr6 dEEMs6$ gIMktνzNuӡYXӎQνкЃ-ܫ?#a!{Z݋? ]]4 O+%}H0$¤5!gP^ycgʠFCc /);A,䋫5!#y1UF.8C߯i7A퉆AԂEK gH)uW(D+f_+ .7>p,ȤLL`!P|M ( >0D#_F& 2y ArhYp6 g{zg%`0PK 6&`B 4E#!A;!B&_@mdqL %c[@7@h5qV m6+WvVPg$P9  0O |hB o  }0؊8Xx؋8XxȘʸ،8Xxؘڸ_ 0# W`~ veZYZ9D(P0 ~c7 IBH0(mhWXg5XA2A`dQ4O @(PO8UZ# 0  p) } :e] _DSKA+#ڶiF#5  ŃCSRX 70@X HPP(Qh 5A `=mHW OhBO&d'p sgjv2Pi4A r0; ( !{WidhNsb (0Rj2q,;o~c(aJ ,h`)0~bII _.`*}VH?$. *A}"G .z -70,+FbE}ND;"E2z,Q*2)C?D2`E,%98Tz42.*-;A$#4}4(j$s?@A"3XPB?(wV3,#)>'v;>asBYq4sssw4B% wtǃ S k;c,('ٰ'qru;us:%:K;tnu%:K$n'A#Tv Ij<&6jcC}*4wzA@o CW*4p=:+L#̊& '5[5*)D`xB%q4 4gyI) g$ww8S R<*2I%A4C2:"ܢ+а##p>d{ ,|)ڗSD,QF f撦f0]R( [tGעᙲ/zA,+;:[I_)C)R*F .i)k Ω%)G֝-[-yȠ21'! 0+(={%%#!(A6'hCS$% 'ҹ*hж p,""T::;x&P3:br& f6mQaj ޻"`4 P8:!e G2j4pFpUM!Jt4%gXD!S1pqmPS0|9@@t kP 8Ón0ua pvP E@ y`b`'RX!-<,X^u6. E:gôIw,W>6˄-3kM)_eͨ9@U4\d6lThX3=k Bt8|pp^l ZCpǨBBK&dƎ ?_e3 Ql@O{YY@}}<-7dG%7&H_9/"Df쀓; 8"vqAIјΊ7H{0` cv0&3{HB.9 >AJJ\ ]@Z W!4Yf$L6dpxgITv:J0EQgc pGdѧs4S+,u# W EÁ~K7;љsPx z:-DȠ!GWQ"k[,1}F HԢfk*\qEj䰒; +)88N(F(f(T' YSԋ!F;pĺSFJ4XܯR4 k*`/OoʙeEA3f ^(C;3E ?<]E&QcNE=8-4D;"I)MsyN4;72JOr6 dEEMs6$ gIMkCL` l( +F}WuV@([(Q4P @(POdDU[T[ G_ "p:8@<h l;S+TB+#!Ez0  ŃCSRg70@x HiP(QY H ~ P25q&dQHvR=7q0hPi4A r@; ( }WiMM"L`3Q7+&KK L Ж,a,./a ,x~"4I'QWIZo/% ~I 03y@0ԂbDb"rp|ʲ`{m 3m, ..P&}#E2z,Q*2)C?D2 #)-p0tTН2.*-;A4}4(j$qg$0r1E( 5ss@>S\yr?(1H3s03Csb:+K`&17+scswo)A%Pts4S k;}*)(*)ٰ'Awu;j:UWoPy{:%:J;\ JqZv&1'ِ&Atq;(ꀩ&6j4wJygXAPv BW*4p='Lj%& '5B5@poB4)!~>G@KSyI) m@r2/;aB ţ!4ZK3$s: -* 0[# -W@*YЗ3cy9u ;0Le0z@f(a.).Q**"Ewt-$nXrmò>೮(.?,.>.z-ka2Ȃa(/p=G1 ]+)26+q@k; + @%%#!' d3/H(ER}B!p!u(R{-J% 5P:3*}b+'Cq0m})%aCP0*+a@+. K>վ1I`O ܑ+/sLpPgs!Jt4%pgX[0(,}GQ! lXu0ŗXP T`0 t@H nA< Q r*ZWPg`P@ [b|f|hjln! , H*\ȰÇ#JHŋ3jȱǏ CIɓ(S\ɲ˗0cʜI͛8sɳϟ@ Jѣ$!drZB`'RX!-<,X^u6. E:gôIw,W>6˄-3kM)_eͨ9@U4\d6lThX3=k Bt8|pp^l ZCpǨBBK&dƎ ?_e3 Ql@O{YY@}}<-7dG%7&H_9/"Df쀓; 8"vqAIјΊ7H{0` cv0&3{HB.9 >AJJ\ ]@Z W!4Yf$L6dpxgITv:J0EQgc pGdѧs4S+,u# W EÁ~K7;љsPx z:-DȠ!GWQ"k[,1}F HԢfk*\qEj䰒; +)88N(F(f(T' YSԋ!F;pĺSFJ4XܯNX4 k*`/OoʙeEA3f ^(C;E ?<]E&QcIE=8-4D;"I)MT4;72JOr6 dADMs6$ gI<0rRT '?PBgy~r{}`I.?Mrx(y'%yL9 H(i:o rM}kB[MDM Y4q &XPP$N0&-pW4T6 Lh1MZZ$$P5wtk0%y8o-K S8cB%v 'Lcrr6cǚLR(;cCȓH5/|Г3H2L>^@f XpLi'@ vr0i0p& p'UD5 !ɟ@S%q+AjQ5^@#L_`[<2Gy#hQխ.@2`S `!|"W  ,"R8oVDPPKͭnw pKMr:ЍtKZͮvz xKMz|Kͯ~Lv@m,M + 41U> OALV?,!   l0l[!<0W@6<و2@(V<"E1DӌzQ/(@Ar&:P 3vI*F֣ϊ3zص] p=eJ0bm]3EsdW$"]R@KpEEM5w L DLK/kNJwI#K!E΍̀>ePVu1))Uʌps⣇ */pѺZ\RRiG u5oa&Gy]w~â*jv|PTNT {okEkl61/}RKJm5aTj]K4 4s#46kJ-K%1fj'~<ӧo X3,p2 QW% h E`'RX!-<,X^u6. E:gôIw,W>6˄-3kM)_eͨ9@U4\d6lThX3=k Bt8|pp^l ZCpǨBBK&dƎ ?_e3 Ql@O{YY@}}<-7dG%7&H_9/"Df쀓; 8"vqAIјΊ7H{0` cv0&3{HB.9 >AJJ\ ]@Z W!4Yf$L6dpxgITv:J0EQgc pGdѧs4S+,u# W EÁ~K7;љsPx z:-DȠ!GWQ"k[,1}F HԢfk*\qEj䰒; +)88N(F(f(T' YSԋ!F;pĺSFJ4XܯNX4 k*`/OoʙeEA3f ^(C;E ?<]E&QcIE=8-4D;"I)MT4;72JOr6 dADMs6$ gI<0rRT '?PBgy~r{}`I.?Mrx(y'%yL9 H(i:o rM}kB[MDM Y4q &XPP$N0&-pW4T6 Lh1MZZ$$P5wtk0%y8o-K S8cB%v 'Lcrr6cǚLR(;cCȓH5/|Г3H2L>^@f XpLi'@ vr0i0p& p'UD5 !ɟ@S%q+AjQ5^@#L_`[<2Gy#hQխ.@2`S `!|"W  ,"R8oVDPPKͭnw pKMr:ЍtKZͮvz xKMz|Kͯ~LN0A0hcb]w\AM )0MLXn@í~ @U9`ض6 Cx`0 m0MJ+T8aeP>3*xD84hć PfΔ[gG*D& :xq+aQB[. :4)}M. DSB*\$N[, 2A'Ap%qhҿ f Dμw.J L o-ࣳD[C`$pi4{"n&=2xڑF.B#nm[Dٸ cnP0+jͥBqp_)Rm  #mY ؇ڐ ]@R}zUH]/j |A)ngQr҈ $bRaG2  uLQ&"@P{bm=XZbN{^iF{VO֩g3@3Fg7j`=W"4@0`riKXQE`-A?jW7cE… Lz8hK+>;3L1j[h *x7U33Gh[bl 3jyl-a񶸢nJ ծ-$W j^o x-=Fo&[ ؾFPm=p`߹)ܧ!Y@&IG ǂ,pj[@DKL`+%(M (0 \8%\P} \- 7he !&ߐ\ Fc6\0P0LpP٦hPn%S41 9\p1`4UpUuU\7 n5V~l@XVm ]-   0Xz <0 h)# 8Xx؋8XxȘʸ،8Xxؘڸ؍8XB #0pH[(r^ZZ[@oU8vX>CL` yl( +ЏGUXV5[L&dUU@ cBDU. +p>UaT[ 1Hn "P:8eYƓ9TCu{zpT-xSh `;:f4Q`;'>B?`ԲYh=&5|,PB&xSs <4(qsS4;:sow:&!:#;ӨȢ:zZ: ;'3%1*%ـ&Aw3h173KB%7O#8OC(:$$aOZ4 '5B5_C B4)!'4QsG#AKS`)y y'13Pa!ـ< c~ƪ'p4C2F!ܢpO( - @*gn/0S6E+F 60n'F0 !nw-Vo,qnA,+J<ڞ_)J)] *F gk΢1l)%)A(t Q-[-ȠiH1!'!L +)) N{%%#!A0)q/ `>4A( e$$K!}  (b-J% Ѓ;h?J"%P 0Ge$aov+. k>Z8"dI`W 1+d/sLihlwXNd4%@mX;0(6',}GQ! lpXu0XP T`0 t@H npI< Q YWPg`P@A [jlnpr`'RX!-<,X^u6. E:gôIw,W>6˄-3kM)_eͨ9@U4\d6lThX3=k Bt8|pp^l ZCpǨBBK&dƎ ?_e3 Ql@O{YY@}}<-7dG%7&H_9/"Df쀓; 8"vqAIјΊ7H{0` cv0&3{HB.9 >AJJ\ ]@Z W!4Yf$L6dpxgITv:J0EQgc pGdѧs4S+,u# W EÁ~K7;љsPx z:-DȠ!GWQ"k[,1}F HԢfk*\qEj䰒; +)88N(F(f(T' YSԋ!F;pĺSFJ4XܯNX4 k*`/OoʙeEA3f ^(C;E ?<]E&QcIE=8-4D;"I)MT4;72JOr6 dADMs6$ gI<0rRT '?PBgy~r{}`I.?Mrx(y'%yL9 H(i:o rM}kB[MDM Y4q &XPP$N0&-pW4T6 Lh1MZZ$$P5wtk0%y8o-K S8cB%v 'Lcrr6cǚLR(;cCȓH5/|Г3H2L>^@f XpLi'@ vr0i0p& p'UD5 !ɟ@S%q+AjQ5^@#L_`[<2Gy#hQխ.@2`S `!|"W  ,"R8oVDPPKͭnw pKMr:ЍtKZͮvz xKMz|Kͯ~LNpFH &"(v\w\ EF}3ֶ#0  F!u F^dCjv* d3w@Gh[P2Ltx0$E[ tHC{GK@7K MJ_i QPN4%t* Xp(sX A,HJ`V@u=)]}#DuQAB Yn¥ 2 7 ]cP#} #=' F7 s(7UH~z`Zrg1p7QP(K,ag1`q=p"&' ^ʾjVij/I%P$4EW7!F@W\p2?ӌH8n4~8>$ EzpD]W0N;wWU6}0=eqLmfp#}QM4pvTp8פ&pW;u -s. )kk -4nɅƄfD\\'."1 o7̀ Vf.!No\@<[aߘs#Y ZO:]NM˹{DW;:(*\'sE ҧ+",~$]`rQz@J?E!.7K4 "]PD$`](
h`v W]ih" #"?W]&i P0L :"Yf ZB :E# T@WpUuU>'^PXnVU DЅ@XVk "| O @Zp <0  `)# 8Xx؋8XxȘʸ،8Xxؘڸ؍8A #0p[p(0s^ZZ[@oU7vh>CL` Yl( +G凇U5[KfC U@ 0r@DAG_Vl[ 1H@S";8frh 9A5T˦G1#oF#5 &rıRfH80@v  H8$Q(Q>3A &4q&dQPhR@=7qg'c` 4$pN#a )B 6i'.03!M$$К /80"`hFi p,a1J&_p,Dj.h~)0Ii @o0%ǜ)n I 06i@.xѐk#ʲfF/$ I-GEl$X),Q*2)1DEtD@3pob$r){ 36tT 42.*-;p $;¤w4 }!44j:'A4$p E(ayBc?(? w.E4s#46oJ-K&1&~<h X3,2 %d%`a E2_jC)Zɠ*an&q-q*i)B-ȖZAN2-m / l)q@HJ>)%%#!ւ0,q/ `>4A( e$$ P0@!~+!1h'-$ Jhk:h.9$P 0G9$0~ w. k>1I`;Q+d/sLpF@Yu!Pt4% m+0()+<}GQ! lpXu0XP T`0 t@H nM< Q ~ZWPg`P@A _\nLr`'RX!-<,X^u6. E:gôIw,W>6˄-3kM)_eͨ9@U4\d6lThX3=k Bt8|pp^l ZCpǨBBK&dƎ ?_e3 Ql@O{YY@}}<-7dG%7&H_9/"Df쀓; 8"vqAIјΊ7H{0` cv0&3{HB.9 >AJJ\ ]@Z W!4Yf$L6dpxgITv:J0EQgc pGdѧs4S+,u# W EÁ~K7;љsPx z:-DȠ!GWQ"k[,1}F HԢfk*\qEj䰒; +)88N(F(f(T' YSԋ!F;pĺSFJ4XܯNX4 k*`/OoʙeEA3f ^(C;E ?<]E&QcIE=8-4D;"I)MT4;72JOr6 dADMs6$ gI<0rRT '?PBgy~r{}`I.?Mrx(y'%yL9 H(i:o rM}kB[MDM Y4q &XPP$N0&-pW4T6 Lh1MZZ$$P5wtk0%y8o-K S8cB%v 'Lcrr6cǚLR(;cCȓH5/|Г3H2L>^@f XpLi'@ vr0i0p& p'UD5 !ɟ@S%q+AjQ5^@#L_`[<2Gy#hQխ.@2`S `!|"W  ,"R8oVDPPKͭnw pKMr:ЍtKZͮvz xKMz|Kͯ~LNIH &"([w\ EF}׶C 0͂  F!A x(X}N6V@A,xylAFA PfNO<8XO0p!A->!l`-} S :4)}[&h9C9єЁ 2JdA9ۅe I%`0+X B숔t¨f!I ,Q~7IR` `zEw1t@&xڑF"ޑVD*l4y%Z]T!ez؀Ki!ٝaD 8B.eڰ 8ct/e@tŵvKd+98tU"CϦ%4j0Xf@BupA%I)Ѓ.@jTr6߱)#թgJ]O>.)̠G`1]ab.07,C}"9+Z`')D TN TB ? UyB& NIɹvvG7;8K]Db\4OUBح"\R{߸8tPɁ+ƧFf =$ka>⁻u*FwXx3 d]JZB,~^`z,2 j&+ ] ()rZ3L`+E/J6 (0 $@^(qv 7^pih z:a !&ߐ7^q; pP0L :"Q?!S41 5^pN .tE_@WuQQps VnU@Zh|xn 1T_o `|[Z ()# ؊8Xx؋8XxȘʸ،8Xxؘڸ؍B #0p[0(r^ZZ[@oU7vx>CL` l( +pGU5[JfQU@ o\F(dUP +0=Ua0 z* 4%<_&!B@8TClzpT-xS;Q n<:f4Q`;4s#46mJ-K&1h&~p2_jC)U*F"l0q*;i)VR2Jir1FyVnK}&#q@HE>)%%#!f0,q/ `>4A( e$$ P0A! +h'-"$ Fhj:h::%P 0G;Q0C / QP0k*aPgV6 pPV8<ֱS K280? `/Ut8f>EAP-VP;0(*, }GQ! lpXu0XP T`0 t@H nO< Q |ZWPg`P@A [p t\v|xz|! , H*\ȰÇ#JHŋ3jȱǏ CIɓ(S\ɲ˗0cʜI͛8sɳϟ@ Jѣ$!drZB`'RX!-<,X^u6. E:gôIw,W>6˄-3kM)_eͨ9@U4\d6lThX3=k Bt8|pp^l ZCpǨBBK&dƎ ?_e3 Ql@O{YY@}}<-7dG%7&H_9/"Df쀓; 8"vqAIјΊ7H{0` cv0&3{HB.9 >AJJ\ ]@Z W!4Yf$L6dpxgITv:J0EQgc pGdѧs4S+,u# W EÁ~K7;љsPx z:-DȠ!GWQ"k[,1}F HԢfk*\qEj䰒; +)88N(F(f(T' YSԋ!F;pĺSFJ4XܯNX4 k*`/OoʙeEA3f ^(C;E ?<]E&QcIE=8-4D;"I)MT4;72JOr6 dADMs6$ gI<0rRT '?PBgy~r{}`I.?Mrx(y'%yL9 H(i:o rM}kB[MDM Y4q &XPP$N0&-pW4T6 Lh1MZZ$$P5wtk0%y8o-K S8cB%v 'Lcrr6cǚLR(;cCȓH5/|Г3H2L>^@f XpLi'@ vr0i0p& p'UD5 !ɟ@S%q+AjQ5^@#L_`[<2Gy#hQխ.@2`S `!|"W  ,"R8oVDPPKͭnw pKMr:ЍtKZͮvz xKMz|Kͯ~LN0L0Cw\AM )0ML5WpPPk@'j`+  F!u h"^0MJ+Ä ' tFȣ}? (c2P2Lt,_ XgY)Gk :xL|ݱz9;FMHIh{!O;Ѭ.C9єЁ *~$T" w thSRSR  X!"M \ԕFu^w`Y`JZGg #H +$EoX(ACGԎ4LȁᭁdbK/d/i%9@bTHIk /S>mY &p~"Y@a) Di{XlL]nlL!$$(.soL3 ?Hӈ$Ywa$g +?. >02L脑r" K ?-j u^ sFHYޝY"=SwMNv!$m$H"]ABή0i;?0p`$PPaZ7H1 9/oY Z /D_cE}vxlgD*w?p}(񀳩u,u'IG'R+ ^ $˒K4 -^`bH(0 ^`jv2%P} ^;2 e"h | 7B0P0L`&RdB :E#P"`N _@WuQQPsOVVq5$nVk "CC  b YF <0 }0`؊8Xx؋8XxȘʸ،8Xxؘڸ؍ 0# W@h WWZZ5[:D$7v>CL` l( +GUXV5[K&dUU@ 0~AD{% . +=Ua0 |( 4%>`&!D`8TCuG1#§nF#5 $&ezCRbH740@f H %Q(Q& r5A :h4q&dQooR@=7q,؂P 6A rNC 4&`@}9$KbM" j2Q +R0q,;𛭦sR(D-_p,G/hbHIY Ro0%R݆.߰HV?p%.`!,QEFf /z -~metFgm~ж}T;EA!z,Q*2)1DEtD@30ob*92c`CAI#ٲ3X'BHwP#Rp*/ A2WD4$p E( U 4@ G\@?t'sOS>'>B?`æԲYz=~'5x,PB&Qj'Ps)<4(&ٰ#+;:so0w:&!:#;Ȣ:ک-Ǫ%37z:; gC`a W; sMP<Ī%Z4S4AR J#4pS4L`%޺$ '5d5EUvG#AKS`)y 4 2/Pa*!ـ< C j!3$s:0+o) ;"xC -@D)@+c꣼/ 0S7E+FgS)a.).ŧn,{ෟ+|y-ֆd&A,+8*~b J-o.`n-q%Ki)B-Fa2Qi2e(/g1'! p*Я(vE]"24&j6q/ `>4A( e$$ 88&,h ,"%"TC:]Vh% 83 I!H@vg0G;Q0K / Q V"@4 pP8<ֱS L28f@ `0Uth=EAP.V푈P+0($&\qmPS8@@t P Hēnua pvP E@ 5pr`'RX!-<,X^u6. E:gôIw,W>6˄-3kM)_eͨ9@U4\d6lThX3=k Bt8|pp^l ZCpǨBBK&dƎ ?_e3 Ql@O{YY@}}<-7dG%7&H_9/"Df쀓; 8"vqAIјΊ7H{0` cv0&3{HB.9 >AJJ\ ]@Z W!4Yf$L6dpxgITv:J0EQgc pGdѧs4S+,u# W EÁ~K7;љsPx z:-DȠ!GWQ"k[,1}F HԢfk*\qEj䰒; +)88N(F(f(T' YSԋ!F;pĺSFJ4XܯNX4 k*`/OoʙeEA3f ^(C;E ?<]E&QcIE=8-4D;"I)MT4;72JOr6 dADMs6$ gI<0rRT '?PBgy~r{}`I.?Mrx(y'%yL9 H(i:o rM}kB[MDM Y4q &XPP$N0&-pW4T6 Lh1MZZ$$P5wtk0%y8o-K S8cB%v 'Lcrr6cǚLR(;cCȓH5/|Г3H2L>^@f XpLi'@ vr0i0p& p'UD5 !ɟ@S%q+AjQ5^@#L_`[<2Gy#hQխ.@2`S `!|"W  ,"R8oVDPPKͭnw pKMr:ЍtKZͮvz xKMz|Kͯ~LNpM^0hClEw\AM )0MLsXA0PP?íX \9`ض6 Cx`0eRZ&b9aP>3*xDL\GBЃZP2Lt|_zf(b :x}4 0!A&op}@Ԏ |%p(':oFJyQ'Ё|L#r6I%`0+X t¨ڛ$g$-ࣳD^M:K ;L#HMHd^b #*zK?;(ϛJ(wh`=0-d⼱HXbɎaG(λƔ#hbk}hBi5 h/IVVБL]0j^g< L3 aȸ$Yx[E 9X0ƈ>^sOFs]`)F'ueDTO,at2>+cǑ"=SyC(!p~[k#"Qj`7B#n 5"3L1̋9#ޙPa^`MrHň UH'\{ 5S0RrAK ]Pȷ <A;KX̯+3CL` l+x0XuXmXA@2AdQ4 @(P P;_VN 0 z* e 6`ezВ8TC"FU)xS;Q ;:fŦP;aRWT+$q`qCph6 BK2%Q(Q$& 5A gn8WjZO&&p sg@j` 4$pN#a j/@ 6'.3!M$$ + !8 "$ii +a1J&_p,dm.h@)0IY n0%™؆.߰Hs}%,`!,QEG /z -Q~9",+aF.~,m)#}TCE2z,Am o)1DEtD@3nb!9'|6tT;2.*q4# gC*p #*Q&rNA@_|@T\@?w'1sOS>'>B?`cԲip{}=xj'lr,PB&Q}&PCs)<4(&ٰC;:sov:&!:#;3Ȣ:za: ;tE8+#1%&917ӫR%7O#8!O(ʺ$`$aO:4 '5{5,ExB5'qI4V A)@ 336s+ !4zK3$s:4-*"0!c1z:~)|. R%|1d*o6ʧ00c'FaGעnF2ò> (.W?*F g)~ϩ%)A(l P-b[0dH1a'!i ,i`)X2+\224R+q/ `>4F(ERK!u+!V,"!"T;;h5:2#P 0GW}ɤ/ Q q. K>"XI`;ܑ)d/sLps2d!Pt4%Pm U `A!S1U }GQ! lpXu0XP T`0 t@F nC< Q uZWPg`PA _\dhjlnp|! , H*\ȰÇ#JHŋ3jȱǏ CIɓ(S\ɲ˗0cʜI͛8sɳϟ@ Jѣ$!drZB`'RX!-<,X^u6. E:gôIw,W>6˄-3kM)_eͨ9@U4\d6lThX3=k Bt8|pp^l ZCpǨBBK&dƎ ?_e3 Ql@O{YY@}}<-7dG%7&H_9/"Df쀓; 8"vqAIјΊ7H{0` cv0&3{HB.9 >AJJ\ ]@Z W!4Yf$L6dpxgITv:J0EQgc pGdѧs4S+,u# W EÁ~K7;љsPx z:-DȠ!GWQ"k[,1}F HԢfk*\qEj䰒; +)88N(F(f(T' YSԋ!F;pĺSFJ4XܯNX4 k*`/OoʙeEA3f ^(C;E ?<]E&QcIE=8-4D;"I)MT4;72JOr6 dADMs6$ gI<0rRT '?PBgy~r{}`I.?Mrx(y'%yL9 H(i:o rM}kB[MDM Y4q &XPP$N0&-pW4T6 Lh1MZZ$$P5wtk0%y8o-K S8cB%v 'Lcrr6cǚLR(;cCȓH5/|Г3H2L>^@f XpLi'@ vr0i0p& p'UD5 !ɟ@S%q+AjQ5^@#L_`[<2Gy#hQխ.@2`S `!|"W  ,"R8oVDPPKͭnw pKMr:ЍtKZͮvz xKMz|Kͯ~LN &"C[w\ EFDֶ6Fᡀ "faM̆+`9hqpnGQXEip  (@Ar&:OvATX N:$}C L '$Ҥ 4Q:GdDSB@hQGM**:H)X0c&`"eL(j?xWt#|t(ɋpA7,f"H-gx( D([CHwktiv)AN0JJK⇒ Qf:0= %hu N4XJpn)#mF< ;;Y$m 'Y@R}fޑN⊌H \@ngjuC 蜖@bCIFg -H$ Cz=$}VCI;hWk{CI1q[=TZ# g3 .l $zus)(2ؾ(Ca{Pnڽ0׋3v?@x$… LfBEB~<"ʐ#1"6$ޙPP~IL8I1b]Xϓ>0Y Z,]Zɷ CL` l( +pGUXV5[HfQU@ cB]&!DP7TCUlzpT-xS m<:fm;aRR,60@-f Gq( $Q(Q8p5A }u8WOhO&d'!H=7qF(P 6A r0h; 4 !`P9$CbM"L1Q +R0q,;sc(DZJA ,Ԝ)!_. *m I}4Y@U40x,헞ʲfF/²# .GE#X#0+#DDDqGt'>B?`ԲIk4w4|,PB&Qkj'Ps)O:+Rb pX5^SR4(!3QsG#AKS`w(y 4 2/;c3a*!n(2I&4C2D*!ܢ+ RcN/Ep ,S#E,FfS)a.).џ 3F,{b+F}t-=[o-an A,+ 9J_)2rJ-o.p-lb)!l)%)8(*- kgllҷ;'!')r,ٙkW%"!+q/ `>4!P6J! ʠ&-J% qc O& 84 J!X@0s9$am z. [>z1I`;)d/sL/pF`bf>EAP(/3mU;0(m U HmbXP T`0 t@F nK< Q WPg`PA _\l pr{a {Q &w~3}A9 RCd z2aөA(UݩLQ`ic p%ʋԍ/ӕc$u`}CK p$)!yBKecA䗿K o,BfG\oF<*Y3Ι; <'f8z'JD) ab<чs /)ecHpI*k6U0;:=rjꔑ }NmP6lqHp6`h<Ңš"AIDʆJ<4e߸R% zLJdeUrR{w@PP"l(B[LG?(pq '0ry|%,$FfI؛.#jCw2Kon6DĊňF(`󛰜RTaF@,y*#@4:egIҖM2c1J]I g^Ā~J8:a$aF}g=>TٲI- `ĿаR ;'eBz¹~R4` -(7Q\#J7tô2Ef@;T{CJ9*pQ!*17q!"l(JP?4i*EXDn6+US 5]d @H bI&Ȭ"H!{ߐZ҂ 4n-ʭd' 0Cc0%e,[S蓑.-@*o,-&Șы=cK=6^`Hyq#IWM߰AgB F ֈ#g'\J0GzwB1d[99 T)|p#He"@[x:A 42Ll!#CMAd`zƣ~j HcyVe%G1<HB0 TLVɐHp VU PAFQYV2 *`1G;z>B rā #l!|R pKLSPPň "t3XH ; d⻠ 0!@S GU q@#y?gF:Ɛ>8@^PZ೨іRAjg+朊$uKMz|Kͯ~LN@M E&1P>IA&@[0 g,R)"PGȠIA64V`P@=D8 482QFqoCB08`aΓPU'fP&h틾1pמ9^i|˂,uOP D%Y2uN v!\M*0Y.OZhx!M3iŶ`P^ @cǒl0AU6x"~- zƢLh Lv}Dl?P7mjb0~jJ9}cџՇf~PK))tABIH=`EFԓ@6GgFe#)IBE|vtq{CJ(f7|vKS6F~}Tbe;.%(0ώL4@J4uP;!,C_"9>+QM()D |5gPB ?؍ UF𓼼lerJ%d7qb@ JGo#`U2o\PV;߸cw#-ŧ@[w]H2Kt^5eh:xH U%gĔ/Zn}*Ӻww"$3W3=E&r.6~P]k(>%0G8 E^lYp_RpZ% X YPbw^ilePlw^!P0!PG:cPp\>!`N fA #RCOU8G_0R!+ DE R!+k "W O H%? ()# ֊8Xx؋8XxȘʸ،8Xxؘڸ؍B #0pVP(ITBET9uTI@oU:v>CL` 9l( +)H!%5'p([M6U@ cCDC. +P=aP,[ Z Abfz8Nl+x./ n;: g / s;qM7d$qPzcpn8 _)  26"c`L(Qko-Q=ږ=,% 6* _p#ζ#-$;Rxp g #9DQl")y=-(-m$ Ȑ0w""!֚ *ٚ+r'22.hRW {o65 % PP.hQZ`۠Yq 2| J|# 85X@|Cc / a' 1. [>4m;u/B{a {Q &w~3}A9 RCd z2aөA(UݩLQ`ic p%ʋԍ/ӕc$u`}CK p$)!yBKecA䗿K o,BfG\oF<*Y3Ι; <'f8z'pBKJxI@#RN)'ᄧ`(<ҢRDʆJ<4e߸DJd3eUrR.{w@rlDDD+apR nk2BDfIjzې.2']r(oCo;l6Dʶ¿ +#|0HO6`aBjM'%.y:rB8\:a$F 3lliRK3`sR&?7 N.H# c JJMU#~HJ7ڨ,#} )c1l#|ٍjJ0~h %^PrJ ı+~7ZRC'jc9Ɛ-PA߬'*/a0:c`0MU Dhb y@םo$&N`.$8E# \& `!.҈ZrƠ^'gB3e`gl!(HxOP6z@ b/"aa6x)(`~8H`#"D~Q[ A%̡ ^A # ] 7 XB2:adբ!GL6ꁂGH ' f!.^N4A7LOAA8Tќfx_YD1T) T Ρ:`%$|LY* $(Q 1ZiNDqƉ$=CY22a'(\(LKm[^ Kyb)giN^ A9$6_&=Ī@քJEX-T FJ`5]CLj#u_]:zV\dJh+L%I՘ dzז*d_Q€Ht.)²ĶB#s+,K&*-=L+F5Ix(!M_V%pt#@M E&1P\>nIA&@\I^J)FPO'>0ZQ@!{&906 j;+46AȇN #YLp 0@(~DT-opp.w3H8UDtG )?E2%#qJ"PJ@~ %ȸ`9Kk?I5 f6TBCZ3X/>n1B9<%GPѺmZII-GZbOaws_Ir-W٢ t.Ӱ PbB>_j g`raE>@xwO2!<" D7 Cr( " $=8 CѠkhR/@ 7 2%0 |EDp ; /XAXbGDeffP@MާE0 P-0P0>:PP9?!PC`A D.6_@FQt ` m$Fb @thl(Ka MQo `|HHH<0Њ }05Z8XxȘʸ،8Xxؘڸ؍8Xx蘎긎؎8Xi 0# Wn #XtHGH;DIUu>CL` Yl( +0YGk4oFq([6bU@ ^CDa. +=ESD0 |%0"z " 68aWh JY'-yA y 0F'6\w*C5,_c$p YC) U@T7P5qG%1MT1kcCC|@b4WIy1~ 2&A/ 7t%16'Q#P)c2AVqu|/ {1f!i50leoxR0&q0 {%0&%!0j2XC( hj2No[- "ـ#./3(!%Q23.1+L%! iY-:[FuzT5R`^uft3 (eAa'|j+-r:p&4 -g5*w*(":t/;,@ n+'Ҳ{oǤՠ+|#'Pp-oZ\#t`+l _p##l"%p r,q>*(22=Rg/"Y/p 9X0Q~""!NF>( w2!#!p+Rpd3%B $ P`!,h -푳\$ pc 0@`& 85h@~0R!@xFcPi*aM. k>8`;sQr `LpF@ b>BAPB KFU 0D!S1Ç!U }G!! lu0jXP T`0 t@G na< Q ZWPg`P@ B&l|ȈȊȌȎ<! , H*\ȰÇ#JHŋ3jȱǏ CIɓ(S\ɲ˗0cʜI͛8sɳϟ@ JѣHqBɄ ص+MAկ_" h ea5Hl6WZQCd6ƟÈdSB heRa_@:|4bp^l ZCҦKQW;_eS8oD٪޿cg7/%;T@N=7DĈ{cTNˡ >{a {Q &w~3}A9 RCd z2aөA(UݩLQ`ic p%ʋԍ/ӕc$u`}CK p$)!yBKecA䗿K o,BfG\oF<*Y3Ι; <'f8z'pBKJxI@#RN)'ᄧ`(<ҢRDʆJ<4e߸DJd3eUrR.{w@rlDDD+apR nk2BDfIjzې.2']r(oCo;l6Dʶ¿ +#|0HO6`aBjM'%.y:rB8\:a$F 3lliRK3`sR&?7 N.H# c JJMU#~HJ7ڨ,#} )c1l#|ٍjJ0~h %^PrJ ı+~7ZRC'jc9Ɛ-PA߬'*/a0:c`0MU Dhb y@םo$&N`.$8E# \& `!.҈ZrƠ^'gB3e`gl!(HxOP6z@ b/"aa6x)(`~8H`#"D~Q[ A%̡$'IJZ̤&7Nz (GIRL*WV򕰌,gIZ2B2J "oHy˪xxb%ST,8 8' D: AD 4 4T3%>^ Iܳv'`AD|,t`P@PbJ;3Ba EA)Ο؆:#GpҎ_ZIZ)M/ri O RdB#ť+h4qhRG&r> Ka2fR@a n c#` G%.&@ G@GaP&3P#Xy@bcMAL20-f P 9bRCَL[d931lqF5xP+G[" 0A |c@ : ,~rAq(~Ɛl7]j T\b9D{0#5Z҅)6  DJ.p;?ů&]0¡Xb J Fy3iGňfDkDTPߝ%1 ra4ܖ "v #r"PUÔ\baiEFP@U2^i"4W4h4,-l"@3`ғh@QA *z9,z$NP1Ea1D9H֎d87kI A>ⶭ1XZ%@t@s"DEP)^ A/> wVdhl;6b:dE dU`J Ce#)IBDh ' )X`~lPMױ75T 8&bh" 8u~I@)W8]D!FΑYPG!^B )bH2%#-D0Y RRC:l!aC 2Zd\Ix A+6^ 5NdF[&=g$
/@ 7 %%0 $PGp XR 5%ց~u$ߡQ9a a }ZD`mU0Pn:PP9?!PC 1 BhI9 .&_@HSQ@n ` m$Fb @fhȄ!Fk "@7 O ~ <0 }0(Sx؋8XxȘʸ،8Xxؘڸ؍8X #0p'(`UQHH~H@o@z0 ~@W7 II0(@mFLGA1AXa(X4xE @( Xa&! +=MN[ _ Cz8C4^At+x$`Zm;Bai-B*q lcf7  % `3?H<PF( i@_H  4q # dQ~/R\7q &;P 6A r ^?$a &Ђ6 $.`3>! 580)~"'=ƅ'z@~#HC)_plPb€3b<~Ή]V6ɦI nf.щ*m : g+z }G,9~vfPr~*-!z |*a$n(A88kg/iף T@'*qh# htiM:p)!*35,_c$p YCuVYOzqRV+Mn ,ehj#FS@1K0&r"c1|o*0c&Qw"P)32aJ|( {1U "!J) a&Ag/c2]Zc2Z*1 &Õ%1 "ـ&/ ]cM%b:1RjAp JՊ0R1L"! T-z(pwCe',^c,V5m80+0rP r2*a* j-r:`ݱ|*$+!5k -``5*f|(:t;,/%-Z|"-9Д#A|#C:;r mV+zM+%*<)_p##?Mb!%)[ j+p#I;c")(_/"/ I/ '"@PaN> )S w2!#!,R@ [3qsR{dA=57;^o[QQ%P)\i/ a DZ. k>m lI~  \8@ `5铀=BAP3FQ C!S1§FU }@yF!! lLu0ZXP T`0 t@F nS< Q ~ WPg`P@ B&tlxz|~! , H*\ȰÇ#JHŋ3jȱǏ CIɓ(S\ɲ˗0cʜI͛8sɳϟ@ JѣHqBɄ ص+MAկ_" h ea5Hl6WZQCd6ƟÈdSB heRa_@:|4bp^l ZCҦKQW;_eS8oD٪޿cg7/%;T@N=7DĈ{cTNˡ >{a {Q &w~3}A9 RCd z2aөA(UݩLQ`ic p%ʋԍ/ӕc$u`}CK p$)!yBKecA䗿K o,BfG\oF<*Y3Ι; <'f8z'pBKJxI@#RN)'ᄧ`(<ҢRDʆJ<4e߸DJd3eUrR.{w@rlDDD+apR nk2BDfIjzې.2']r(oCo;l6Dʶ¿ +#|0HO6`aBjM'%.y:rB8\:a$F 3lliRK3`sR&?7 N.H# c JJMU#~HJ7ڨ,#} )c1l#|ٍjJ0~h %^PrJ ı+~7ZRC'jc9Ɛ-PA߬'*/a0:c`0MU Dhb y@םo$&N`.$8E# \& `!.҈ZrƠ^'gB3e`gl!(HxOP6z@ b/"aa6x)(`~8H`#"D~Q[ A%̡$'IJZ̤&7Nz (GIRL*WV򕰌,gIZ̥.w^ 0IbL&Ll%x%Xg |ȁL` (Aa02T7[ԃ0(uPMRG!@LhcxqM&'&0aXB-ĨSD\  lpc{V(3(P; .I/P,n10$g*᠞"# ;$ۖ  @c+bZia]+{JzF_XI%0Tq 5qJ0yJ%u7OaZ3KLzB, }G(h/tVgP#~@GT"]LءHλiBK30a}uPЂB 5 A #P~CDU1IFbF DpEF!Fk "@7 O ċ~ <0 Ƙ}0hOؘڸ؍8Xx蘎긎؎8Xx؏9YY. 0# WP ɐ WtHGH;D(P0 ~PS7 II0(`(a8FFaGA2ATa(T428 @(NG +<ESD0 u&0z " 78Vgh Zٖ=$$D-xp Pb::X BBylj"# ~r ezo"'=EzR *aw(1-=2=P*y0E)B)3^G` "(I c.I),x: g+z 'zG,9Sau|+ (9"Ld,x c8s< t) E9*%vҒ7-0yO0F'6fqDZ@ 6oJ54% p) Ue7P5F\oo'k}XD#1FS11f3d$@xmm(Q#m&,-lJuc{1s!o`pR0&q0 St fc2Z*1 ëc(1"%/ Yh:iߒ  /ن'!m%Q23.fZ+0b ,xR f!s_5RdiglFgj3 (jAcՊ2&a*bqb3x* &bh 8[] "ICFv^**!|"-Usl vw_0 !w#'A^Ҡ-1c*z6,1\&9>pw#o#{"%7 |+q١?Bu@"2\,r`"#u(9ٸ(""!j 'X'r'22+r o  2%B $ P*h` .*G7;$c 85H@tY/ a9& . k>Ĉ{<aiQ PQ8? `Un1+4%0a¡+N└FU }G!! lu0jXP T`0 t@H n]< Q YWPg`P`? B&~|<Ȅ\Ȇ|ȈȊ! , H*\ȰÇ#JHŋ3jȱǏ CIɓ(S\ɲ˗0cʜI͛8sɳϟ@ JѣHqBɄ ص+MAկ_" h ea5Hl6WZQCd6ƟÈdSB heRa_@:|4bp^l ZCҦKQW;_eS8oD٪޿cg7/%;T@N=7DĈ{cTNˡ >{a {Q &w~3}A9 RCd z2aөA(UݩLQ`ic p%ʋԍ/ӕc$u`}CK p$)!yBKecA䗿K o,BfG\oF<*Y3Ι; <'f8z'pBKJxI@#RN)'ᄧ`(<ҢRDʆJ<4e߸DJd3eUrR.{w@rlDDD+apR nk2BDfIjzې.2']r(oCo;l6Dʶ¿ +#|0HO6`aBjM'%.y:rB8\:a$F 3lliRK3`sR&?7 N.H# c JJMU#~HJ7ڨ,#} )c1l#|ٍjJ0~h %^PrJ ı+~7ZRC'jc9Ɛ-PA߬'*/a0:c`0MU Dhb y@םo$&N`.$8E# \& `!.҈ZrƠ^'gB3e`gl!(HxOP6z@ b/"aa6x)(`~8H`#"D~Q[ A%̡$'IJZ̤&7Nz (GIRL*WV򕰌,gIZ̥.w^ 0IbL2f:1$@LhcT}qiM&WJaXB-ĨSD\  lpcw<$ࡀz$Q9qhq8i2 Ţ hr1 ֋+xPuଠ\`@@P%\ŽNfÄPl<* Xl"'_sdI&r9B *ܤ0,C8  VĚ\v~_M`Ci9$A1$ t4jͤR#*V(aD>jb~I B{$.Pߴ|I+MP;LK@xY| KSrur}S9SPP|S2ai#-xAw InZ6ВvYZF\(c|*@zA4$8@0aQEtQISTWFI!Rp&o" pB}m[c@G hj,12FS 4oK8bSWJHa`Ei{5lci@JFTC#":\WAF RP=g&{CJ2TVڨMS)TC>-ؚ" |AMhDTdʖ:-qTz[0@J4@li",6 b_ʆR xc &D0Y RR{C--u $?(4Zd\P8uEIH" ט::n>DR ,;!)N +¾A [chI0g83*,҃k $@IiPWZcN%i(CXS N5_V=3}@.aEqG}PI֎*йN2BG(O+1St},SFx 0wj%_.qx :1<nI%}'!DxF6`^))}L %:8$p4DL'`a2z?t$ 0ht/M~1~N"rwK WAXb_'|'X@Pc;f~R ~/Aipe< `f(&:  :PY>!PC$a 2&;A #@GDDUQx@R> ` m$Fb @>Xll8Wa t(Fo `|H ()#`~0qrcФ^@s _([ 1X 5%Q9&Q4P(D(0 ~O7 I0xai a0pڴXx蘎긎؎8Xx؏9Yy #X!)|IQHH~HUXp>C mFkFaGA`3AQq#H BDVG)Q4EJ7gx瑋78`Rh W "JH4Q&RpB1qZ/()aw(12.=2= Ȁ[{|#d<.'_+~Ij6,,u:և}}w9ė9x8B9g{Ғ{+a$U&i)A88AzdLIBvzs07n F'6AxL:\#q51wuPv(5YlQvLcAi354>$FSU1XΦ1f3q#mqtt(Q}"P){2r;w|p2690r&f/c2"' nZc2Z*1 6{%1 "ِx&/ To*oߒҩr/fv'!٠t%Q23.欨m(Ӛ A-ւl(̆pq5rk6t6r80+©TƩ(S*&j{-r:u)+!+5f 8[` YCѰg+ "|"-1fѩdmP`L;r" }g]b|Hf|L_p##=zM!%qcc0q[>*$F)<lj#`X,R"#a\k6F;1}~""!`H`4)`0'r'22+J(_6Ro:_" B+hRA=5r\i\;VY;ax&!["D[:Q  / aZւZ"Z>48:AsQQBXEpF0Ѿ=BAmQaTvD!S1Frqr> mPSp6@@t kP *“nP`qa pvP E@ T R{a {Q &w~3}A9 RCd z2aөA(UݩLQ`ic p%ʋԍ/ӕc$u`}CK p$)!yBKecA䗿K o,BfG\oF<*Y3Ι; <'f8z'pBKJxI@#RN)'ᄧ`(<ҢRDʆJ<4e߸DJd3eUrR.{w@rlDDD+apR nk2BDfIjzې.2']r(oCo;l6Dʶ¿ +#|0HO6`aBjM'%.y:rB8\:a$F 3lliRK3`sR&?7 N.H# c JJMU#~HJ7ڨ,#} )c1l#|ٍjJ0~h %^PrJ ı+~7ZRC'jc9Ɛ-PA߬'*/a0:c`0MU Dhb y@םo$&N`.$8E# \& `!.҈ZrƠ^'gB3e`gl!(HxOP6z@ b/"aa6x)(`~8H`#"D~Q[ A%̡$'IJZ̤&7Nz (GIRL*WV򕰌,gIZ̥.w^ 0IbL2f:1$@LhcT}qiM&WJaXB-ĨSD\  lpcw<$ࡀz$Q9qhq8i2 &TLQm@X 8<MyYd87|MqjK`i.+qXR)P '^$<>fW KL])!A{FZGT@Q]Y)Q ZJ Ce#)IBD`dQ*< )Xp~N)Þ D)mI 6 &&_h" ,7%Xah ڴEhh%! 1daiK>FaɆ&Pk6kR$rrVYYɎ8nX@K)\DWIQaø+y\Ê aXFI~*йN0q<%&sT0] )pb;,qx :12K s7I%d}'!DxtF`xޠ%oĻC}_kn T9D<&^'q@HjZc/M~!/c$pݫ=&^OdK֩F PQ@&+(X<5b`i/j '@}j+ZSPpRUP9?!PC 1 3,12A #@GDDܕUp Fb!F DFF!Fk "@7(\'ţ^8 O Ą~ <0 \}0(M {!>r@3Wy?PW0@ݕ}W8p1 Lj"cpІB&ZP *yd 뒊# p 5u@-H" e%b%8Xxؘڸ؍8Xx蘎긎؎& #0pTP(yIQHH~H@oUt`>CL` 9l( +`YGk($8[5U@ 0fCD`ZQ4EJ [ AV ":80Qh G'^.0J!"!& |{*_" C6 nvo$A=57;H<yK%PTg/ a1~vн5 ASIϦQ+Q?L@pS71+4%aG U @C!S1çFU }jE!! lZu0jXP T`0 t@H n_< Q WPg`P? B&,\Ȇ|ȈȊȌ! , H*\ȰÇ#JHŋ3jȱǏ CIɓ(S\ɲ˗0cʜI͛8sɳϟ@ JѣHqBɄ ص+MAկ_" h ea5Hl6WZQCd6ƟÈdSB heRa_@:|4bp^l ZCҦKQW;_eS8oD٪޿cg7/%;T@N=7DĈ{cTNˡ >{a {Q &w~3}A9 RCd z2aөA(UݩLQ`ic p%ʋԍ/ӕc$u`}CK p$)!yBKecA䗿K o,BfG\oF<*Y3Ι; <'f8z'pBKJxI@#RN)'ᄧ`(<ҢRDʆJ<4e߸DJd3eUrR.{w@rlDDD+apR nk2BDfIjzې.2']r(oCo;l6Dʶ¿ +#|0HO6`aBjM'%.y:rB8\:a$F 3lliRK3`sR&?7 N.H# c JJMU#~HJ7ڨ,#} )c1l#|ٍjJ0~h %^PrJ ı+~7ZRC'jc9Ɛ-PA߬'*/a0:c`0MU Dhb y@םo$&N`.$8E# \& `!.҈ZrƠ^'gB3e`gl!(HxOP6z@ b/"aa6x)(`~8H`#"D~Q[ A%̡$'IJZ̤&7Nz (GIRL*WV򕰌,gIZ̥.w^ 0IbL2f:1$@LhcT}qiM&WJaXB-ĨSD\  lpcw<$ࡀz$Q9qhq8i2 DN*:X20z'f.MM*;(BaX0Anդ F8S 0RZjLZ J1b"AF"v&&ꗤ |@''|KߠL 1ADw#Ԏ5}d`| KSrur}}Q9 x J6,q{\ 7-KhR,-d#Yh Ip_ #@6g *6 ,͵nTDRkTIa{zD+P ⑸/FCL` l( +HHk$m5q}([U02U@ PCD%Q. +P>ESD0 y&0~X \y&z@9C>dyAz+x(GB `_<:`S BB@p 'pz82=P zp[.{b<0|)U6I `.*|*t: g+z vcv ~ry, (v9!Frdc8s< )+ 9*%asR7A y0F'6`*w*AH5, V$p YC) Uk427sF\u4(qhGD#1FSI1K&1l"c1 u*=%8 E27,-pJ|`/ {0q r gom{%q0 sq%0&C$a0:gtYC@( lj2;'1(Q2sƬ#./% "s%3.J+Pb ,R+(! '1!,^c,V5qv(jAaj'rsZ--r:u*6Ҥ -X5*{*e($+:t1;, _+'Rra#e DW`@;r*6`V zP+%'&Q) _c1#-$)Rx 9V+Y?/2a "+2-} 70!w҂ &a{>P' w2!#!}~л.R0_" B{A=5[:;TVTZ1߱%PTz/ awǥ . {>ă1;PLdQ?LjpFR>&1PLF! C!S1ħFU }F!! lVu0jXP T`0 t@0G n0e< Q WPg` P> B&쏊ȌȎȐɒ! , H*\ȰÇ#JHŋ3jȱǏ CIɓ(S\ɲ˗0cʜI͛8sɳϟ@ JѣHqBɄ ص+MAկ_" h ea5Hl6WZQCd6ƟÈdSB heRa_@:|4bp^l ZCҦKQW;_eS8oD٪޿cg7/%;T@N=7DĈ{cTNˡ >{a {Q &w~3}A9 RCd z2aөA(UݩLQ`ic p%ʋԍ/ӕc$u`}CK p$)!yBKecA䗿K o,BfG\oF<*Y3Ι; <'f8z'pBKJxI@#RN)'ᄧ`(<ҢRDʆJ<4e߸DJd3eUrR.{w@rlDDD+apR nk2BDfIjzې.2']r(oCo;l6Dʶ¿ +#|0HO6`aBjM'%.y:rB8\:a$F 3lliRK3`sR&?7 N.H# c JJMU#~HJ7ڨ,#} )c1l#|ٍjJ0~h %^PrJ ı+~7ZRC'jc9Ɛ-PA߬'*/a0:c`0MU Dhb y@םo$&N`.$8E# \& `!.҈ZrƠ^'gB3e`gl!(HxOP6z@ b/"aa6x)(`~8H`#"D~Q[ A%̡$'IJZ̤&7Nz (GIRL*WV򕰌,gIZ̥.w^ 0IbL2f: (FL  A}H &"(.Uw\4q %AL1b AQaXB-ĨSD\  lpc<$D&31P@CHr( RdF*xZ㇉FĞea[+( @΍& &[WIjc %趴ki۫f%! 1dQ{XI0 %EH80L,vH~ic %ȸP ؜Q4,(+H@ !@ \$I>I/4#&n1.r'(h# :x%-Х$rrV_ioܗewGZb6O!#܉yw}XhJ4s{N`r{K4oa#WbʟG;o]#1qDxWg!vz:A[RzIH3}!@L~bB+f X#>;>_ I&$-s?$%g@8 4>YXb@7>5{" YY|9$(:a #ߐyG:PO @G;AglUo0aP0>) O7P@UB 5 *AR&z9 .68_@HTQj0DpS%FcaT `@bB ` m$Fb @WhlHa ts$P9  0O G O 4~ <0}0M8Xxؘڸ؍8Xx蘎긎؎8XxH 0# W@N) wtHGH;D(P0 ~PQ7 II0(mFFaGA02A0Sa(X4M8 @(ReTp +>ESD0 '0z "880U_h Ru8'vBTq2*a*Y&ۢ*:r+()Q9[pvx)mC8,!|"-F9@!av'6+b;r @1-abg]/>Ф/7R+:($hkRx` ^]W+9?H2 "2-{701~~""!& (~'a w2!#!l'+R*3 % &@,h@ ,)S# uc 0@% 84h@\Eg`:;Q qlѕ`. ޫQ A\hVIq+@S]LpFД zU>BAPKFU PD!S1çFU }G!! lu0jXP T`0 t@H na< Q WPg`P? B&܏|ȈȊȌȉ! , H*\ȰÇ#JHŋ3jȱǏ CIɓ(S\ɲ˗0cʜI͛8sɳϟ@ JѣHɄ <@ҦON rΆi|K@c +Xe;+Z3p M \DŽ #V,=~k<&[ (y1>CͰU=cL ]Þ=>;کo&V8-NҷI7b؍Csa9cSAF=h {t~k6 HsA .*P|726#!fgBs8lz"5h2p>n6BtHE>vdHB- SR H1{^9%%Df֎1=F}#QIdCi {3Geq@w4(`F8Qs>7}L'esQ<©`0 7n;kc Q?,Z1P+`~>G+D]لE@C_hQv[C@,^i6M,QD(!P|;*W,':Q8iTDCT? N!eR2RYӑEDa Z*],#f  Lͪ~S{_ #Hb1PתưR1*8<ڜAUd[âiUP߬¢߀ H8$w8c`5Ww, TY8!(e.2өE9!M’9E¢3kX%,^-nrWp{0` AN(d=`BύtgHzzpq~ʨ:izpP o~chwI01F$!<ҙV݃#hQd P0v<0"`Cl'/@0G 0>A" a oA s&:PH*ZX̢.z` H2hL6pH:x̣> IBL"F:򑐌dh#hH Ȅ6F_W$)29ML'C nS#X3hyT}3P#dи <F!AiID= ab<(F/  aG% 0A=&(δaP!qpF T!5R6$GF r;cs8f$P&PH'$@B L$:`P L&4,8O'p(*P @.l"ULu?DN*: Ъu&]`ATf"D2 G >&դ FP]e ` i3iňvS9llJR!L> fh 8VF ߆HpIBj6=XI, >8p:k1 O7EJ(@"*@{}4( ^T"%Ua-9@fGL`$8h!̷HF \)$buzlU t[f@8EI@pe +YdU| 7r}( W(qI\"[8E1 T&T2e")^V6,VDz0n# ƕ dK3*2:/kE+IVl %yJW*C*E0I l!%DJ[ZU8I j:eeEHmj>$hu\d]3\qp\j* )| l!%ˠ`ԣ-L"Qj()D Td~K3>/#%CP Qc@JQ$hW$PX[JI%d{dB>Z3Jznex%N1.rKh&Ҳ`xYf@EAM%u BުJi6H{k @KV_N,#ixwSJh `}YzN`&9KQuD}/@`K`w#Cо/g # JL1I I|=j7 7K"k #>BfKۚrXLۦPH›6xK{*/%sr$>jcM*&oHߐE^PY|N0PW! M:a R%dt#r `B;Apd2U=0W` qrM<:Pp6@! 1 ` ^zpP.8_@^Q g0DpBc@ T `_A ` +B! @.x!\Bk ",7Ae|` C{bho `|pD| (h)#|t؊8Xx؋8XxȘʸ،8Xx 0# W`J BdC8C(Q'd6A n#ME4R8q$W4A C%z"a zgMW4a;! 'PcmY2:";@j@yt2"&_{Rz1(W |Q`0g,wi8p]%(+v`Q (z -AxqC,6>եuH@rvb6_,:g)5[bb7 D76.%`/i') #lˢJ0q"ٰ'App6QiolF'.tark&"&p.!jvy%1!0%--֥rr&-2-櫁"(*!ِh%1b,怜{yZ+ b pBR쀭mp)!-*8*7s UvfR I2sz +r:0}&6:a6m0Gu)b'Ѱp]+Q&rqZ'`7)2guٲ]Kr] >2 _ ץ,d4IpXq*OvELopFE3%` d#wXH0(,N Tl^ mPS5@@t {P > ēn\qa pvP E@ +Xf|hjlU! , H*\ȰÇ#JHŋ3jȱǏ CIɓ(S\ɲ˗0cʜI͛8sɳϟ@ Jѣ:An$\|0@J+\8%cEuh 4Dl6WZ. (de篰eBcܟ hbY*4;Buc*> N$uPcUht_7FYkj'n ķyW8\ ƣf"Oi--Iz>x94xvoW {Q :ڕ~'}ЁxBC8 E I6@qx`/vl8RRS-#e]9T2>B7Xhʼ#h8de߬$Gxeec!DLxee^|eF p}$DokbJnu.m`f1L=}#KXɨ?8(E tE N(NDf-mMUqh) keђN>C#"- iC}ZacJ01alC)H?V̚R[7|ːNG++@cBZ,"Ľ:B[ Ԅ|0B +L@,+Lc~( N@J$,7Cl(T*}LasAl\4Ѱ4fSeb4˒,PD~EK 17tM 2* ,}B}~c_KSm --N#/!>-84>-L30Py'"@e N1mvc4@!lf %z Lp@w:A-;3;Ԁ}f_MhA(DO2P,u ? 9Ep}HNߵD0O2?q\ ,!#(2 UH0 9\g$egAY p!jBB6yQz!zdÀaBÇ@LqƐ8H`Уpd`c#@ ty#hQp A D `7{Řh_`8OA \#$'IJZ̤&7Nz (GIRL*WV򕰌,gIZ̥.w^ 0IbL2f:Ќ~h#hH Ȅ6F$W&)9M('C nS#X3h&R}3P0#d (F6$4<8M| |mD@ 0E EQeC*xD^㇈b1HQ$alh9xT%D=*oAJ=q'p% V!px EI&Q=k`\xJm}:lAgD2 \WCUN.p %Pb Xy[pCDA!i%Ǖ Ǡ3CTZ]lH.uen(ŐPz\I4$ SzPz[(IjTe )!BCELijwnMcm%hc݄" 4OdͶRqoڵv}ʆR ׉ ~+@$J"8M@E&@qX$?5ZL]BE.PP %(|M+,9v|%|2(zw3Dr"u%9#tr)l6V eT\dTBm*oƀV²J=%+p!4l/VêM T,"OUXR§j.qC YO 8p [WR5T |! AY|8#(:a eAwUUg, PG;AgmUp0PP>+ UP UB .f% $` ^QAA Yq<8_@%bQ@jC7Po4F %FaTF DЅGF0Fk "8Ae|` C{ho `|H (h)#8Xxؘڸ؍8Xx蘎긎؎8Xx؏4 #0p0)p~HaHHH@oUuX>CL` l( +IGk$pFrw([3QEUS8 @(`DjT_6OE@EE0 `(0\U ^8zP:C> cD-x9zl`z0 6@!B*qtcP!@!c 1!x< }&Q-p7A <5qt%dQ`zER8qu%cE !Dp? Ȁ4y%ES'2?"2Qg'%>ϥ @*a˷=1(_}0rBR@tak(syzh'a++aD ـ)q*aC'!۲*\w+XHJ5-}Jd([,6{+Z,9@Ja('t{b,vg+qvaQVc-qbf),1;3R*__ ULҲ%D;j,!vR.#r}_# *I(,)-;7@0~}"!? )~' u2!#!q$ һ*q&:%'R WȻk ' @~c + 804qDh@[u"~#!ACѵ . [>afIFE*@S$D8A `O"_ QS6APM FU pD!S1äFU }G!! lu0jXP T`0 t@ G nc< Q WPg`P? ײ& ȊȌȎȐ\! , H*\ȰÇ#JHŋ3jȱǏ CIɓ(S\ɲ˗0cʜI͛8sɳϟ@ Jѣ*lm$\ qJC BA%h ͎p0mh˕O8ΟÈ׻hq#[@ĘRaM!d41z0m\=M 4&a&ڪҾ{g''=& AT>7\R{￰t'\`NNI'G]2> [9 <ПMtpJ$x ddÄN&E#%&M!E-6D5N%1#=vW $GA@,!0L;"}Uf#*c!!9(1..R4pIaIĶlkQ& [ _Ik( }#2&!GTl7p* zoq:~ieGG%|zj6I7ntTGfSFр-b@-G)\aV ɜk(žG+mc]-~0h|+ F$̯0:lFiphl zJͱΘlQG!sEb/OntCʘfi`Q0Oc LOIʂKGpͫg  ^M]-2G =(88-Q5: %w$ا}6<2L`+*201lR*S$dR P**UCd)1wpCB~H7{D^.R:/P,|%PDL=Z'(Z.O W )?c7ZNK*㽧r0+E0K 1M-rx09c`GZ R؜yƠ -&@qv6:HMЁf:qA&D@L  7nPlpTGt#p "0 81.@(!O 1`<0G |x" "(a (GIRL*WV򕰌,gIZ̥.w^ 0IbL2f:Ќ4IjZ̦6nz(FL  zH &"(ңw\84 %AL8b ΠAaaXB-h2 \@a }|c#` 5$"Ɣ-& AƄCY:"@`eHt LP'f h"C"04 8`hcV$5%D=)4`' 3̴'a7yB])^{^U:DDX@PF6&W)' >xw:}1 vZ  3%1^xtWL@0J^1'ea5,9wL`O 7 \CC#Jpc6#,5hy$@[1pEq % ,@h3-E>p(9@>q.x$3f-]J =@*Qt⇈[ItƯl, %$(pbG +B(;k$Td>(V0 VJKI*"HQԀzCJnPֶՎ`=;i_S4ut%Ļ%EiE[QꒌJ40ol՛"nuLB*bH2,'",PA `Jq:`ȁ,@^FJhaYs"uQk; FQVpW;>>HCR : ;!@c#~c-% 5uL}9L& U#0 ŷ,6u, &%g tR4> Zd S \dR5&z" ZY}9 $!I9a x% v @H;Ah-"CA5P>+ D*R=:W$ P9@![1 .` ^PzHCR]U&[@lGcoT `pc@ ` |GUp @jx{ho "t$P9  0O E O T <0}0N8Xx蘎긎؎8Xx؏9Yy ِI 0# W@OI tIII;D(P0 ~@R7 I U0(0mGGoHA`BRFU@ DD7A. +?EacPx[ Uz "Ж88 Vh dKDE֧^ bzEZz0 8CsHu60@H L"c 1+<*p8(6i`[UbYhW`AdA&W%p ig\]2P`\3A @' {2HPdR\4q?" (g{b]2q^V;٩@}r#:؇)_W~,^~/!~= MpuT+{"<a6@}xI!р~0|w*@,a:]{aw&, y*W+jGl(89cf9dXikg9sTbgUVV7~t-0v}u 9s6C$pi4`**'5oB*``1lw5N/Qckz*Kvvd,cb] G!/ˆ%Um'ާ,73pJPu$ٰEG2ºmfopʒ Dp.opEb1\4jIC= }j1.(Q.v/r *A5 V.s)L0&" 'a-2R/)Q1B2',j,QvKK-r.rA,**lw(+l+peǴƧɅ:~Bf5,@)!pТRb+:ʒuq' x:%r1:N9FbbyB> /y P)!%:k%&x pw*jDҠH6E*)\01##/PA~3 2('@_s{()2(22wλ   1cRh9|%>%1W7;[{&V5Xeyh/`ATv$P\FU D!SV$|FU usJ mPSt7@@t 2\P 5rLǓnpqa pvP E@ -`Rɚɜɞɠ! , H*\ȰÇ#JHŋ3jȱǏ CIɓ(S\ɲ˗0cʜI͛8sɳϟ@ Jѣ 9d$<@J+LMd%EYyt%Wշl6YC&ke㯰cG!c hRa]Kz TL0<^M=K )IGXo^Vye U=I8h)īکo&Đwc*Tnԝ$畗CsA,X9~([z2 ~RA$H] ]xNwz dCNpBUP_:E 1!1ԍp,#8NW `^}CA6!0H^;eEՃ3"!Ep%L o,bQ ʼ[ *I4R˙~Nz~'ENѨlUJ|9iacH@nO6I\ }ZNLd~#)-e!j*|@I<4a߸!{W kR8DD֎:%PmV QZd@JDl69;%J`LHt[ D$|0H +L@,k6L  NJj|k6xQGM`Gb/ D̢Bi0D+([DoǂD'8dD ^tMQrDx Z)˷ 1̫F9'L m9/woE ~HEyG odKưEExFqzEުLk~}Q *(jٮz#cS< v`L (WA aD`t`%:HJ0\f: r`e8H`s 0A0` c6bO N<+ABbt ' IB ` !X6_` "(a$'IJZ̤&7Nz (GIRL*WV򕰌,gIZ̥.w^ 0IbL2f:(FL  AvH &"(Fw\4 %AL0b ACaXB-¨SD  lx#8$*31P2Cr( dF*x^ÇF1R` aT%؍R- 4P'Cm'@a<,k&PVI'$耱`dDw0Қ\` &8/HIp WJa6*Xl"'(N8qhgĽ TI%``'X >͏ OI$'-8#R0 h y@@ b_;r66 " &) &v3Ok#  LQـT51$/<1_ʼn%Εd@X- E++=l%` D#v1Th X1Jpb0#܎5(y$N1< t{E\adi % 8,@gD>8k}#_'5\$d,zt @I!hB%Z.^PX`I~nec)!EMd LmBaKKj$TD (F0f)HI~-*" jf({RxCJFI@ #dS6}ئa "vt& FӧM2Z)u튼!`FLB c)B2%")f%HI($L"VMQ+e(,ƅ(߾3J"Qt$VX$PWT[JPfJ%d`AI,-h(q#E $mUckYGت`LY.xKKe\u"wU h \|oH"wVhlJ$_ N`KM}3T#JDw;L_oxW?0@F/D7J'K*7 i&< p`/P!Mw~zg^;C0 R|cW6Pu, &Cc3 4>Y7u>MA$' @ȇQaMXA w#u|u{V g;AMP >* BpC>:3Pp9@!<1 %` ^zFdBQMU[@7 Ca4F %&[d nFa @^xm` ?$P9  0O G O  <0 }0MXxؘڸ؍8Xx蘎긎؎8Xx؏ #0pDP)~UaHH}4H;D(P0 ~PQ7 I`U0( $mFG`$GA1A0SUX4TD)ņ". +=ERbx[ An "88 Uh X<4cDͧ^0 a<:V ruhQ"[4>ș!М (fv2\2qS(; |5#1q=B==]r}/}r<.8<@~L&*IP'"#i@q)}wxK#:uw sVr,'"eac,B1)q88Ss҄:9`s** FpU&Qd" }lSgJYv }iڤ(!jb? U*:j"cGg-cp(15Fnl j0kpV$a4 4j n$$~@2&k;wݲk(Q#f P}2Ȗ0Jt"r/` Д'$k[Sx𳾡 g.;J$_ #*=.R-k3ȀG1~~2"!ǀ (~R(b'220w  k  1(%R@dǻ/;;$4:;;Q1%!4e!Ɓk. {>X8IXn 488ЉA `R#b1@4%`X;0(Al`Tqr> mPSs7@@t $P dlƓnoqa p vP E@ -LrȌȎȐɒ! , H*\ȰÇ#JHŋ3jȱǏ CIɓ(S\ɲ˗0cʜI͛8sɳϟ@ Jѣ}e$\ qJC 1=`%h ; H:91Y,wmBu뿿k d;\wЧg^ͅn@w]2d^9`QtРE p!)C!AEr"cAEQ 1!Yr#AGcO@0&LtAGC5I2DUZi8D!?zrP ʘi]y.7<]ڙ84i*I<6gD+'CZ)fLL?cHАZ(PK4haF8ZۼҤb !"8kK<,b߸< m ˲G6( NٮZBjQaΤǪ ͣ˼ (]"دsnJ< 41r h#Ч` *"%yؚ3 %4$:Bk6b=v+M4 mH\,80T׺ żDkhtʣ2BCd(=*8R7: #2H;j }C<mX;A-~W-xz9H ,8@$ x۹y{B~C pR *5Hb|H$o=dYGLV*ᇄ${EU⺡//$,nH`лo$蟝`P;^vq@8 0Б `z*HC> !HKa ; R] aPtņ(YGb0U50IhXg1.qH a `f&9@nPlpЇr%p "08(;^€ b0  L~q<oP>A%A (GIRL*WV򕰌,gIZ̥.w^ 0IbL2f:Ќ4IjZ̦6nz# R4A$0 RdB#݊+&7)Z,4> Ka2_U@a }|c#` 1$"-& x&_ʆ:"@ApZ(CK@`z44h"C1̌l0m*T$/+m,r@aQc9D8$+mƠ P&02 o qP֖ /X'E~ O,SI;~$S*+ F+,=l% D.7 HFIp0c/#,5hY$PYN 2< s݊A-+; NPv$")A `.AJ@#`)7lȺ[XFJhq"JpxQPwkm>ɪ (QUW;>>GRI3?]VORjIY%+7q"|E!g1 :` ^z  .S8_@,TQPk2DFq4F 6GpcG D`EGFk "8Ae|` C{ho `|I( (h)#Ԏ8Xx؏9Yy ِ9Yy K 0# W0O  dIII;D(P0 ~@R7 IV0(<mGGoHA0BRFU@ @DA. +=E`Dcx[ Y "88Vah p9KtE-x{H*z*y5az0 k8TCHw70@X W"c 16x$*4~%QCH 7A e%ʼn6qd&dQ{MR8qAd 4!a '4%E_0]3 &(gr]1q^b;("2ag8;W)_0~05*=}L`w}̳I&Vc.*+A{; 7+z -|cxa|+ 9*W"Fp C9s<0fc** Jx*@"w8" Reuj p6'q7岧f\*b6D()u27]siF6/20k`W%535Vs@o#~2&lExAl(Q #v `~3֫(Ju "ٰ't&on(*5Z#1uAo&qC1oˊ!}&A-f&a0 YAMo'//((k l%ѯ. L`$; '- .Ht(!E7/,l#-sl3mRji',F,*+rg&R+ֲx*y8Bf [\&*Y98,p)Aų %b-af1*:c.{{y 0-cdBzJ>/@z %1%{%mxд g๴Ѡ/j$~1`y$$* 6= -='=2 ;J (*@_H*?"2Px0   @2=h&c % x&-hк%a>% c + 8`1(H@0TYP=Q/ȡz@. [>`@WIi7F*0TjLpF9 hV?AC"PhFыU B!SUFU }E1! l`u0jXP T`0 t@?G n{< Q WPg`P ? 2$ʢ<ʤ\ʦ|! , H*\ȰÇ#JHŋ3jȱǏ CIɓ(S\ɲ˗0cʜI͛8sɳϟ@ Jѣ?|a$\<|*W>N@C 3}1ph ؓfp30mٴg˕G_$ΟÈVOq#C\@E‰/B:3&ic @k5|uU(@(  Q)Z588|vD[tc< ֯f"C_-իUIRz94:Pr\Q4@U F?LsK ABx] ]d  deC!6[b@?Eq 1!a!B#O@0d&TJGC5 2P@V6/QDȍ]6BH e^qaĀ m6"ue!A4BC>Chp'hL(p߈Q dN8< S/j*ܟ}ž/DàfaCW+EkfX697n8DLʪՎq$ %cmJ)p[04"d@ CaKPI+,*Ɔ&JC Sp$M0./KH(+X oz2H k2ʱ:3HbӰ/ 4͢C!ip"OM LIոCa4o6 d ^M"i.gG7,#Tj:'r?Q/E~]PYG88d@ 9BV¶F_`P ,,H!v*v`@8D]>rHO<*(C>("ꁃ͑p$g3m$h vXpȢILz@ Pbx2)̆h %`8H`XP1fp#$yq%p "081.@`%)' bݻK~q<oX>A%L (GIRL*WV򕰌,gIZ̥.w^ 0IbL2f:Ќ4IjZ̦6nz# R4A; RdB#^+&7)Z,4<> KEa2aU@a }c#` *4$"F-& x Q cY:"@Apj(CK@`z<11cFbaa[$ ( @UD PjJ!z53@Cz91|^FM OK@Uq (㭧t@l8 WIF@RM.0h YؠNU%ay(]Dls`T" eHI 'XP&UsY?R!L7)դ $JnRcb[4>ZLZ Q19&UE` >kFGpI1t &0ẍlt:zQ|Jsm ~ubp0 ~-$t,EIz()ėdň! R#//IP):VlHeC)fE$TD N(V0$[THAJlME'"^T6ސ"P`AEpn>e&C딍D)n_û$w&}o3XꒌcʆR|{y­0oR6ĐeH0GER`qg&(|5@"*b$?uBEqr$(BvU7,_+v|%|3 2lwxtDpv$Ux}!CKVIeT\disVT9gō%vp1؉4T? +p%+21cuJ_C*@~b:VRΧj.qE KL`".a}S&D/VPJPT} 1@bu)~ϴe: ]Rשy3)@`cB;'2) >_4`|Rr33~@ ,6v. X&0gP7 $>PZe 8pP\$ hpB1('` ӓ\MB2YI7:PmߠW0h< Pg` qvR<:d!P:@!U1 ?` WCaAA CY8_@,ucQ@k2F70Vq4G :GqQG DGGGk "8Ae|` C{H O D (h)#Ԏ8Xx؏9Yy ِ9Yy)K 0# W0O  XdIII;D(P0 ~@R7 I CL` :ɓl( +IGzGA T-e&oT4p8 @(pSuhoQ`$FOU0 )`e xc՗z8DN!!E-xUhVz.~Uaz0 o9T¡ >b70@X ]e+)GBWI(By[VBhWAVA&$8q҅cP\4A r`)@$a &h]\4? #0RЂ1Q{F(%>? 9 *a^;#*8 H|^/!S=Ӂe/wp,|އ<b`1'*рb:-!}G,:aa"b,;7%q:z2e9sਃ3#. (1%jXxവ g.Q$Q`y$%* :6= -k- <11"!&2* (,!!"!gм*T' % &+h p7]rSA # 8 4Fh@nYP=Q) /a<̡!eH^+a. k>[KI U(0T LpFdĉvaD3%ppT%X L0(S)pTNlsU mPSy6@@t 6\;P vǓn`pqa pvP E@ 4ɞɠʢ<ʤ\! , H*\ȰÇ#JHŋ3jȱǏ CIɓ(S\ɲ˗0cʜI͛8sɳϟ@ Jѣ#lm$\ qJC ;A%h ͎p#0mh˕G@DPΟÈChq##\@ĘRaL!d41z09_ z 6=F] Z&AmcIjzH 2(S,SzuL:yCÃԝ藗Cs/'4~U H@A1] ]D Z[9|N6TauxQ4 xrH2"NQ oc pG84r#zT2cM@0w' )tdiʼXyefH!6~Yr@zY]=. "EvNrl}ζƎ-LyY)= R79@R/ejj\Jv(ajiFU0ټZZ sZbQԠ+I<+b߸aQ.#ǎGw$A"MeF"-X EjX(þG+cϻA]8P8 FfG(0Yk H>`2pc*y@VQ~Q=k/[J ǁ"P_)mCIgtm"ǻ1 v&TRft"YAk~W6D]8@E,^# dPNV[Iplb! %Ih6zPtH[T(IjpmM+%D6T#Lq3*\3EnsP6 ݧGv" p7$@  ̛`妈 nl$!,CW"9*Ҍ 0F w&P H;6^ .~*j :I5 4*rw(C_t(/âRo&_] Eu$tUBdtYٲ'd\.J[9\"_֙U ab rܫoL!#0x Apj ,sf 淒2X>Uw#8oAzUYbGSw鏞&0UPxJ@=Q} 1@^u&rD{: _R )I3ybQ@`L(Pw`-)!SsP(@_3a_RrP32p_?R! b'`c`nR/r6 x~0N"e&߀7e& X B*(' Nrؗp[c@YG:Pߠ~vg< g` q{R;:R?B .n% 9` ^zG0CPR4U&[@fGciT `cA ` vFTj @sxuinA7Ae|` C{ho `|pI (h)# t蘎긎؎8Xx؏9Yy ِ9 0# WN H4IPII9D(P0 ~Q7 ICL` 6l( +FtyF{ASxhT4j( 1]GKYE$ 0 '`,$ r[5Uz9QDG D-xO$t`z0 h3 8`?20@X W"c P16<& d| 6A u%u6Qh!dQp{NR8qUcP\4A rpr@$a '4%E'r]!y`1Qw'%>S}#1~=h}2`^~/~<J qd0W+b<rt$$*~g+<+Q:Sa*Pz4'9%AP ./pa (A%jYx * E*?22 5r-{'Ƞ$2"!ׁ p((.k@!!#!ׁ(R /f % %)h%=% Pc + 8`4h@dXP=Q LCݵ L P5 V8<vJu2 a<8A Es_Ea\(2%c X L0(iW,}E1! lu0NwXP T`0 t@j2!0E;dvPԆ$L8h( !Pl8\.i7hjZ+fhqƌyMuLV('!FieqC)J4x)aF8aQ4Ҝ:7jҡZGC%a([X;R(;w@RQJ8+R jk2͐mH\zjTfCʺ ]b*HѼ~J;p00p6 @ J6k6ahQ~#raFb/Q4ˮn+4T$ϟqe"4bhLgD~E{:ed C*rmxiE9*4kʂ#}XfI Z)yT9ly4.1S|@M>ȵw"RF/rx!0T>cO;> UI+rpdq -2dv:A0 OBl ʠ>"DP=<1i4Uiּby8J H40l8 AʍAZ1ȦEk#Њ\g|6Ѡ銆CXj): FRUZгC00$HpJ`+`lP8"Gbe ! (؆$R\ZE" .%Rԩ^/]" 0"RY)b,99*} Pd'L"; I Μ Eڊc(+X$uwG$Y7i}9vK :5Z3"+8bb\k)d7/P %'H]u .6StrSP>&y# ,mp3O />&yhv@_Pq 6y (&0 %>0Xv ߀ % gQb> {,V'( F p߳` qo\$%1 +` QT_@dCQPs1Dpk0lZE@FE DUӖ|`ئmCFa <0 f)#؉8Xx؊8Xx؋8XxȘʸ، #0pt(P~GGHu dH:D(P0 ~p\7 IH0(mx0FetFYFA2A]%Q4N( @(PyC8ID,t0 `) j .P8ICJC+t!8!84 f!RBW870@H jf"cLpkVAd/&XPd/hXW0@SR@&v%p 2sge[2PPe4A W?$a 'w5&TF>"yЁ1Q(e&== (c]w##1<)7Ɩh Q@(“[o0Q!Eݗ]IRI39@P_w*)ѰQ-!~ɓZ9ak%k}쉕"9CB,z,iMC8s<p")* 6n*$Qz"7Aw!F_&qX" {X2jq*!XW2G84$0r҂9) U-JSa|&Qc\x7<0>cXO&Ds-T(K$1]*&~2&!g%@'QwBGt%Qm:%t&=G,+CvJiu1{p'uj:R^govC'UZ|buzM js_S$1Z$&U3bvڢ`ur1J0Bw'iAG#nxpj+`7V'-LZ-JN`x,'!,]c,gz)Gr(A+U+a5Jِ*!Y'Ē*+ !s+P'a)u9"|)!(PwB,{YQp"zF+7&A!o-!nQ-%!zg_Pc"p$zJr$ ks-z".#œqiy#)(e0_#-R[ ٜ1q"!f )Q)y y"!#!) P]3.R(heq=% Pc]O;֡ۡ$`7 rq/0jj2!0E;dvPԆ$L8h( !Pl8\.i7hjZ+fhqƌyMuLV('!FieqC)J4x)aF8aQ4Ҝ:7jҡZGC%a([X;R(;w@RQJ8+R jk2͐mH\zjTfCʺ ]b*HѼ~J;O60OL@, 9 G8s F2`D$E M7A{`.∗6%yJ99 H5%,BMeqB,8-o %/}zʢ#2L*_5`驲qϗ+ V6J`ui]FO0{a-1\JBDY_yU`IjBg*aq +NTDpVW!Gr G><Y$ƠIvT!Ђ:K\.,-d, 2.V&}PHϠZf q e/d͔p:ڔ!A`eL#QG`vD!k$C`yB+ͯ~LN#F&HĬqRPx&APD`g- !nr0 0l"P 1$\@"P[ $ f&1ӆE _ܚ)w* $Af| 0\!px=I&<*#_60 :0|%# Lp#*Iώf\ : 0lA!Y:;R뀻uevDr_.(} z4`3( <>&yl`Z&R`% PPrb`j&>0߀ % g`hE |$z 0P mU` q`[d &1 ;x` ^Pdzݱd4UB'[@74N "FONP *b$P9  0O h o }0 ؋8XxȘʸ،8Xxؘڸ؍8Xx蘎긎؎ #0pS(psFQQ %R:D(P0 ~c7 IT0(myOOPA1Ad( U@ @D0QEHMt0 p) d NcpP91Lt cLt!etXz0 tzcKi70@X "c /78BIV`6A @Z}hWYE!'QkSR@57q%c6;xԀ"a 'qx5)&FcrF"yЂ1Q'(& EBE (#|(A_`r0mul@BH0b@W)Ar*1%y*0+~F x*z -Id?m7+A@}ʄ>Wz 7+1Jփ=s94$rT5?TBHzxyr(`pt6'A7s7C0K$1r'~p3&!'@'1xOu$Q&٠u&JG,M#wJ|b {p4t:SQkWopw'E'1@|z0'%ـ&Ң0zRgYC0S `J1{-(.3#./竊%$`߲E2.gqj+>b pSzB@b2@y,'!,c,)s2wSbp Iz%,r:1'*.zp -p=hAB)(C,@qwB,{ j|"{7 S+ ϱ-nYbW&>orWգ vX-aA$3Kpt-a{.#Rf/Cn-22-dfp0y"!ƛ )QRе(>2|bR@*͡&J$>R08hQ!D% p{c `9# 8p2aX@lfhP\p!6+mn3 b8p=a51 PV8PJB `]`gF!4%X;@]cep* mPS7@@t LP Xœn`Uqa pvP E@ HȂ<Ȅ\Ȇ|Ȉ ! , H*\ȰÇ#JHŋ3jȱǏ CIɓ(S\ɲ˗0cʜI͛8sɳϟ@ Jѣ$!drZBJ㫥MFEDNwK@c,P-9MUcY&DlLlTL14\dW`pdа.f& z([M   4cU% }Y m[5Уkp, q~N_3%F{Pw^ͅL[~LHݱ= zύQ #}`B9W~@C|$K  Pt`ЅHbv `w`=ϩHȣqc1]Nhkف"pSBYX)^9N` &I#^gBGp])WTY&RIhʼQfEm3UrA!z&jYBht>3壅 .tj7hAV悟0QK'*Z^ J79dɭjaKan߈*L  T2"iK`LZ;-q/ [NX4,EIJw6jL6Kޏc4!gJ|=m6/E^1 il KjM kA6F`Cx/&>ċtl@"_ġLbŗaڰ8 bs/* $| /#pxX;I&4JjZcۂ.j 9@S~@(w"'RF/ڋUJ29캼h _x0bx50v}"O{Ǚ",8A"*"ջP X5,/H0">q^2{Pc#Ӊr ljq3DwCfT\d`ϋl"!N9`2$Vl P/2"w T^G@__F0p ?(}+@urr^y4-_} _~d, 9>)RlS;e%0 "6z m0`! 8`_N ! hYpe F'ߐ7hz"J_0P,* 7_B .z)ACL` ( +GA5TESGMA`d1XQ4] @(P'igG"ERt0 `) e$ BZN9P '*xP'b& @n<:fʦ w;aOJed#qprEH w5$c 016&c@Ni i #(26qД(dQpkRP;7q(cF 0"Ѐ",2Wj4Jy"$.R0Qd,&IIi,w%1H;`tZhQok{ @ٱo0QTxFÆ~kI-4IT1+~E 0E),qDI2@}ږ"cIR *pdLpFD_F!W4%0X+0(#\%,}G! lu0͇XP T`0 t@`G nPG< Q {JWPg`P? 9CXh\lnprèoz 2 ao1K j]R:xtAUN.}CQCK@0+XQ4x7*C ,!0*GFtѺ_J@[r!Px84ǮΕͯ)1B0.+*Ր b}J^ M*=0֑k7^2&Bn(DJ<`NJ(dsDEHCSH' kEAm'գ]҉|늊 )6~f6C50\$RtG 2 s(x$[zW" ߡWֹ=WX' κWa7ϗheC}}^`-[6{BE.u@"< -z@p7 EͤbQnĸL~ 3*. ^B~1#N` 2ŽEόf.(?_'g{}*a#ߖˋܢ=yUzV:\7h`K"Jۧ1E_T+ 7>/Bl2@TP@| 6z (0 6>0d!߀G' Ldg_`hɔg:Pߠ_0&PN* G0_B .zp(APHaDdU=[@7P1b`aQ! $P9  0O @ <0 h)#/8Xx؋8XxȘʸ،8Xxؘڸ؍^ 0# W戎 2WZEZ@[E@oU9vxV>CL` Yl( +6YK([JdU@ }Dta +0lW5i"[ fk "0V8eh :YjVValzfov"1 ngWuoUd(M5 ?$ 0/3HFJTLAA wXWRnR&v'p \#g@jwbF -!"a *Wj4Oy"$К )(pj2q+;@R *a.7,_ B9xk I- +~TJ,~$)/)}I (/z -~Hm.,C }*aG2z>+J P5)F jtSM;fG5w:)zAPt$1Sc9j+_b 7$S8yS7osC7G)r(5߃Ja+j!ِ*4T'&4D!+8c1 -/3d +f*8װ%doi0zf(1 C1n,aoӟ+Eїno-nm>+BI=2-!F A'/-qG22i0*;s/s0 " x*@ lrX+ P(z("!gP*(F'% H&+aK;SbX"M% @cP + 84#$H@hvnQUᘅmVA"&r"P8k K 3 @!8W B v  K$SpFcy14% Q!X0(4y!QAUs! lXu0XP T`0 t@g nM< Q WWPg`P`rC.8LuIpT:10+bC!Q.T#0zh@g@U<>- KNn6aL:'Y3HX- iөE`!3оKFi`pKOE¢K~ `j3SG܂ tIH/Ȅ[nP_")F pXRp[-9bƠDoD^ (#cЃ/ĀbG+գSS^$a/bbaP[ӡup 0 25Jj CC 1N9&GZLt;-jiAx;$0",w9hyV B&i锅,d} ` yLdL"IzMMb ?nGBq *S(-A(9 N'K0P~a)d R?yTyi"ty\P;=x K%Gy 4hj<8(C-2ed%Aƞop`tD#fo`… AA F SW}ǯo#0ٺ܊ѠHi.O%rFׁnbcøa;&SHF&"@j@Zeu/U9CExd|f/kxك2v(#$,yA"B? ;ʹ Hd(/=H$?1[2PwGrqoQ1n|]ƞP1upo5\$y 7[ߘBI!I^¶7{\0Rd^Z'XTmyZ}0ںc AKWA@huD:Do[|D}Õ2OOSP@gq jҾ@*rLZ4QTo@~~i 8 \P^ |5 kCL` ٸl( +]Uac [3IH D. +1TEE7[ j@ "8bhh I.SF4>x`RF 0k:cFtQE&RSq`jc`x pC18QQQgFoxA @=LWOOPE%p g@g^2PgpA r t  {UtgpANd` pgnq(D;R Saw2*4_`~ }ah`h+cQ}'  ilcq('Xg{TI1o39@`C8g,TѰ}Ƣg-r.TIdF.FzTG$/zUiB#fFH30+`j `Q,"x, g+1a++2ai(߰fca**#-C@Im}{b)6v c'eqiA(B!W&};0#t a4O&o20 hK[pU1%z%D@B\T F8p$:$8@2d0CQ)"; p !6I! TPahvLKpFs-K1sp%!bX K0([,}4amPSPy@@t NP "L“n@ta pvP E@ jXDHJLNPl! , H*\ȰÄjHŋ{ȱǏ CI$A8"a\ɲ$&cʜI͏R}kɓ|7 J\$PJBRX-AׯAI,o:]˶f2*Ӷ]I;O=b޽^8(ơ׸rC#,6hZ,odތ툒4BX1MFrS [zoX8l:ݺEy2!dSglTLКelY&Kuj3Iw80`W/gVU5:UPU$ Kw{#uПYeS[4qS }kx7&O9/;~i([FDEcB;N3F;5N6]U]@ Q B}`ReSO^=BfL"@Wt@g1tTlvY!^'=eSDW6 Z*؄A]!T(-3UQpBYJQ9 t˨b SHG0S47,Nu+,}ST `lʼ#ӦNńQkesTZtS-(."&i`.."f -Q!k:*LK79HUC̮Q׮?߈@*|% AJ &O4 QhNTD7DEHH2H &2T/"!̬PPn$ KPEĐU:KTq [D%oV$ Q Q(G+!9ِ2g yQ]B4d m; $ZSҶ?>KsQ6}D tG JU(.+Xyax'U iGdWG%Ӷ/h.}6pY,m҈FHA@fl- y%y8r!!bix[*"C H(:–uX{-G<uH.!]mя2rpx!kKh1m$288w İ._`FPN0]`Hg:88 @0#8y]ࠁe10`D2!5qzLbGpqD7dRȖSRI˂ y*fR.Ff b-k-an2ɟa: % *LdLB]BkB[G7DaP`2 \'-iOK xgdN |86I 3h>a_zxJȖA `Dy)t;0A x(&5Қ#1)E>ʁ}8X|8(ȬiM & 'E d  l 0lNSH"|bl *GpeW@`z;mX0 h.E @H0n h\ 42V! OX"cPFV\tlH0"=&\8MP))"rGf lJO@DPfgH#!dѤ&޶ lLKYO8$^}6K;Fi Nyrh4no Hz$Ĩc/f@UIR1Ӻ=LDn.7[@IAвlO$Huih.&B?;D;ӭ+ dd 0v۽B Y[ Aܒ _K#VAfܷMF$n~ëݤpK,Hƅ[}e0yˌw[}RIW=j@l9`:Xn :`!`ێ[Z4)\(^Z$XX-ݦ;#ແQng}'{2*F6.,D~ ^-)x[H].Xi0ʷ }КY;2kyX>I4suu#L 8$p]|q VM{7/_&W(>0\\PQ#g&mg\pe yE@{ V͕ n` qpmPD%|1 ` ^ Hz`BB]_@QQ0qkDpc0 E]fTk "*vAe|` C{Y] o}0w^Xx؈8Xx؉8Xx؊8Xx #0pX(`|XEXw XD(P0 ~`7 I`0(mLPVgVVAMa+FU@ DF. +TNH7[ c@ "p8bYh 7SqS-xR pk:c|PxQRd*%60@uX PSc _h cQf8 vA `!eLhWP PE#p sgpgt x}Aa P'H0uo$sԗOA~kQ'@*M"N6I*K)|*j1}BM4'+d ,_}vL3,pR{K2%cApD,0SyJ& ,z ]1|ÉBty%j.T ,xCP,@pY MqoA3KM3sF9m9VuP#0rCQ-8aǪe]J+ b `xã|젫vF!g2ocf#ipw?#PwG5C:X# e 0!4#jC2F3:q0Rj 2m @- Gd S;mU3G"C.ZTQJ< 1 S٠G:9a.l.z]Q.k1J*cP+^ @E-2xp< ,,I葜kf_**3-p9WUIw)*@Pi;)  (|R(B!$w;B] pak*oBPcKT;@S1%j%D:P>#=b @2CcQ6T4qm%`"aXJ0([+}4~Vu0ZXP T`0 t@нܴ n< Q SjPWPg`PAK7ø8D\F|HJĕ! , H*\ȰÇ#JHŋ3jȱǏ CIɓ(S\ɲ˗0cʜI͛8sɳϟ@f Qt0MJٙJjrK.e:Ɵ?pmmCcETh`Az [^uʷo_pPRWnQFN+p!ve?mnL[M+8x~MȖ%Z-oNX6˄Mc)S1pP D^>ۀ2ApMrThX3=֙ 4~<|>k(~* $aB~#<]I9 JziرRRH8a_4CN8Q$` (Q^7]!Lj4gbg4#7=V  \;$<:7@DZ0&Te_D9Sg2}ЁmC"e~U`Lv4r t a|!LJ/1 ˍ LQ<Y`:Z9 @xL.H}cBGp"XR+1J 2p䨰~AK ?,!*cŵQ2U`(+1^}Ft `JX V ʶM+ k7"`2{5 ƚK )¯cHQ0C;) G| JlE# .j WD1aQ+y )M4q3B4x(M 'ucIE=M4%gI)ޱT4872Jnq6DdJE08)~Ko6}tI(E}Jbn6Ċw'=!;(a;zkI($ -S^DDAI `m@SdJ$7>ξG+~J7^hzВ("]~*Z% $8ra!hxJ|P`&!(\: RЇJ3Y<"Ř$AsP!Bj *P[EU2pH%a.|!XF1`u+Ʉ|1T yb'{ ի xFʨ3V7^8):.Eo\&e<Ɩ*:R'Zi (K0 ZU I0yƀ )nQbfVQL2i@XvT,TDOcP0eI MZ(@HHpkZ IcƜDd*XP$N&`A\d`$D,,Q( $:;uUxW vd ,/lOA I r#8m vrtTPJ fJ cPZ0Ȣ:J $ R)pa%8x(fQZXF~ CMeR#%ʞAeZ>0AM(UH th6 c&XA"UX (x Y4(,kY` 4 ) 0>L4/@0G6%Mr:ЍtKZͮvz xKMz|Kͯ~LN;'L [pF~h#hHᅸ h)B<M&C nSf+X3h 0ZlM8SH"\a`b Xf8K@`LP%@ޘ48^UIäAU!^R OtXȀLHKFMq3)%*]( t@>.0+v"B$x5wᎭ҂d%A .Ҍrgodt"í0`Ep #.)Mgp9Zly~G {:FC&!"@x"`oX@v`پ0)8!߀ .' %p``ig }&p Z `rڴ` q@qc!`B .P{)Axz0Ra_@PQQt2Dp!cQ `bZ U$P9  0O ` <0 h)#Ђ?8XxȘʸ،8Xxؘڸ؍8Xx蘎긎؎8 0# W [%[ \ĥ@oUIvX>CL` yl( +Аi%Z%\AeQ6,[Q4m @(P99 . +`@aXH5[ aR| "P<8f ‡h LhVSV x,x U-O o<:gqx;T1Le#qO870@H R$c P0E%cS3jS(Y 16q&eQ`lmR>7qMP k4A ;2O(Kb#W|k4N?"$* ;(pk2qD+;@R )a xl(,_0bI1ht+T|AK9 @pcGI169@F"рcE*GV}jD9/sY$` F_$6cd}W2`ޒ*. Ф&,[x@̡ RG.+$,זEB$= )tk-Hġ2(!V ,!)z0'r'#!P))W&% 0h BBGN# c; l (+% %pg=Q!æ K" K>"sI0k1*eL:%pF x?!Cء1PCiU R!S1aU }0h11 mPSЫ6@@t P LKQ YWPg`Ps!r`'RX!-<,X^u6. E:gôIw,W>6˄-3kM)_eͨ9@U4\d6lThX3=k Bt8|pp^l ZCpǨBBK&dƎ ?_e3 Ql@O{YY@}}<-7dG%7&H_9/"Df쀓; 8"vqAIјΊ7H{0` cv0&3{HB.9 >AJJ\ ]@Z W!4Yf$L6dpxgITv:J0EQgc pGdѧs4S+,u# W EÁ~K7;љsPx z:-DȠ!GWQ"k[,1}F HԢfk*\qEj䰒; +)88N(F(f(T' YSԋ!F;pĺSFJ4XܯNX4 k*`/OoʙeEA3f ^(C;E ?<]E&QcIE=8-4D;"I)MT4;72JOr6 dADMs6$ gI<0rRT '?PBgy~r{}`I.?Mrx(y'%yL9 H(i:o rM}kB[MDM Y4q &XPP$N0&-pW4T6 Lh1MZZ$$P5wtk0%y8o-K S8cB%v 'Lcrr6cǚLR(;cCȓH5/|Г3H2L>^@f XpLi'@ vr0i0p& p'UD5 !ɟ@S%q+AjQ5^@#L_`[<2Gy#hQխ.@2`S `!|"W  ,"R8oVDPPKͭnw pKMr:ЍtKZͮvz xKMz|Kͯ~L/@1mdM w\AM Gp( "` uKp  9`ض6 Cx`0@WLy|fP>3*xD[ci"_P2Lt@Hl ` tH`P O<8K MJ@Kh~PN4%t%) V" i t@(o%`0+X B$3~@v¨<'ga{no ,Qz3 dFFcH/"T-vQ#W_DH =$kwbY+m f`=0-d# cD 8B8ȂfcP6,H3zZ ^ʾω| H)8 nguC5=m}iF$i(!zRd\{6߱ \zq_r t]`Xԫ_0=."9*‚S Po)L"{K ? ߑEB&'+%C}C$;ä"g0y :2p\{Np1T1e']{q)dg#/ˬ-u2[6mt>e>MR w # &^ʏT_+ 6>p,ML`+ P`}N (0 6>0D#_P} $g_`ha P|Vߠ_0P0L) =_B :ur1 /` ^ dzR`_@WuQQs0DpVcpmU `a@XV`m 1T$P9  0O  <0 h)#`.v؊8Ci pGaAf*! (׌1`X BCg,aF& ׈$q&c]$a )Fi"Fb ,R@f 9?T 8$  .Q )s~' 5nG-{4 !3 .2po>!4WU 41n0UE9')IQY(p\)|03c9C} #k9,8:t f:@li'~Y{. #0p ۠?B5:ZZ[@oU8vh r8UXVC([LfQU@ 03n4`_VN 0 `z) $ -p& AU#bTb1#&>C7&CRV)60@2p.,P0h s2-q&dQ0oR@=2  ʀ<0A rp; ()x1!rp2CbM")) #5RK"L  ,:,0k.J, i1:& @#ҝ'Q~ I 0R([3Ц(zO*00 '`ƹfFm2Rb#/X)YPmhCTDGt@o(*o.qu))Ql{*%%(f(,o:_8+q@l4~^%%#!2H(ER-h^(r-J% `rc_rb%f6s_ap` "~э!+d/sL)`!P42%VAU  V! lpXu0;kXP T`0 t@xa pvP E@ H5~9d\f|hjl! , H*\ȰÇ#JHŋ3jȱǏ CIɓ(S\ɲ˗0cʜI͛8sɳϟ@ Jѣ$!drZB`'RX!-<,X^u6. E:gôIw,W>6˄-3kM)_eͨ9@U4\d6lThX3=k Bt8|pp^l ZCpǨBBK&dƎ ?_e3 Ql@O{YY@}}<-7dG%7&H_9/"Df쀓; 8"vqAIјΊ7H{0` cv0&3{HB.9 >AJJ\ ]@Z W!4Yf$L6dpxgITv:J0EQgc pGdѧs4S+,u# W EÁ~K7;љsPx z:-DȠ!GWQ"k[,1}F HԢfk*\qEj䰒; +)88N(F(f(T' YSԋ!F;pĺSFJ4XܯNX4 k*`/OoʙeEA3f ^(C;E ?<]E&QcIE=8-4D;"I)MT4;72JOr6 dADMs6$ gI<0rRT '?PBgy~r{}`I.?Mrx(y'%yL9 H(i:o rM}kB[MDM Y4q &XPP$N0&-pW4T6 Lh1MZZ$$P5wtk0%y8o-K S8cB%v 'Lcrr6cǚLR(;cCȓH5/|Г3H2L>^@f XpLi'@ vr0i0p& p'UD5 !ɟ@S%q+AjQ5^@#L_`[<2Gy#hQխ.@2`S `!|"W  ,"R8oVDPPKͭnw pKMr:ЍtKZͮvz xKMz|Kͯ~L/@1mdM w\AM Gp( "` uKp  9`ض6 Cx`0@WLy|fP>3*xD[ci"_P2Lt@Hl ` tH`P O<8K MJ@Kh~PN4%t%) V" i t@(o%`0+X B$3~@v¨<'ga{no ,Qz3 dFFcH/"T-vQ#W_DH =$kwbY+m f`=0-d# cD 8B8ȂfcP6,H3zZ ^ʾω| H)8 nguC5=m}iF$i(!zRd\{6߱ \zq_r t]`Xԫ_0=."9*‚S Po)L"{{ 7A{B ? ߑEBdq+/.H`r"?LAppnSk5!C}C$kxA S&9W13@y :2p\;&PR;ecŜ+RŔ@v2Q & qSpFb 3 C(YJ0,!G#.Prd: 20߀m~2Q$+F%/m27D-P,,PQ-CH-'TJJ8Q,qIc,уG͒_.ilN'6>p,M1.0a/L`+ PN1~!0pR@"`+BO3 40xP} @$7|K/3a &ߐGh` X2DD3 3jÔ` qpځ`0a}46B :ur1 ` 2a!6QoW_@WuQQs0DpVcpmU `a3 L 4N@XV`m 1T$P9  0O 5 4.А6 <0 i)#')vb(13(ـ<6iƓ:}Гۓ} ,#$8HI|@v #ה!78TU:J!P&  #4H 3= nE4p-3Fu>pa.)+9/‘y}Ԙr02FIPΒ2ѠN2l?KΔ ٚ() CX- drc4$aooh`;IAe. ;!@ m@(}PVn4B- J@y  H&:Zz #0pٔZeY5[:D(P0 ~c7 I UXV[LfQU@ UaTƗ[ 1H݈ "TC"Fń+x~Ȧ peRR,60@I_%Q(Qvhu&dQhR@=E r4 _(0g_҄;bM"uk;Τm^N_GIMao ɩI 0貘r ].z eFhmEE2^CTDGt`'RX!-<,X^u6. E:gôIw,W>6˄-3kM)_eͨ9@U4\d6lThX3=k Bt8|pp^l ZCpǨBBK&dƎ ?_e3 Ql@O{YY@}}<-7dG%7&H_9/"Df쀓; 8"vqAIјΊ7H{0` cv0&3{HB.9 >AJJ\ ]@Z W!4Yf$L6dpxgITv:J0EQgc pGdѧs4S+,u# W EÁ~K7;љsPx z:-DȠ!GWQ"k[,1}F HԢfk*\qEj䰒; +)88N(F(f(T' YSԋ!F;pĺSFJ4XܯNX4 k*`/OoʙeEA3f ^(C;E ?<]E&QcIE=8-4D;"I)MT4;72JOr6 dADMs6$ gI<0rRT '?PBgy~r{}`I.?Mrx(y'%yL9 H(i:o rM}kB[MDM Y4q &XPP$N0&-pW4T6 Lh1MZZ$$P5wtk0%y8o-K S8cB%v 'Lcrr6cǚLR(;cCȓH5/|Г3H2L>^@f XpLi'@ vr0i0p& p'UD5 !ɟ@S%q+AjQ5^@#L_`[<2Gy#hQխ.@2`S `!|"W  ,"R8oVDPPKͭnw pKMr:ЍtKZͮvz xKMz|Kͯ~L/@1mdM w\AM Gp( "` uKp  9`ض6 Cx`0@WLy|fP>3*xD[ci"_P2Lt@Hl ` tH`P O<8K MJ@Kh~PN4%t%) V" i t@(o%`0+X B$3~@v¨<'ga{no ,Qz3 dFFcH  Oxj P;Ҩ ~4P6x~@ r`'%F!Y+'Xd 4DN6(F "8w욬8 9~h`K1jrd|GcEZJJ dA1(.yUmY Wf{GhKPt00Y@R}NwzU5Ʉ+ήVd h0R+%ۙ.rݐ/~,ё``ZB#&%g`s4#QP%>_`$"@f"͆z]OTbn|$zqo9Ku)4>1B%0o8h@)=SS)GQ g O-@"% QRKP44q,D2̷#tB 4` ?x;Wd`sv@a}(1(pZ%1CIC y;S(15'(r;cp47{&Ch'0){ 30gQE)sƓ:dwuSpFb|*8),!.@srdS& )- x.lh eh~XID-P'!c*.'TJrF+~,xN-107+ '>p,M.qx,LPzPPN0w~- ˸(0 6>0D#$B\/' AhYp3/C/a &DžV0ߠ%'r3 jÔ` q`sځc2!.WB :E#(ABrʧN6pUuU[@7@n5V fݷL@XVm H$P9  0O '2"{> <0 hi)#zXb/ჽVyWjt7Kv)fGKwW) PW6E7r/l2 q@r pGpVtp@nV6o֛<  ֆmmf9Yyؙٝ9Yy虞깞ٞ9 #0pYZeY5[:D(P0 ~c7 IUXV)[LfQU@ UaT-[ 1H "TC"FE+xS0 0eR94+Ś!qncPi&Z_WOhO&d'p ԃ_ I#`"a )r%Mq#$p 8_K"L  ,V^,_@qGIh1r I 0R(z^z.x@^fFVE0z5DEtD<2^:6tT y2.B;# EvI녌INAq3^^@nG\?>B?`Cz^p=)yw^ y)`'RX!-<,X^u6. E:gôIw,W>6˄-3kM)_eͨ9@U4\d6lThX3=k Bt8|pp^l ZCpǨBBK&dƎ ?_e3 Ql@O{YY@}}<-7dG%7&H_9/"Df쀓; 8"vqAIјΊ7H{0` cv0&3{HB.9 >AJJ\ ]@Z W!4Yf$L6dpxgITv:J0EQgc pGdѧs4S+,u# W EÁ~K7;љsPx z:-DȠ!GWQ"k[,1}F HԢfk*\qEj䰒; +)88N(F(f(T' YSԋ!F;pĺSFJ4XܯNX4 k*`/OoʙeEA3f ^(C;E ?<]E&QcIE=8-4D;"I)MT4;72JOr6 dADMs6$ gI<0rRT '?PBgy~r{}`I.?Mrx(y'%yL9 H(i:o rM}kB[MDM Y4q &XPP$N0&-pW4T6 Lh1MZZ$$P5wtk0%y8o-K S8cB%v 'Lcrr6cǚLR(;cCȓH5/|Г3H2L>^@f XpLi'@ vr0i0p& p'UD5 !ɟ@S%q+AjQ5^@#L_`[<2Gy#hQխ.@2`S `!|"W  ,"R8oVDPPKͭnw pKMr:ЍtKZͮvz xKMz|Kͯ~L/@1mdM t'4x h`ں h) <ML&C nSíX3hyl6OuփhB'A ~;\P Ȣ{(3 sxB 4` )3=dzWwKr)!Wyu(!@ qy De`;3vewv cp4)3u+0z71*u 30jQE)sS+ySpFb `|R(ÇB#-u/'@&-F-[Ax/Bqu,u {I5tȒ(rHD-'0Q-#y1GR$ &'+ >p,M4 ,K4 8!d$KR1 (0 >0" uH!&)P} $͖g%ka #ߠV9s0P0L) VS CE#x0=!St)Aa!CUa BpUuU[@7@n5V '6 TdI=@XV0m 1T$P9  0O H#) Րo x )i)# ֞9Yyٟ:Zz ڠ:Zzڡ  #0p)ZeY5[:D(P0 ~d7 IUXYY)[mfQU@ ƘoRm[ 1H "Ts9"FqzI_&*R*j60@i_%Q(Qk u&dQy ԃ_ 1I#!a 6/P%M#$RВu"pf;pmR 쵎J _1i_ˈ.߰HaW?ՋG .z eFhsǸI>^/X))^CTDGtB?`Z^=&1~g^ })<H3{;;97^;H8(;C{^E|D&cnz^;"(47O#80^#4pb+)L55kK(E^G#AKSIz53Pa˪!p~2xZ^x C^0S #Ee.).F}t-ˬj^2>(*.xpgHr*%P%(g(/0*S5'!F7 X2+\22*^ R$% ٓ&0""T67 H!Y@k^ `a "u _I`y +f/sLE`!Ptf3%VU V! lpXu0G{XP T`0 t@ya pvP E@ 5"pr`'RX!-<,X^u6. E:gôIw,W>6˄-3kM)_eͨ9@U4\d6lThX3=k Bt8|pp^l ZCpǨBBK&dƎ ?_e3 Ql@O{YY@}}<-7dG%7&H_9/"Df쀓; 8"vqAIјΊ7H{0` cv0&3{HB.9 >AJJ\ ]@Z W!4Yf$L6dpxgITv:J0EQgc pGdѧs4S+,u# W EÁ~K7;љsPx z:-DȠ!GWQ"k[,1}F HԢfk*\qEj䰒; +)88N(F(f(T' YSԋ!F;pĺSFJ4XܯNX4 k*`/OoʙeEA3f ^(C;E ?<]E&QcIE=8-4D;"I)MT4;72JOr6 dADMs6$ gI<0rRT '?PBgy~r{}`I.?Mrx(y'%yL9 H(i:o rM}kB[MDM Y4q &XPP$N0&-pW4T6 Lh1MZZ$$P5wtk0%y8o-K S8cB%v 'Lcrr6cǚLR(;cCȓH5/|Г3H2L>^@f XpLi'@ vr0i0p& p'UD5 !ɟ@S%q+AjQ5^@#L_`[<2Gy#hQխ.@2`S `!|"W  ,"R8oVDPPKͭnw pKMr:ЍtKZͮvz xKMz|Kͯ~<'`%B`mP^%\Z5Lo԰66ٗƔF"4Lb U&F $Fxhl#F&H(_W@Hh5AJp^3hyRͺ```a6DAdI\2F  4Fx X}NgT<&N9ӆEt&r PfQICBMmBItU6IḬ3<GHj=%)8|@$w"#RF/Xw ` "tvQ#W'Qh86I9"VMODݸ=xh_L bl(!% DH7=MC %p#pƠ|U6uH3>zX|{T6!+%N kH)^RJz3]!M_K2/* G約F@M$lO[J>$#6=m%4ў'$ g'9EPBC4{;,P9(tI (7h9.S='!`;&&03|x0mh퓁)C8s9ڃG2;P,Ё 0 cgEphBP4yP/WwqF, ePIz' '4IVp@1A,APwy %-T }(1CIC q| Yp)"O;cp4>,,0`$.A 3 vQE)sr{SpFbq2lh'F.,!.Rard2vooPߢvxd.^h  Q1I\D-P@3 0T&g(rHD-'0Q5q` 8QGR@f*! (t+ >p,M?!fPfWiR`+ PND! pG@ a`r6 '>0s_P} $_na @!p X lÔ` qwځ0h%S(P)Aa!>_FQ.DpVcpmea SbnVk "C5Ae|` p`O`ٙ  o}0Bٝ9Yy虞깞ٞ9Yyٟ:Zz ڠE #0p cZeY5[:D(P0 ~g7 I`g#2ApiQ4b_aYx[ 1H "ФT8"FU+xpb4Q pveRvR,70@9_ %Q(Q܈& "uq%dʸ(p ԃ_ ]#"a H/%M:$$+ Y)_K"L  ,q^,_bEIiAxU I3J^ֈ..ѐ^fFXww[w}啬E2z5DEtD2^6tTp1.B;# E{I#}EWD=&$|OEvO7@g,^#46+K|3bQ2ͳ4^Է3{#%::,(EGWsǥ9mBM^4C@)~&(*.u))s%%(&g(iD_'9+q@Ps?V^܀%%#!HphI(ERPci^(z-J% sc_҈%pk6s_a# "ѵ+i/sLXc!P!2%VU Y! lpXu06kXP T`0 t@ya pvP E@ 25^`b`'RX!-<,X^u6. E:gôIw,W>6˄-3kM)_eͨ9@U4\d6lThX3=k Bt8|pp^l ZCpǨBBK&dƎ ?_e3 Ql@O{YY@}}<-7dG%7&H_9/"Df쀓; 8"vqAIјΊ7H{0` cv0&3{HB.9 >AJJ\ ]@Z W!4Yf$L6dpxgITv:J0EQgc pGdѧs4S+,u# W EÁ~K7;љsPx z:-DȠ!GWQ"k[,1}F HԢfk*\qEj䰒; +)88N(F(f(T' YSԋ!F;pĺSFJ4XܯNX4 k*`/OoʙeEA3f ^(C;E ?<]E&QcIE=8-4D;"I)MT4;72JOr6 dADMs6$ gI<0rRT '?PBgy~r{}`I.?Mrx(y'%yL9 H(i:o rM}kB[MDM Y4q &XPP$N0&-pW4T6 Lh1MZZ$$P5wtk0%y8o-K S8cB%v 'Lcrr6cǚLR(;cCȓH5/|Г3H2L>^@f XpLi'@ vr0i0p& p'UD5 !ɟ@S%q+AjQ5^@#L_`[<2Gy#hQխ.@2`S `!|"W  ,"R8oVDPPKͭnw pKMr:ЍtKZͮvz xKMz|KVaV&<Pd$c %׿BjT&*xc![w8@)g`Ё9r8p 7#Ĵ$#ƨ=ZF4|8>Jӧrp}AJ(rBI!4c>T}```a6DAdIPTDdTq`GN ǖ)gڰ0HVP2Ltz@H\VlJ ƨ N:$a0(Ʉ'<4ed`V@&D!*3& {G ʉ$\T@w%M0Dx$P (64/D3 DdǎHikI+-#{r&oV>:K9@̖/uvV$Aq/"e%KTiUadqWJ9#$;! c,K5ƌbO$ꌑh`B;JҾ3ǥD 8B8ȂfcPE*Y7LN "pAN}{@?xDL kH)8_\Jz3]!MMԣSwFK$lO>)!'!' ;'1F34PB'A3oI|B=0;S*j c}fB,9) g0U`!(ǀ)h9.G`}B3g;)!LVwP):DZT* Bh'`9H-@ ,AEЅoBP4tbdpsBC\P0B4/sy {; 4` 0A Wd/`.APw0{ %|;!Ev 34pgpR1qv W3f F4q`5f *u {H"wH50 ʸ" Pm p #20.ЋQrdP4Pmw7-wq @p Ko#0:ȄB0 $j?ZD-'0QIbf8+pNJ_>p,ML`+ P@} aRg#@f+BO0'  ApYpIf &ߐH:PߠdRP0L) dB :E#'A !a6_@WuQQ/DpVcpmU `@i)nVk "C5Ae|` C{ <0 i)#@ܹٞ9Yyٟ:Zz ڠ:Zzڡ 0# WZZ[@oUv0XuXmXA01A`kPQ4y_VN 0 & 4 ^ aA5TܨG1#d&*R*q;FH0@Y_%Q(QWfu&dQPIvda fI#@"a )‰%M#$ b9_K"L  Cu uJL,>_`'RX!-<,X^u6. E:gôIw,W>6˄-3kM)_eͨ9@U4\d6lThX3=k Bt8|pp^l ZCpǨBBK&dƎ ?_e3 Ql@O{YY@}}<-7dG%7&H_9/"Df쀓; 8"vqAIјΊ7H{0` cv0&3{HB.9 >AJJ\ ]@Z W!4Yf$L6dpxgITv:J0EQgc pGdѧs4S+,u# W EÁ~K7;љsPx z:-DȠ!GWQ"k[,1}F HԢfk*\qEj䰒; +)88N(F(f(T' YSԋ!F;pĺSFJ4XܯNX4 k*`/OoʙeEA3f ^(C;E ?<]E&QcIE=8-4D;"I)MT4;72JOr6 dADMs6$ gI<0rRT '?PBgy~r{}`I.?Mrx(y'%yL9 H(i:o rM}kB[MDM Y4q &XPP$N0&-pW4T6 Lh1MZZ$$P5wtk0%y8o-K S8cB%v 'Lcrr6cǚLR(;cCȓH5/|Г3H2L>^@f XpLi'@ vr0i0p& p'UD5 !ɟ@S%q+AjQ5^@#L_`[<2Gy#hQխ.@2`S `!|"W  ,"R8oVDPPKͭnw pKMr:ЍtKZͮvz xKMz|Kͯ~4a8B& x(C n@(d`WLyq`H-S(FL  U<,; &#To8L8P[kgР Pu@BA6`CL&>DMu(@0HCS 48`9Q#-FUX@w PfQIԣBMs2 Lg  Nc/D TZ&:p%H78Ē Do&\$YR`LPN4%4#)e X_R >/: fr7' ,v)&X8ڒ.pQWZG1L GHj=%Ibm|@^$w"4RF/\CY,AԎ4jd*Xz, K "vQb$8ߑXӨsL(VIGh¬+;(9L`$p֍LkEK &p#pƠd Edֆ"q#E MAw} %%Wh~DNaϔPһ ig!_Rx=:3j"a{V9?>4#4+FN=技ON3|Ns'>BG`zC?&>Oc e&03w?K&)C 8s9c @_tPhBP, v&0(Q0&0!>Ov)!1pb%A ~;\P`B4*!?sPyB N` +a.) duZ)ԇ.cV34pgih Hr3f AsF/rz 2=0c FU2'a,QD-0j'+7g$F:,0L+,!.cQrd &~20߀1uK_J|I_((rD-'0Q0 `IX-, d%ޖV+ >nrr9Q`l#L`+ PN;S2 PH08 `&>0˸"VF&P} $ yBRvXQ ` P:a ƌ0$ XD< O0 C+  eRP( _B :E#(A"Ga_@WuQQp}0DpVcpm b)`nVk "C5Ae|` C{b oМ}0KI9Yy虞깞ٞ9Yyٟ:Zz ڠ:Zz #0pQZeY5[:D(P0 ~f7 I@≕uf\XA2APhQ4__VN 0 0) t T_A5T2G0I#V_&*R*q@pc0NPn<(8j_WOhO&&p ԃ_ ?I#"qi9h_4;bM"&) ;)_QdamR UJ,(_x QwE I 0|8J^Ĉ.x`^fFGv^/Xq ^CTDGt@셷 S! #E ./c^aGעJwv^"K>0w(*}o.ru))r*%%(g(/p2'!% ХX2+\22PϺ^@R$% )&p""T^7 H!I@lvEcP q*a+. _I`c 28pEAP(PmYamPS6@@t |WPg`PgA hd\f|hjl! , H*\ȰÇ#JHŋ3jȱǏ CIɓ(S\ɲ˗0cʜI͛8sɳϟ@ Jѣ$!drZB`'RX!-<,X^u6. E:gôIw,W>6˄-3kM)_eͨ9@U4\d6lThX3=k Bt8|pp^l ZCpǨBBK&dƎ ?_e3 Ql@O{YY@}}<-7dG%7&H_9/"Df쀓; 8"vqAIјΊ7H{0` cv0&3{HB.9 >AJJ\ ]@Z W!4Yf$L6dpxgITv:J0EQgc pGdѧs4S+,u# W EÁ~K7;љsPx z:-DȠ!GWQ"k[,1}F HԢfk*\qEj䰒; +)88N(F(f(T' YSԋ!F;pĺSFJ4XܯNX4 k*`/OoʙeEA3f ^(C;E ?<]E&QcIE=8-4D;"I)MT4;72JOr6 dADMs6$ gI<0rRT '?PBgy~r{}`I.?Mrx(y'%yL9 H(i:o rM}kB[MDM Y4q &XPP$N0&-pW4T6 Lh1MZZ$$P5wtk0%y8o-K S8cB%v 'Lcrr6cǚLR(;cCȓH5/|Г3H2L>^@f XpLi'@ vr0i0p& p'UD5 !ɟ@S%q+AjQ5^@#L_`[<2Gy#hQխ.@2`S `!|"W  ,"R8oVDPPKͭnw pKMr:ЍtKZͮvz xKMz|Kͯ~L! 62A&E0AH0Q,uRTxM P[kgР [6\I```a6DAdI\ιM   P2P#`BaP>3*xD[ci"8XgGB>0$ggB@`SB8*ϝ`CLxQF]p2ApB MJI2N>C1 ʉ$\T@wqQRkR,h?'I4+p&H39T"D2 \G L ^pn #fuI+-#{r&AkC<`>:K9̘D:Y |DFHxNP;Ҩ ~rIxrRFisYgcWA1J  VrL H40Q}+AR6D 8B8Ȃf};IDtC'y@vFS_Q=~PMJoZ"C݇zkg=K+2?"Zd h0RQ<-.Iv7iyIRdb'!F@M$lO[J)$#5=m%4 Hq$ ݟz0;%Cчw,P9'9:&2C?:'(03||} > 0 h~=&}}& "h'`H-@ (!O'Q3&0!>OSvF#~)B'A ~;\PB4) L@u0yB 4` *aA;dyuZ)A!34pg)1Dsm3f {F,2 +r| VU2' ;+40RzSu< .Q*su+,7".RrdP<2x/@t-AZT-qq b|I)0ur>)lܨ+~,xN3LQlqZ|LDn(B=3L`+ PpN7A l}5  F>09" - ߐ XcP} $Ֆg`!%c`x2a &ߐHl` X=hp+Q@> kÔ` qxځe= ב <@!S41 =` ^fzB@v +GUpUuU[@7@m5Y 2V?P%P` l` #6 nYk "m4Ae|` C{Zp s`v <0 i)#6Yyٟ:Zz ڠ:Zzڡ ":$Z&z  #0 f`ZeY5[T0(0 ~m8_0XuXmXAA 9pF`Q^ +PUaTLYi˜ ^^A5T_G*5^&e*R9oc :Pu;lh^WOhO5a&ۦ^ 39"1q(^%%M"֤' + u,p3^,BhHUI,# i]ˈ.߰H*a.^.xȌ]fD(2]/XWzm]XzGd[m 3*|gC&) ]!3p#QV$P%}N" U 4]@DGj]#4C>(46ڵ8O 7.:q] <$85|;;$#/%:#"acمGW"Ჩ93la]3 EKWDV]H3$c+b]x~gZx]0S&! K#ze.).( .c݉]aGגk/j^s[{s^‘*,a+*^.IAF^"qpJ*!Mċ x ^%%2q0lr^R$4hЪ ""DOo H!#;Q!^ᕁ y) 1 _I` Q:4p/S_!PtC!A1V@_mPSF +0 @t0 Q `WPg`P _pr`'RX!-<,X^u6. E:gôIw,W>6˄-3kM)_eͨ9@U4\d6lThX3=k Bt8|pp^l ZCpǨBBK&dƎ ?_e3 Ql@O{YY@}}<-7dG%7&H_9/"Df쀓; 8"vqAIјΊ7H{0` cv0&3{HB.9 >AJJ\ ]@Z W!4Yf$L6dpxgITv:J0EQgc pGdѧs4S+,u# W EÁ~K7;љsPx z:-DȠ!GWQ"k[,1}F HԢfk*\qEj䰒; +)88N(F(f(T' YSԋ!F;pĺSFJ4XܯNX4 k*`/OoʙeEA3f ^(C;E ?<]E&QcIE=8-4D;"I)MT4;72JOr6 dADMs6$ gI<0rRT '?PBgy~r{}`I.?Mrx(y'%yL9 H(i:o rM}kB[MDM Y4q &XPP$N0&-pW4T6 Lh1MZZ$$P5wtk0%y8o-K S8cB%v 'Lcrr6cǚLR(;cCȓH5/|Г3H2L>^@f XpLi'@ vr0i0p& p'UD5 !ɟ@S%q+AjQ5^@#L_`[<2Gy#hQխ.@2`S `!|"W  ,"R8oVDPPKͭnw pKMr:ЍtKZͮvz xKMz|Kͯ~Lu! 62A& KA׸0QO,uRTxM P[kgР FTFdF=  F!u &H⪖)O6b(P,PʙЀLCaP>3*xD[ci" (@ 8`  9=$G'8`3't0d%22@. 7lH78Ē Do&]OP@Kkr)-I)]DDT901mT" t@(ަ!L0,!Y?R@qLHtk0KE]iaTٓ0ZL"a>:K9̘ ([ |DFHx5ď%'GW;Ҩ ~rIxr:/HZ-a^db) 2 Qä 2h`;1V򃠥Lb+ڵP`0v;O6,H3 Oz$!+%3YR ίH)8`O@Kһ i~^=YIH0- G<_WPIFl {(Jh>|VIL\P2K*}pg;,9'9:&g~h9.(xp}B3`;7(~#)C8s9c%v g oBP,074@xEhBP4hg42 sq>,DP T( '4IVp@u zZdR5`K")07"@ q!O[n C4yp3f ~F,2ǀ)r GU2' ;+40L.)+7u .Qsu+,7".Pnqrp oI^߲x/@t-QpH H|I¨pw0uD-'0Q-P)2+~,xN3ʈ0[2J|LTp}!A8K4 X-tVQ@=5 (0 &>0"@ 9)!ߐ 1TP} $ז`q \vlH:P| X=r0+Qp; oÔ` qvځp!S41 Iy` ^f1a!D!+ agdmSD_@WuQQ}/DpVcdQ `DAIu` lp @SfedmYk "04Ae|` C{Ni !Y[ o}0`:Zz ڠ:Zzڡ ":$Z&z(*,ڢ.02: N #0]ZeYu (P ` me]0XuXmXA@" 1`Q^ УUaT\L5i hj\A5TiG\p AeRvR,E\?w\`%Q&-Bu&\)I(@E >iNU 'MB\;bMeȒS[K"L%…/PJ)*"ՏI n5 G[>R[Ĉ.xT[Hֈ[fĉ 3.uEv]TO5DEtD^F)S>5CgC^ך ~x[!3XHtE E}NF?5*0F? 4@HV(4^@>B?HH&~8O Xc:c[ `'RX!-<,X^u6. E:gôIw,W>6˄-3kM)_eͨ9@U4\d6lThX3=k Bt8|pp^l ZCpǨBBK&dƎ ?_e3 Ql@O{YY@}}<-7dG%7&H_9/"Df쀓; 8"vqAIјΊ7H{0` cv0&3{HB.9 >AJJ\ ]@Z W!4Yf$L6dpxgITv:J0EQgc pGdѧs4S+,u# W EÁ~K7;љsPx z:-DȠ!GWQ"k[,1}F HԢfk*\qEj䰒; +)88N(F(f(T' YSԋ!F;pĺSFJ4XܯNX4 k*`/OoʙeEA3f ^(C;E ?<]E&QcIE=8-4D;"I)MT4;72JOr6 dADMs6$ gI<0rRT '?PBgy~r{}`I.?Mrx(y'%yL9 H(i:o rM}kB[MDM Y4q &XPP$N0&-pW4T6 Lh1MZZ$$P5wtk0%y8o-K S8cB%v 'Lcrr6cǚLR(;cCȓH5/|Г3H2L>^@f XpLi'@ vr0i0p& p'UD5 !ɟ@S%q+AjQ5^@#L_`[<2Gy#hQխ.@2`S `!|"W  ,"R8oVDPPKͭnw pKMr:ЍtKZͮvz xKMz|Kͯ~L21#F&HAPgˆ+lö@ @S PDpk- A^$Јʈ6ku```a6DAdI\2ID`>C@)gb` @AΨyl LPr *OAj0o~ PfNQIBMyӶQŨx :xhp,M5QH(X2Ll*t㦋Ql2 (0 6>09u C5'  A2mYp<!scP6a y!ߠ."%¸0P0L) g[@mf z+B :E#(Axz8 tH0EDpUuU[@7@n5HV Hsp `d.G@XEVచ :I$P9  0O ^$ 5 <0 i)#Vyٟ:Zz ڠ:Zzڡ ":$Z&z(*0# ǥZZ[^@oL` Vv-\0XuXmXAqQ4PN[_VN 0 ׵  sY[A5T`]@I#*i[&%*RFHqYГ%QUBlHq&d]0}Rr@DA r`# ] Td+bM҅*r@Aq,]2,\  (1\& ج\5u@G%w֪ ftՊ S>>AEE/ߠt:1DEtDDž0=hCU pl:B;# E\ |hWD4\:{9? 4@ŵD%`3>B?Ų;4`#0v Є X2+\2]]0*T0R$eFl&@""J]- 7 tG!I]@khEqjPнE+Pfg8] qL[!Pt%FVUМ3[mPS@E @@t  };\WPg`P _xz|~! , H*\ȰÇ#JHŋ3jȱǏ CIɓ(S\ɲ˗0cʜI͛8sɳϟ@ Jѣ$!drZB`'RX!-<,X^u6. E:gôIw,W>6˄-3kM)_eͨ9@U4\d6lThX3=k Bt8|pp^l ZCpǨBBK&dƎ ?_e3 Ql@O{YY@}}<-7dG%7&H_9/"Df쀓; 8"vqAIјΊ7H{0` cv0&3{HB.9 >AJJ\ ]@Z W!4Yf$L6dpxgITv:J0EQgc pGdѧs4S+,u# W EÁ~K7;љsPx z:-DȠ!GWQ"k[,1}F HԢfk*\qEj䰒; +)88N(F(f(T' YSԋ!F;pĺSFJ4XܯNX4 k*`/OoʙeEA3f ^(C;E ?<]E&QcIE=8-4D;"I)MT4;72JOr6 dADMs6$ gI<0rRT '?PBgy~r{}`I.?Mrx(y'%yL9 H(i:o rM}kB[MDM Y4q &XPP$N0&-pW4T6 Lh1MZZ$$P5wtk0%y8o-K S8cB%v 'Lcrr6cǚLR(;cCȓH5/|Г3H2L>^@f XpLi'@ vr0i0p& p'UD5 !ɟ@S%q+AjQ5^@#L_`[<2Gy#hQխ.@2`S `!|"W  ,"R8oVDPPKͭnw pKMr:ЍtKZͮvz xKMz|Kͯ~LE0U#F&H PVD@ˆ ^¶@ @ PDpk- A^V}  l0l[!<0L| Z<وD`>$AIg` @A,xyl LPr *O !e R3GxP2\s@HlPR20w :x J2ቇGtMWO( :4)}M,Bo`8B9T&pX6B+0ВrQM!II%i@$Ҭ\m#RT@dR  ,l)T&?C5( \ԕF=9k Jܑ#&-ࣳD$Zό F%GgkXыKAr#7,Tq`z!q#9[h5J6(F "zp]azXJi!&oc%aTV dA1($ Sf@mY Lf|E7JBw4}t,^ʾω|tRRr~ H+.?"Zd h`QQ<-j(t!4m32DO3S$$xiC#@l$#5=m/ +ՙ$ z0;auI;?u7)c|)Cg9.g'()03|8(0'0 h~=&Q'٠' +h'` H-@ (11&9xEЃhBP4d4 sq7,DP T( '4IVp@!4dyuZ)0s!@ q}(!I-@D)C4ql3f A2t)yz)r Z7G0' ;+40U`!*+7g$FGײ̒z#-u.'@&-AHt20߀HR0#HPTtI,s0tD-'0Q-qL)+G8+pN3A ()c%+ >p,M5a #PJ2'nPN7Q( n6}Rpa#NSWO8!Yl 5' ! A&6r<DHq@1V@la @"@ ;qO0P0j( ~Zme zB :E# )Az|8  L` pG_@WuQQP}/DpVcQ `Cq+ 6~` uPE@XV 1T$P9  0O @T$Xx o}0ĉ`ٟ:Zz ڠ:Zzڡ ":$Z&z(*,ڢ,*6# i5ZZ[_ PO`clcD@WUXV^^ *(e_VN 0 סfpA5T+bT^@`+`:aRZR,^Ѱ 0zvr>?Pe^ g# WOhO '9A r@;)K8!M:$^?Щn6q,] *%'J5Jum"_ 1]p03 ]rv2Ge bIYfF ?etZ#Xt]wP)1DEtDs4# 3Tx6T][w- X5y(F*&}NUGBSԳ@4] i)>B?K4F4`%9C ~)<Bw#2"3]0u,!:CuL+%zP O ;L ]K9N;\aPK E43]9|#4p3]z2+5B5_3]w8G#AKC]00/x7 5]50b?.2;C2Յ Zx8zSx p]g033zJ]p22]؈.Rywx](Fڈ]&pQc*A*.]0 p]Ȫ+P@i1q'I+1n?%%$"14EI(E2^O"](B{-bR%rh)%cP88AqjPP y*%+I`~ aKEg q wöh QQ*P[KmPS Ԡ@t0 `Tk[WPg`PPf z|~ Ȃ<! , H*\ȰÇ#JHŋ3jȱǏ CIɓ(S\ɲ˗0cʜI͛8sɳϟ@ Jѣ$!drZB`'RX!-<,X^u6. E:gôIw,W>6˄-3kM)_eͨ9@U4\d6lThX3=k Bt8|pp^l ZCpǨBBK&dƎ ?_e3 Ql@O{YY@}}<-7dG%7&H_9/"Df쀓; 8"vqAIјΊ7H{0` cv0&3{HB.9 >AJJ\ ]@Z W!4Yf$L6dpxgITv:J0EQgc pGdѧs4S+,u# W EÁ~K7;љsPx z:-DȠ!GWQ"k[,1}F HԢfk*\qEj䰒; +)88N(F(f(T' YSԋ!F;pĺSFJ4XܯNX4 k*`/OoʙeEA3f ^(C;E ?<]E&QcIE=8-4D;"I)MT4;72JOr6 dADMs6$ gI<0rRT '?PBgy~r{}`I.?Mrx(y'%yL9 H(i:o rM}kB[MDM Y4q &XPP$N0&-pW4T6 Lh1MZZ$$P5wtk0%y8o-K S8cB%v 'Lcrr6cǚLR(;cCȓH5/|Г3H2L>^@f XpLi'@ vr0i0p& p'UD5 !ɟ@S%q+AjQ5^@#L_`[<2Gy#hQխ.@2`S `!|"W  ,"R8oVDPPKͭnw pKMr:ЍtKZͮvz xKMz|Kͯ~L~a#F&HXF 0C`@JW@HAhb7AJpBnA>+ x Pq2^A8j```a6DAAcI\2F   8 41( xyl LPr *O F@K{ Pfl@QIcBMy \ZJ0w :x J2ቇGtMZ WO(0 :4)}o&Bo`81D9T&pX6B+0ВrQM L' M*o: fm8j=`V@HfakH2̭Mͤ \ԕF=9k Jܑ&-ࣳD$Zό,%GɸmXыKAr#7,Tq`z!q#9[ȵI6(F "zr 5LzXJi!&oc%aTx]KF A_/\2<mY Wf|E8JB0e)Y@R}Nw╒CkgFJ\q "[8EeXiQCIv 7iI1z$!F@M$lO[JRde$#5=m%4 l $I`Ѓa Nr6߱9~#:&͗2C?:'9 '()03|8(0' 0 h~=&7OJp}BP, v2='Q>&0!>OCv(1%80 sq8,Dy2P( '4IVp@!4dytRҁ)034pgP{) m'F(0"v;cp4\t)Q*'/*r)3Poqt sPISBzSpFbw,G̢z#f.'@&-a .b|$- x/`,# WIED-P+!,@+tHD-'0Q-A+ߨ+~P 4p&+ >p,M5qI(Ppr3)0 X-tpuvRrJ4 'ya#NSWO8$Yl@6' " A&6r<DHqZW&&瘏#` X=yS0j` qpvځe<P4vB :uh1 F` ^fC" 1`QL <pUuU[@7@n5V S)NP ~` KuP 5G@XVКm 1ke|` C{YWs i@Z o}0!`yٟ:Zz ڠ:Zzڡ ":$Z&z(*7i00sDZZ[%`dΐ 0 ~`eu P?UXV`_0* X%0G>UaT^zA5T+bT8_BrJ# tu&e*RjIHYN3CP_PnWOhOum2L 8#0nR,!M:$֤^f.+q,„^ Rp"JLǏ1D^P/B( 4^/!x/iwfF-zފ.X^ %!w$1DEtDߵÅ_D؉6]/HkH3X%ax8'A]'Ai(@]Z#43#4]#4m08O ĩ]0hb e ~)<5|u6!< *;]F4"ק)(;][+4~ 7O#8%B; )3p]%@a|!\5ݵr j^(G#AK]ې"13PB;0vC!3$]O@ #cx ^ʅ:y0Sߵ72^5%BuE/ }x^@p)*KR^p/$q)bp pr^p-'*y,X2+\^v/II(E^;!D) z(R{-s6bz2 H!Ы^Bk@6A#pApP1g1K_+'qm y!PtBm̹4,mPSд @t PewWPg`P _âr`'RX!-<,X^u6. E:gôIw,W>6˄-3kM)_eͨ9@U4\d6lThX3=k Bt8|pp^l ZCpǨBBK&dƎ ?_e3 Ql@O{YY@}}<-7dG%7&H_9/"Df쀓; 8"vqAIјΊ7H{0` cv0&3{HB.9 >AJJ\ ]@Z W!4Yf$L6dpxgITv:J0EQgc pGdѧs4S+,u# W EÁ~K7;љsPx z:-DȠ!GWQ"k[,1}F HԢfk*\qEj䰒; +)88N(F(f(T' YSԋ!F;pĺSFJ4XܯNX4 k*`/OoʙeEA3f ^(C;E ?<]E&QcIE=8-4D;"I)MT4;72JOr6 dADMs6$ gI<0rRT '?PBgy~r{}`I.?Mrx(y'%yL9 H(i:o rM}kB[MDM Y4q &XPP$N0&-pW4T6 Lh1MZZ$$P5wtk0%y8o-K S8cB%v 'Lcrr6cǚLR(;cCȓH5/|Г3H2L>^@f XpLi'@ vr0i0p& p'UD5 !ɟ@S%q+AjQ5^@#L_`[<2Gy#hQխ.@2`S `!|"W  ,"R8oVDPPKͭnw pKMr:ЍtKZͮvz xKMz|Kͯ~L~a#F&HXF C`@JW@HAhb7AJpBnA>+ x Pq2^8j```a6DAAcI\2F   8 41( xyl LPr *O F@K{ Pfl@QIcBMy \ZJ0w :x J2ቇGtMZ WO(0 :4)}o&Bo`81D9T&pX6B+0ВrQM L' M*o: fm8j=`V@HfakH2̭Mͤ \ԕF=9k Jܑ&-ࣳD$Zό,%GɸmXыKAr#7,Tq`z!q#9[ȵI6(F "zr 5LzXJi!&oc%aTx]KF A_/\2<mY Wf|E8JB0e)Y@R}Nw╒CkgFJ\q "[8EeXiQCIv 7iI1z$!F@M$lO[JRde$#5=m%4 l $I`Ѓa Nr6߱9~#:&͗2C?:'9 '()03|8(0' 0 h~=&Q'' -h'`H-@ (3&sEhBP4d#P0'A ~;\PB4)w)uyB 4` *BI)AGHwx q(%x 1)1CIC q!Kvbt(l3f AӅ@Bz*2c FG0' ;+ 40U`!*+7g$F|Љ}*+,7".h1rd0",G20߀H:.~TtIB, ո(MTJrR,G8+pN3Q ,/Y2J|LTp'7PqR nPЋN7QWg' D}6>04u2&5iP} $hb#g[O8ueBla8:PwߠUR#S0j` qpvځe<Tʀ Ј8!Sp(A`C" 1`QL ;pUuU[@7@n5V T)NP ~` KuP 5G@XVm 1ke|` C{YWs @Z o}0"`ٟ:Zz ڠ:Zzڡ ":$Z&z(*,ڢ6X02A9ZeY5[ePuK;F0XuXmXA6mPdq8y7UaTI_ StEA5T+bT_P+ &u*R%w@%Q&cWOhO Е ( 9#pu&!M:$^ :$(q,^ ُf,^.G1^7! ^ `#!x.^PaFh^1E;.Xd^0 SCTDGT^SShCC\S4# %^'4.0!}N!y@4^ ; #43^΀9o4 4Z ~)< 0:``1|;;S4°!:c%M+4ORK}q]3:09ll0:4#^4p a`'RX!-<,X^u6. E:gôIw,W>6˄-3kM)_eͨ9@U4\d6lThX3=k Bt8|pp^l ZCpǨBBK&dƎ ?_e3 Ql@O{YY@}}<-7dG%7&H_9/"Df쀓; 8"vqAIјΊ7H{0` cv0&3{HB.9 >AJJ\ ]@Z W!4Yf$L6dpxgITv:J0EQgc pGdѧs4S+,u# W EÁ~K7;љsPx z:-DȠ!GWQ"k[,1}F HԢfk*\qEj䰒; +)88N(F(f(T' YSԋ!F;pĺSFJ4XܯNX4 k*`/OoʙeEA3f ^(C;E ?<]E&QcIE=8-4D;"I)MT4;72JOr6 dADMs6$ gI<0rRT '?PBgy~r{}`I.?Mrx(y'%yL9 H(i:o rM}kB[MDM Y4q &XPP$N0&-pW4T6 Lh1MZZ$$P5wtk0%y8o-K S8cB%v 'Lcrr6cǚLR(;cCȓH5/|Г3H2L>^@f XpLi'@ vr0i0p& p'UD5 !ɟ@S%q+AjQ5^@#L_`[<2Gy#hQխ.@2`S `!|"W  ,"R8oVDPPKͭnw pKMr:ЍtKZͮvz xKMz|Kͯ~LE!0U#F&HA$AIg` @A,xKf LPr *O !e R3GxP2\s|@HlPR20w :x J2ቇGtMWO(1 :4)}M,Bo`8B9T&pX6B+0ВrQM!IϞI%i@$Ҭ\m#T@dR  ,l)T&?C5Ԩt¨<'gaV;r|Ĥ|t(=rx;1z *_2zVx|K1r#7,Tq`zJ9#9[hPuJ6(F "zr 5LzXJi!&oc%aTVk dA1(% Sf@mY Wf|E7JB0}t,^ʾω|tRRr~ H+.?"Zd h`QQ<-Z'Iv7iyI!z&!FK$lO[JRde%AiF$iI}]$IhЃa6Nr6߱9~#:&͗2C?~&9'()03|8(0' 0 x~=&Q'٠' -h'`H-@ (13&;xEhBP4d4 sq9,DP T( '4IVp@!4dytRҁ)0!@ q(!K-@D)C4s(l3f A2t)yz)r 7G0' ;+40U`!*+7g$FGײ̒z#-u.'@&-AHt20߀tr0#HpTtI,s0.'pJrpsmި+~@ 4p2/Y2J|LTv@vQ$@q8 X-tvoRl2 '0 6>04u2 &dXP} $hb#g_R2q@8@l8:PvߠnQ t; `k` qpvځe=x_ A@!S41 E` ^ЗCq" qٰwDpUuU[@7@n5Y SW`cK `P InYk "C5Ae|` C{y@iX ?Јu oН}0lj`ٟ:Zz ڠ:Zzڡ ":$Z&z(*,ڢ.4$@ #Pi@)ZeY5[ p0uKKd#=UXVa_^A;UaT9^p s! @:TC"FŎrP)&e*RezmYm4BP^%v9WOhO t*&0&A rp; ^ #:Nu% K"LE -JU {+`;)Ir.߰HuBO)GeF ):0^!F'AE)6CTDG]SD,C3Tx6].w?$4# ](4$3'A]Z:j'@]#Ai(#4]çC^(#=X]CO #2]$# e+K~] ;]` IKѴGWuH49Ò 0:'4]@B"a`'RX!-<,X^u6. E:gôIw,W>6˄-3kM)_eͨ9@U4\d6lThX3=k Bt8|pp^l ZCpǨBBK&dƎ ?_e3 Ql@O{YY@}}<-7dG%7&H_9/"Df쀓; 8"vqAIјΊ7H{0` cv0&3{HB.9 >AJJ\ ]@Z W!4Yf$L6dpxgITv:J0EQgc pGdѧs4S+,u# W EÁ~K7;љsPx z:-DȠ!GWQ"k[,1}F HԢfk*\qEj䰒; +)88N(F(f(T' YSԋ!F;pĺSFJ4XܯNX4 k*`/OoʙeEA3f ^(C;E ?<]E&QcIE=8-4D;"I)MT4;72JOr6 dADMs6$ gI<0rRT '?PBgy~r{}`I.?Mrx(y'%yL9 H(i:o rM}kB[MDM Y4q &XPP$N0&-pW4T6 Lh1MZZ$$P5wtk0%y8o-K S8cB%v 'Lcrr6cǚLR(;cCȓH5/|Г3H2L>^@f XpLi'@ vr0i0p& p'UD5 !ɟ@S%q+AjQ5^@#L_`[<2Gy#hQխ.@2`S `!|"W  ,"R8oVDPPKͭnw pKMr:ЍtKZͮvz xKMz|Kͯ~L21#F&HAX```C+6DAdI\2ID`>$A@)gb` @A,xLf LPr *O AO@ 3I!zzBh)O\6O0p!A 'Jed@5iX=!&$Ҥ 4$:ћ dP4y![DSBZR.*I&rp3DM$P Mz$ҙuK*Y"#ʄr weVt҂;'gaN .9|Ĥ|t(=rw;1Aľ2 *א2zVx|KDvQ#W:L?] "vQb$1ZŽ3 >(SBZAIT>0-d#DW c$ Jp`v-#,h6x$"paʌ< ;*ҌӨFI(oK79a4WJc)qW$@l Fd]$ۙ."ΐO%He P/=m}(yud%g:$a"@f"s8|Ns~:&'~g9.g9}p B3;7C} pp)C 8s9c%v JpoBP, h'Q>&0!>O3vF~)0'A ~;\PB4)U@uyB 4` *rI)AGHwpx o(%90K1x8{RG q~!J[@D)C4rk3f ܆F,yy)y U2' ;+40L`!*+7Quy .s3u+,'!.nr&`u 2xK.s-! ,ZT-IDD-P+p (rD-'0QTwsm1ۨ+~,xN3A ()2ylBq~'>p,M5Qi'X2Ll*t0n}Rk2 (0 6>0)u 05' A"mYp<scP6a yЉ!P @TC"FU`;?aRuR,^ %Zy9=P^[0ko2m:q&t^mvbn8A r`# 8^ro6!M"4^WX`3q,^Ԫ;S$,^2/`)qIUq.߰Huv/GܵX#9fDڅ L2z@s,Ee TCTDG])4KZ3Dx6]Po4 B*!XUW4?}ENB;?z$g]CvK?9O 4*]a )L0c]9o3c]ʇ9;H]=4#:f5*;$`)93E Y#0)7O#85: 6cK#pc]Ч;)5B5_S].BkG#AKs]bp RY3 e]!3$]TDCx ]2^"Ѹ Iw] 0]@,a.).{1Vx.]5D.+",7]. i(*] o0q))%Rp(!(g(J'4a' X+J3{E]2^ 춓CI(Eb^=+%B9"~"k^p'-2wB&%c`8AzPpS1E#*I`~ Q`Eg (K!Pt"@IgQF W`+mPSn@t0 `TP ;x[WPg`PPf {xz|~ ! , H*\ȰÇ#JHŋ3jȱǏ CIɓ(S\ɲ˗0cʜI͛8sɳϟ@ Jѣ$!drZB`'RX!-<,X^u6. E:gôIw,W>6˄-3kM)_eͨ9@U4\d6lThX3=k Bt8|pp^l ZCpǨBBK&dƎ ?_e3 Ql@O{YY@}}<-7dG%7&H_9/"Df쀓; 8"vqAIјΊ7H{0` cv0&3{HB.9 >AJJ\ ]@Z W!4Yf$L6dpxgITv:J0EQgc pGdѧs4S+,u# W EÁ~K7;љsPx z:-DȠ!GWQ"k[,1}F HԢfk*\qEj䰒; +)88N(F(f(T' YSԋ!F;pĺSFJ4XܯNX4 k*`/OoʙeEA3f ^(C;E ?<]E&QcIE=8-4D;"I)MT4;72JOr6 dADMs6$ gI<0rRT '?PBgy~r{}`I.?Mrx(y'%yL9 H(i:o rM}kB[MDM Y4q &XPP$N0&-pW4T6 Lh1MZZ$$P5wtk0%y8o-K S8cB%v 'Lcrr6cǚLR(;cCȓH5/|Г3H2L>^@f XpLi'@ vr0i0p& p'UD5 !ɟ@S%q+AjQ5^@#L_`[<2Gy#hQխ.@2`S `!|"W  ,"R8oVDPPKͭnw pKMr:ЍtKZͮvz xKMz|Kͯ~L16 a\`E>W@HA&@S/$FTFdɆP@9`ض6 Cx`0B@)gb @AΨy|/~3H̀O@ 3ɲztY8P| :xhpz[Vz :4)}M<.fCX ʉ]ΤYh{N"@W_R   / j _I+- \盾exg ŁL.j!Hͻ@u8?0]Hcw80=t% F]vZh*1rw)Ur^L n&ZG(+f66ڰ@x:v/eاuc)qw$`O@GһbHem"NG\W(aIFPh gq$ Sz]a'9X]4}o/>>t_IuI`]>nn 꾨l($ |`&iIx;`=vZw\.:Z$l:h\KGJź S0i2$ޙPݐ<(vq1¡cºR+O6fF.hv/pFtAR*RK Hm;_a}p~ (r]mb,IGE ,rpql"l '^pK4 B^&Q@R5 ((0 $^pp (!ߐ FP} Nv P7 x"h P})q+ K` ` lp 0 0' Qm< l ٠c5m@e+B :E#p)xT 3Bd e0EDpUuUD`Q;R D1 u` d0e`>H@X.Vcm A[ HkH_ ii <0 }0+;o g hi987'04Q i}@rkǗHkH rl%P2Pd+I)vn3I.R(fS@fD3c E)+bqK.8~8@.^Ia4Ke,R k17sxry3s9|9;)t:MPіOq!>VÉ`)4DS A|aU"0 30t60^ĚQ*)p -Ip1y~ ÛAW?J)a !,D!jh8 A RPo @ #0 ؎ZZ1q緇$p0 (}PFUXV FѐaQa_VN 0 @ $pf%@E . A5T+bTeW%c}e0g&U{*R29 ( v  aO0 { М8DPepo([gobi:q&t^@ivBj8A rЃ+ D^DфbMu/ s6K"LK$KJ8+c-]5dH.n $]a.QEGܵNYG ] - Ee CTDG]`${gC n Bؚ 7?tENE{Bj@d]CpSyo?98$&П~8) pv <օ>)Qs;;֥sӱ;NF]вar(!:CW5L%`)9E U+%0)7O#850: 6_$pc]@;)55"4%yI4%+!UWrPmG3$]TDr ]0^p"13 wՕ E]2]5R7ײ]5pAғ].f)*+]h)Բ)u[ @^1xB;+q"^р% 4W%5H( 7A( e$奿8+%B9""k^Ч'jlc`V,^8S4⋳I`;`) "K!Ptq"@bV3\mPSn@t0 `TP w[WPg`P  ֞r`'RX!-<,X^u6. E:gôIw,W>6˄-3kM)_eͨ9@U4\d6lThX3=k Bt8|pp^l ZCpǨBBK&dƎ ?_e3 Ql@O{YY@}}<-7dG%7&H_9/"Df쀓; 8"vqAIјΊ7H{0` cv0&3{HB.9 >AJJ\ ]@Z W!4Yf$L6dpxgITv:J0EQgc pGdѧs4S+,u# W EÁ~K7;љsPx z:-DȠ!GWQ"k[,1}F HԢfk*\qEj䰒; +)88N(F(f(T' YSԋ!F;pĺSFJ4XܯNX4 k*`/OoʙeEA3f ^(C;E ?<]E&QcIE=8-4D;"I)MT4;72JOr6 dADMs6$ gI<0rRT '?PBgy~r{}`I.?Mrx(y'%yL9 H(i:o rM}kB[MDM Y4q &XPP$N0&-pW4T6 Lh1MZZ$$P5wtk0%y8o-K S8cB%v 'Lcrr6cǚLR(;cCȓH5/|Г3H2L>^@f XpLi'@ vr0i0p& p'UD5 !ɟ@S%q+AjQ5^@#L_`[<2Gy#hQխ.@2`S `!|"W  ,"R8oVDPPKͭnw pKMr:ЍtKZͮvz xKMz|Kͯ~L} 0. a\pF>W@HA&@Sͯ!FTFdɆ  F!u Fw(@(L B@0@(V<"1H̀#O@ 3zH 8O0p!AQ80} :4)}M<. Ik6C9єЁWAT90ҙT" t`^C$ҙ0a0+X $xA5T%]ࢮ0*ro"a>:KECG& 7@Xlt#ݭ`Cb;\'vqF1z"(٠%p[azJi!A&aG(+e/Hڰ@8╒t/eg Q<-y.IvNw E)qLKhqus]5B 'AiF$`EU6q$D=SvpuIe'9X]at&ǙN=u`O{E<}}I4- d́]? 8I&LG{NB ? ؅@XB[Y)! yzO7}VR2äC[ bʈf{{gf@vCr$E̯ J J @>B;KٽIx[[Jti-q7" 0n]pnRb,IG׀0[2JmU'<3L`+iXp FR@gC5' ~&`R2 Dv"Ӈ^ Ql; p^W%S `S?!S41 F^ S10EDprUBG_$OP`  d0pjG@X.Vcm @_Ni ,X[ o  `)#8XxȘʸ،8Xxؘx$Pp  O ۨ7/64n5  PH^` q&P Hbh JO"Whl+Bib7 րФ h 0XB-'J7:r uؓ ,D!nnJYfDpQE)s\H_0pঔ{WmJ '%ꐀ=yV A@?T4< #0 f[ZeY'!>OdY 7lP ` Lov0XuXmXAX$rI-~y4 IQ(FUaT)s9 p Rz>TC"F:;72Qq &u*R 9/fIR;CP7[@YT`i8q&,P.q=&  I#pNAO3 %`j5!M #a4W k r6K"L4Ӑ&K$4KJ@ C,w@4 w>,BlG+¦P+q*HN V/xp 43 2Qٕ_Y Wn) L2z -QEa 2*a pZCTDG`ג)4i7|6$G0 Ba:"X߇.ʀ<*GsENa wz'4^@&? rQ ,t$?:W=&f,Pq+98۪%З%aw)K2G(P->)1s;;l -s&!:#;hr*6o&%A@GW ` h01 $`)9ө!ԺCC4C>{h (#r@lC;71 P` X~P!08>;)5B5_# a x.Bj4Ks]bШRY3 e]#G3$]TDqm]0^P" ?] 0]@,a.).z1&aG]5 A/ұURjf.]p/q)Ru)(!(j(Ł",)q"^ѐ )@%6H( 7A( e$E%B9"p"[^`Z : y&0pc`8APkS1E#*I`;`) J!Pt!"@V38;mPS`n@t0 `TP i[WPg`Pp uK~Ȃ<Ȅ\Ȇ|! , H*\ȰÇ#JHŋ3jȱǏ CIɓ(S\ɲ˗0cʜI͛8sɳϟ@ Jѣ$!drZB`'RX!-<,X^u6. E:gôIw,W>6˄-3kM)_eͨ9@U4\d6lThX3=k Bt8|pp^l ZCpǨBBK&dƎ ?_e3 Ql@O{YY@}}<-7dG%7&H_9/"Df쀓; 8"vqAIјΊ7H{0` cv0&3{HB.9 >AJJ\ ]@Z W!4Yf$L6dpxgITv:J0EQgc pGdѧs4S+,u# W EÁ~K7;љsPx z:-DȠ!GWQ"k[,1}F HԢfk*\qEj䰒; +)88N(F(f(T' YSԋ!F;pĺSFJ4XܯNX4 k*`/OoʙeEA3f ^(C;E ?<]E&QcIE=8-4D;"I)MT4;72JOr6 dADMs6$ gI<0rRT '?PBgy~r{}`I.?Mrx(y'%yL9 H(i:o rM}kB[MDM Y4q &XPP$N0&-pW4T6 Lh1MZZ$$P5wtk0%y8o-K S8cB%v 'Lcrr6cǚLR(;cCȓH5/|Г3H2L>^@f XpLi'@ vr0i0p& p'UD5 !ɟ@S%q+AjQ5^@#L_`[<2Gy#hQխ.@2`S `!|"W  ,"R8oVDPPKͭnw pKMr:ЍtKZͮvz xKMz|Kͯ~Lu!^"p 0#3ꐂe@ R4aj~42"M6XyP9`ض6 Cx`0@@)gb@2 @AΨy|/3H̀#hO@ 3ɲz! H 8O0p!AQ80} :4)}M'00 #" Es4PB%W4?sENSԒ Ep'4^&? r΢:%?:W=&3;9%98% :;7&}8) @ <9C9o33S,:W$sè&!:#;#&1fZ tt5>%1Ӻ$`)9Vp@pCC4c j;%1.: 6#pcgФ &sZ#4]5W'"4&yI41ì'! U&ArPn *;"P+|4C2!dT qC3cH2#^P"SqȂT+@-a.).Jr*F}͢+pN.Q`.D.+",I>p,M0 nf)*(ћ .>0 DK- š2$I8+¢/ѻ'&0 X+J&ؼW%'  Oș5H(p5K(E ` 2k+R!]x"r&-+DpVV Xf~&Ҟ-p|`CP  Khp8AɘPkS1E#*pI` a`5cPDEgI_ d~arBQ! lpXu0$_?  0 t@@@ua pvP E@ (` !~Ȃ<Ȅ\Ȇ! , H*\ȰÇ#JHŋ3jȱǏ CIɓ(S\ɲ˗0cʜI͛8sɳϟ@ Jѣ$!drZB`'RX!-<,X^u6. E:gôIw,W>6˄-3kM)_eͨ9@U4\d6lThX3=k Bt8|pp^l ZCpǨBBK&dƎ ?_e3 Ql@O{YY@}}<-7dG%7&H_9/"Df쀓; 8"vqAIјΊ7H{0` cv0&3{HB.9 >AJJ\ ]@Z W!4Yf$L6dpxgITv:J0EQgc pGdѧs4S+,u# W EÁ~K7;љsPx z:-DȠ!GWQ"k[,1}F HԢfk*\qEj䰒; +)88N(F(f(T' YSԋ!F;pĺSFJ4XܯNX4 k*`/OoʙeEA3f ^(C;E ?<]E&QcIE=8-4D;"I)MT4;72JOr6 dADMs6$ gI<0rRT '?PBgy~r{}`I.?Mrx(y'%yL9 H(i:o rM}kB[MDM Y4q &XPP$N0&-pW4T6 Lh1MZZ$$P5wtk0%y8o-K S8cB%v 'Lcrr6cǚLR(;cCȓH5/|Г3H2L>^@f XpLi'@ vr0i0p& p'UD5 !ɟ@S%q+AjQ5^@#L_`[<2Gy#hQխ.@2`S `!|"W  ,"R8oVDPPKͭnw pKMr:ЍtKZͮvz xKMz|Kͯ~Lu!^"p0#3ꐂe4@ R4aj~4L=*  F!u F_G(@(h@0@(V<"up (@Ar&:XV/$)C N:$a0=\& 5 '$Ҥ |ؼ]ODSBz^QR lR,p@'Ёy Hgr`]2 f D/" \ԕF^\MX$LZGg2Qೳ0ʖ|@^$~K#ݭC^;\'hvͨ0a9$̊x+LXr=0-db?ZƠaG(+n/5Yva7GSRqvֳu vA0E%t6NBi #p1%d]>xp8I>+Czчw,.0:)#@د;uZf/*Qov}ʆM ۉnZ&ݣ=M'|" J~1qE׵ڡ!Kc)a҈!ҌhɾwfTl7d/{J=p\@jSnf-0+KrAKˎUk hJ}<|w0~.0~ }#0n]>)R |,IG) a+ ^F/x3L`+i7p SPiv^@ 56' 2~ &`R2 f" x^F(mz 0P0L% ב `S?!S41 B^@S FUpUuUAW_$POP` l` 6 A5n{Vk "h -@m <0 }0(`؋8XxȘʸ،8Xxؘڸ؍8Xx蘎긎؎𸎿 0# Z5Y[_$p0 (}phFUXVE_ѐc%c(FUaT^ PRz@%TgT^eU"o?aRWT+5l @R;?P^[74@i8q&$ J0950ivj8A 8hN. }cpXP( ~:AFKVҔCbM*I1!ɇ8p7q+p4q,„X_)0E2JJ%q@ Kl2/*I*g!}&i5 }β)vnl!_b'e$1HL!U40x$\360n X2+2z - Eb]Ơ "1,{wDmx)4Y7p6) -90  B۹#Xr+W4?sEN#)'4^y rUEtCoca?AE9$^'98T%CEz'{9) t)< *О">)!s;;1 Y$'@W%qs&!:#;(4jZ0nƨ%?3 T'1$`)9./gPCC4S4P6%1-: 6#p`'RX!-<,X^u6. E:gôIw,W>6˄-3kM)_eͨ9@U4\d6lThX3=k Bt8|pp^l ZCpǨBBK&dƎ ?_e3 Ql@O{YY@}}<-7dG%7&H_9/"Df쀓; 8"vqAIјΊ7H{0` cv0&3{HB.9 >AJJ\ ]@Z W!4Yf$L6dpxgITv:J0EQgc pGdѧs4S+,u# W EÁ~K7;љsPx z:-DȠ!GWQ"k[,1}F HԢfk*\qEj䰒; +)88N(F(f(T' YSԋ!F;pĺSFJ4XܯNX4 k*`/OoʙeEA3f ^(C;E ?<]E&QcIE=8-4D;"I)MT4;72JOr6 dADMs6$ gI<0rRT '?PBgy~r{}`I.?Mrx(y'%yL9 H(i:o rM}kB[MDM Y4q &XPP$N0&-pW4T6 Lh1MZZ$$P5wtk0%y8o-K S8cB%v 'Lcrr6cǚLR(;cCȓH5/|Г3H2L>^@f XpLi'@ vr0i0p& p'UD5 !ɟ@S%q+AjQ5^@#L_`[<2Gy#hQխ.@2`S `!|"W  ,"R8oVDPPKͭnw pKMr:ЍtKZͮvz xKMz|Kͯ~Lu! !xp0#3ꐂe4@ R4aj~4L=*  F!u F_G(@(h@0@(V<"up (@Ar&:XV/$) N:$a0=Z-4 '$Ҥ |ؼ]OʡDSBz^QR lR,p@'Ёy Hgr n3 f D/ʑ \ԕF^wH`o`>:Kፂ uQ DX>kinxjvwͨ0au$l}x+LXr=0-db?ZƠaG(+o5Yv0_7GSRqvֳ;`vA0E%tn6NBi #p1%d]>xp8I>+Czчw,.0:)#@دuuZf/*Qov}ʆM ۉnZ&ݣ=M'|" J~1qE׵ڡ!Kc)a҈!ҌhɾwfTl7d/{J=p\@Snf-0KKrAKˎUk hyJ}<|Ǐ_.>n]>)h,~4^;`~RQllZmj3K4 B^iv ;W iv^ (!ߐ XcP} g^`!%c`|3a !qh0Ӧ@> m^p ʠB :E#  1eqlBpUuUAW_?P%P` l` 6 A5'nxVk "hp sPm <0 }0%`؋8XxȘʸ،8Xxؘڸ؍8Xx蘎긎؎ 0# ېZZ[_$p0 (}phFUXVE_ѐc%c(FUaTH^  Rz@%TgT^eR" &{ERpX^fߠ$%Q WOhOuf'@T^hj5!M5#4^Wk g@K"LKo$0E2JJ1/p*I*]5 }β)Ćn ]#o/QEG+< O(S160n%" LUa* |X2zp,! E%@bU6a qG$_Fp=S22q%{gC!@C#R  B9$XP]9pE3>WDW  K/zB?peY6Coa?f /N` #/âZ h law)I2ͣIJ*9o23g)h79!:#;.)1:fcj;te.q`(1o$`)9Y?ЧCC4sw/qP%1-: 6#pCElj=;)5B5_S .'"4@&ayI4 QZb R_J#w8 E`@Iө%:"P+|4C21?z?<7 o{g4ԇت0^#܇C?2=( E2js9c)F}t-:;7RA/{8Ri"*ѱ{(!Cj)B-J-[j&,R}1B3 %1xBK;+qa4lY+ X+J W%4.'.H( 7A( e$:CrlZ!zz(B-µh A8fa 1" :$m Vp9A=Pk ()S1E#* I`;ѣ.(!ߠ|3`e *!Pt! .Sr dPv)W*mPS`'!tG? 0 `АOY[- } `+!λI/@>D_P/hHhĺhXJȲO(\2 Pⶪ(pFo/L5< 6$! , H*\ȰÇ#JHŋ3jȱǏ CIɓ(S\ɲ˗0cʜI͛8sɳϟ@ Jѣ$!drZB`'RX!-<,X^u6. E:gôIw,W>6˄-3kM)_eͨ9@U4\d6lThX3=k Bt8|pp^l ZCpǨBBK&dƎ ?_e3 Ql@O{YY@}}<-7dG%7&H_9/"Df쀓; 8"vqAIјΊ7H{0` cv0&3{HB.9 >AJJ\ ]@Z W!4Yf$L6dpxgITv:J0EQgc pGdѧs4S+,u# W EÁ~K7;љsPx z:-DȠ!GWQ"k[,1}F HԢfk*\qEj䰒; +)88N(F(f(T' YSԋ!F;pĺSFJ4XܯNX4 k*`/OoʙeEA3f ^(C;E ?<]E&QcIE=8-4D;"I)MT4;72JOr6 dADMs6$ gI<0rRT '?PBgy~r{}`I.?Mrx(y'%yL9 H(i:o rM}kB[MDM Y4q &XPP$N0&-pW4T6 Lh1MZZ$$P5wtk0%y8o-K S8cB%v 'Lcrr6cǚLR(;cCȓH5/|Г3H2L>^@f XpLi'@ vr0i0p& p'UD5 !ɟ@S%q+AjQ5^@#L_`[<2Gy#hQխ.@2`S `!|"W  ,"R8oVDPPKͭnw pKMr:ЍtKZͮvz xKMz|Kͯ~L! !xĀ0#ꐂe4@ R4aj~6\I```a6DA G(@(h0J0@(V<"up (@Ar&:XV/$)%t;I< FCe@ S9ÚhJ@+"J M*.:0!Lm&`Q9RaVt¨ MPLZGg2Qp!Nd|@^$O~Kg #ݭC^;\&db@ oa+9LlAK5LvE!C)y@v0_7GSRpvֳ;`vA0E%tnȚIH0-P};@4#n'IgE`wST6p@g}8=eu9=" Ee;Ҏ]c&YFDs`WO-NRۉў“v%"ZpgGJȐqa%i0ii6^$ޙPݐ}+qqgcQOX¬h/-.;ѯ1)] ?ZKz}iT( H^>)Ȳh,~4^;j &% ^)B=3L`+E h 6 (ie0 56' ~ &`nfx{3a !qph&R0P0L% ב <@!S41 <^@S FUpUuU@W_?P%Pΐ l`  àJ~D@XV0m _p s. <0 }0"`Xx؋8XxȘʸ،8Xxؘڸ؍8Xx8 0# `Z5Y[_$p0 (}`hFUXVE_ѐbc(FUaTD^ ЅRPz@%TgT^eN" &zERkX^fߠ4%Q ~WOhOuf' 2"pNEFKV4bMu+p4q,^ĖCS$D-N }P| b 0<0;1/*I*F ^kP97Q,2l,߰H&Az`q1vT#o/QEG$!xPf#Q0G4﷒p)n )m'2z -A E QP' r0a љ qGOvb#t) Y'p6?`o.2 ;" vj.W4?sEN Xz>%^xhr#2*?:y#4I JHN` #/Ch H*z9) t)<QJʼn$>)!s;;/79;c2F`"1Ppw:CWTZ:TO&1%`)9s)! A *%0)7O#8 /$ $1-: 6$pS`@W;g;(5B5_C@?t?&"4&1yI4!>Ws !*!U0}%1rPc(h0gX+a|4C217O=;pv)0Gp:;7( 9ު}?5;9#0]@,a.).&/q*F}t-+΂8)Q .D.+wB#)  f)*54pZ'!+ߐ/q)Қ",g1k%,B3 ,J'4!'r Aр% ԁp{E]ӐCr{ p$PF@5C Z!Qx"8&-2CEԶ0 m& : J!O2^2mc`@9A7Pk .;T8S4R>ֱSS 905` FEi݇.  dqareBQ! lpXu0}JI8Cp  +a0J@ ! ${Prv#HOY"5l!> ;"APF8Ĝ QqT0 V ! , H*\ȰÇ#JHŋ3jȱǏ CIɓ(S\ɲ˗0cʜI͛8sɳϟ@ Jѣ$!drZB`'RX!-<,X^u6. E:gôIw,W>6˄-3kM)_eͨ9@U4\d6lThX3=k Bt8|pp^l ZCpǨBBK&dƎ ?_e3 Ql@O{YY@}}<-7dG%7&H_9/"Df쀓; 8"vqAIјΊ7H{0` cv0&3{HB.9 >AJJ\ ]@Z W!4Yf$L6dpxgITv:J0EQgc pGdѧs4S+,u# W EÁ~K7;љsPx z:-DȠ!GWQ"k[,1}F HԢfk*\qEj䰒; +)88N(F(f(T' YSԋ!F;pĺSFJ4XܯNX4 k*`/OoʙeEA3f ^(C;E ?<]E&QcIE=8-4D;"I)MT4;72JOr6 dADMs6$ gI<0rRT '?PBgy~r{}`I.?Mrx(y'%yL9 H(i:o rM}kB[MDM Y4q &XPP$N0&-pW4T6 Lh1MZZ$$P5wtk0%y8o-K S8cB%v 'Lcrr6cǚLR(;cCȓH5/|Г3H2L>^@f XpLi'@ vr0i0p& p'UD5 !ɟ@S%q+AjQ5^@#L_`[<2Gy#hQխ.@2`S `!|"W  ,"R8oVDPPKͭnw pKMr:ЍtKZͮvz xKMz|Kͯ~L2 !xĀ0\#3e4@ R4aj~6 J@R  F!u 6g8B@)g@Q @AΨy|/3H̀#&`O@ 3ɲz! H) N:$a0=Z-t&$Ҥ |ؼ]OʡDSBz^QR lR,t@'ЁyYI%`0+X $xGTT%]ࢮ0*C|7Y u' 7DX>kinxjvwͨ0a$n}x+LXt=0-db?ZƠaG(+o5>ڰ@8╒t/e߈sugߑ Q<- 3IvwGw ENBi #r1%d]>|p8I>+Czчw,.0:)#@دuuZf/*QovsF72' hq"Nv4~ &4(Ņ L^ׂ;k>RB#.RN#dI#vH1p "̀[aJ=p\@P Snf-0KKrAKzk hJ}<|Ǐlǒ_.>nH^>)h,~4^;j &% ^)B=3LjE h 6 !(ieP 56' 2~ &`ofx|3a !qh &R0P0L% ב <@!S41 >^@T FUpUuUAW_?P%Pΐ l` 6 WnuVk "h W 0b[ o  `)#@Vx؋8XxȘʸ،8Xxؘڸ؍8Xx蘎 #0 8hZZ5[;iP bC%0#GAI 7lP ` Lof0XuXmXAX4aWQ7 ѐbc(FUaTFUŇ:Y@.)R`z@%TgTȐSQ @WD4`@IS(zB?&+W@?t?&?:V=a&S4k`s98o&=${9) t)<4c9o333;"s3@(!:#;cԢ:%1}j:CWc΂$1*%`)9C :;8&A $0)7O#8*4vc#r@lSC7`#b eNSz8Z#4]5ڪ+ Gj4KS[3%3'!# UP}&Ar3(4w`_Y+2;C2Q1;sNro{w.V0 ;9}?e D"2) E2Я!~*1/qp)F}t-.J7.r~:!z Rj"* -!+߀/q)႞aB-?YgQk{˶[,c01xB;+q)YФ/ X+JW%! ˙)0J3-H( p$PF2@¨hbF+%2'#o";(c{fa B$ x 6F _ Pp1S1E#*HI`;(d#-VsQPЦDE6)5 ^ E"@r8QF WmPS@6 v `T :?  0 t@@xua pvP E@ >@NPpr`'RX!-<,X^u6. E:gôIw,W>6˄-3kM)_eͨ9@U4\d6lThX3=k Bt8|pp^l ZCpǨBBK&dƎ ?_e3 Ql@O{YY@}}<-7dG%7&H_9/"Df쀓; 8"vqAIјΊ7H{0` cv0&3{HB.9 >AJJ\ ]@Z W!4Yf$L6dpxgITv:J0EQgc pGdѧs4S+,u# W EÁ~K7;љsPx z:-DȠ!GWQ"k[,1}F HԢfk*\qEj䰒; +)88N(F(f(T' YSԋ!F;pĺSFJ4XܯNX4 k*`/OoʙeEA3f ^(C;E ?<]E&QcIE=8-4D;"I)MT4;72JOr6 dADMs6$ gI<0rRT '?PBgy~r{}`I.?Mrx(y'%yL9 H(i:o rM}kB[MDM Y4q &XPP$N0&-pW4T6 Lh1MZZ$$P5wtk0%y8o-K S8cB%v 'Lcrr6cǚLR(;cCȓH5/|Г3H2L>^@f XpLi'@ vr0i0p& p'UD5 !ɟ@S%q+AjQ5^@#L_`[<2Gy#hQխ.@2`S `!|"W  ,"R8oVDPPKͭnw pKMr:ЍtKZͮvz xKMz|Kͯ~L+FЇ' xmlI@^w\AM )0ML JVu7Ѐ<   l0l[!<0q/XƩ0B2]hqs:GQh,:4=H`e(3HDɽE*0HA]m.N:$a0=:I$Ҥ 4Q]:P¡h"n)ADI`TɭD  2NPV"nD"+ S4n4EHoVB^Cᶀ%mF4 7:kiDy l _p7(F u(TT_Z Q쁵cٽP)oІŀ}CBw.{)>r!pvHTWHn{3]\'Cp[LKhPH30#3,ptZnq]C90FGݞw, 1:SlcΣ.WPi֋>+ Kf[ h=eP1:a+ {`aorowI`DI< Q.A} Y$0)yChv-O;'&FW;3*ҊwmS6\(!SO)8a;2KrAKR~|!RŸ}oXmdz@Q:RQ Jd[,~T[ oPl?'p W\K4 b[NRpZ 8@a% sZ tɅ' }FH0\"h p|E} s]0P0L`Q H\B :E# >Ea']_@WuQQ s @0vbo`0 Db@XVpn \-0 0 ps <0 a)# 8Xx؋8XxȘʸ،8Xxؘڸ؍8 #0p s[ 討@ZZ[l@oU5vX$p0 (}~FUXV5[HfQU@ > I* PQ(FUad0 ) 4%B p Ry@%TTfT1#ܦeZH?aR74+$qqca51$p%Q(Q=X2s&WPaO&&H=5qf'  #pN#a )B 4AFKVCbM"L0q/} g@K"L   `*K)%jRJArn(k #o.Q+ن.߰H.œ$H&o-}G .z ,)aFhަ|)k#X醞;ư"1,1DEtD@3*n)YG|6tT2.(F3 $C" 7pJ#(W4?rETA@_t'4^&? q.cCSx?`ӦԲ9N` #/c} ,2 %Dav)003;OJ`9owc9,S{@v jj;C9%1& stE8+ِ#1&`)917&A &0)7O#8OJ#0 #r&aO .;)5B5_C%zMZ4KS`$'!@!U&13;c3aQJ#pq&Ӻ$P+|4C2 AASDT# -h]@ ӡ;90S#E* 902@gn* aGtz>E.D.+W'kPR`*F `j/q)fHlԹ$`,q%[ K,rwI,xB+)q@ි(@X+J3{E]"2# p$PFBt-*%rg'#o"Ka }%P" 9lc`9AeIPj)a. sX8Օ+RP>ֱS I28Q?`d0dFᆸ1P(Pm U A"@QarŬBQ! lpXu0XP T`0 t@PDnXL@ua pvP E@ #PȂ<Ȅ\Ȇ|Ȉ! , H*\ȰÇ#JHŋ3jȱǏ CIɓ(S\ɲ˗0cʜI͛8sɳϟ@ Jѣ$!drZB`'RX!-<,X^u6. E:gôIw,W>6˄-3kM)_eͨ9@U4\d6lThX3=k Bt8|pp^l ZCpǨBBK&dƎ ?_e3 Ql@O{YY@}}<-7dG%7&H_9/"Df쀓; 8"vqAIјΊ7H{0` cv0&3{HB.9 >AJJ\ ]@Z W!4Yf$L6dpxgITv:J0EQgc pGdѧs4S+,u# W EÁ~K7;љsPx z:-DȠ!GWQ"k[,1}F HԢfk*\qEj䰒; +)88N(F(f(T' YSԋ!F;pĺSFJ4XܯNX4 k*`/OoʙeEA3f ^(C;E ?<]E&QcIE=8-4D;"I)MT4;72JOr6 dADMs6$ gI<0rRT '?PBgy~r{}`I.?Mrx(y'%yL9 H(i:o rM}kB[MDM Y4q &XPP$N0&-pW4T6 Lh1MZZ$$P5wtk0%y8o-K S8cB%v 'Lcrr6cǚLR(;cCȓH5/|Г3H2L>^@f XpLi'@ vr0i0p& p'UD5 !ɟ@S%q+AjQ5^@#L_`[<2Gy#hQխ.@2`S `!|"W  ,"R8oVDPPKͭnw pKMr:ЍtKZͮvz xKMz|Kͯ~L>o4Ґ>< O(W@HA&@S 6@0Pn09`ض6 Cx`02V 2@(V<"?A@Ԍʁ{/(@Ar&:P։pPABTe{1p!A =0d!%A&op7D̬ 4p(':Л` a*L^A?'ЁMP{) f DЄvf.pQWZLHg}CYm%JD^B& \zEK.HvUڑFl"ȁ@*YnP`I@.ZxL]QP$* 6,; d3p1 ^ʾOLRav]!m7%uFngxvH|]UPs L3 $aᆀ$`\WIHqY5"=Xp#4=Sـu!&:n]}\]RϾcW]Wׁw`{`-D suiq"Gɱ -.:>$c '-7؝a҈[Kxv33Bc_k씍q> SwgWK/rsY ,kL;5uG7,:w=p/u]M"ˏ2A5ʱRW YL |L`+3h"i&^6 $hf'j' B}41gg#x^"h 61feHzf  mRFe#beX_B :E#@<1.A C^_@WuQQ@sC1b%vb,FW@XVpn 1 `a:@aL_ o  `)# ؈8Xx؉8Xx؊8Xx؋ #0pH[ hۀ5ZZ[@oU8vsh$p0 (}hGUX(V5[IfQU@ c@ I PQ(FUaT[ 1H@S"8 "0ב ! A5TV1#e# &zCR{(70@!V { @R;!P-(( }WOhO&&p Shvi8A rNC 4 !P)fKbM"L1q/~ g@K"L fUZS$D-_p,G..,cMǛ ~,QsݖmۦI 06O/U40x,}20)aFh.",*7)ݩ0X)*a CTDGt{ z )h{6tTp2.(F3Р d;" Ep(J#(W4?sENA@_t'4^p3xh"@.cC3lch?`ԲINps#/Ca (,2 %DA9) s)<4&>)qr;;u:S{ Z :Sr:;:%1*%H;fبh,p#1$`)9s17v&tvzw~"8qOS#0 W<:$pS4LP"sZ#4]5:+D#W7+jG#AKS`'! Ul'13Pa/"ـ<'"P+2;C2AASD2C3cE2J9$^! ?u0R@@-a.){Y?ڮ aGע9oR5p/+Y~IQa*F .`j0q))Ql%Ѓ,q (ƛ-/Ҋ'4a'! % 4@W%"!ip,H( 7A( e$$ P"-r%B9""K)a [%0K 0G: mc`9ACIP k*a0#. i8Ȧ =ֱSƺ J28Q?`d7;!Pta3%VP d@q.QF W:mP-XP T`0 t@оFn0L@ua pvP E@ #ଽ5xXZ\^`! , H*\ȰÇ#JHŋ3jȱǏ CIɓ(S\ɲ˗0cʜI͛8sɳϟ@ Jѣ$!drZB`'RX!-<,X^u6. E:gôIw,W>6˄-3kM)_eͨ9@U4\d6lThX3=k Bt8|pp^l ZCpǨBBK&dƎ ?_e3 Ql@O{YY@}}<-7dG%7&H_9/"Df쀓; 8"vqAIјΊ7H{0` cv0&3{HB.9 >AJJ\ ]@Z W!4Yf$L6dpxgITv:J0EQgc pGdѧs4S+,u# W EÁ~K7;љsPx z:-DȠ!GWQ"k[,1}F HԢfk*\qEj䰒; +)88N(F(f(T' YSԋ!F;pĺSFJ4XܯNX4 k*`/OoʙeEA3f ^(C;E ?<]E&QcIE=8-4D;"I)MT4;72JOr6 dADMs6$ gI<0rRT '?PBgy~r{}`I.?Mrx(y'%yL9 H(i:o rM}kB[MDM Y4q &XPP$N0&-pW4T6 Lh1MZZ$$P5wtk0%y8o-K S8cB%v 'Lcrr6cǚLR(;cCȓH5/|Г3H2L>^@f XpLi'@ vr0i0p& p'UD5 !ɟ@S%q+AjQ5^@#L_`[<2Gy#hQխ.@2`S `!|"W  ,"R8oVDPPKͭnw pKMr:ЍtKZͮvz xKMz|Kͯ~L. FPxD8ʖ h)Hф O'+Pu#HPa  F!u 6H !m\)@AΨytR,0N:$a0)($Ҥ 4$NpN¡hJ@Mo%0@%@%%Vљ"FRʤo+-#H9Y0Q!U@zM#Hƭ#"BjG7""K+x/"vQjb׻A1JA=R 2I @yqpo-#N@> ;,ʈ]@R}V$늌HfjDH0)!,(Q`iF$X?\ϛ=>l!g ,mՇFWDLϔ:ސK391WlW-B /*C72'"#ໝ!/Ao", 6`v祅||Bi -H FGx|Epzg4b`A2^x 6w).]8l,c/(> `UzEl)qq-~k`CGF_|X&Ցu+\ #;|WhIG!7R_+ NRR|L}!%&*_ЁRPr$1 '~$Q}_"h  #  <߀ _0P0L+`#LF_B :q1 8 dzX3`_@WuQQ0s2S@bV^h`nVk "C7AJ%C0bDb <0 }0 8Xx؋8XxȘʸ،8Xxؘڸ؍8 #0p s[ 討ZZ[@oU7vh$p0 (}gGUXX5[JdUU@ `z? I* QQ(FUaT[ 1H@S"08 "0ב ! TBuoF+xS m;Q &zERgHrmc~4Z @R;5P@io i zWOhO&&p Shi7A r`; ?@6j5!M9$$R/q/ g@KBk+v9f)/JJ,B2/*aI!5PdH.m I 0R(IIo.~G rmNfF/R# Ln/X)n)a  CTDGt< 4Ӣ(&2q&A{g|A+ j)1 B#XwФ4 8'CCʢE3-WD4$p E'zB?& W\@??&?:z#46pJ-Kd Sx'8O ,Ч2 %Daw ) s)<4r%>Cv(qr<<èȢ:%k&0@(!:#;9SЪ;'3٠#1*%`)9ã17&A Ǻ$u(7O#8OJ#`sw%aO1:Z#4]5:+g.BPjG#AKS`TRv3# U@+ sPW+2;C2AGqWS -`m]@ ÖK90S! #E* 902@yn*Q q)F}T~v)QG.D.+JIPk/*ro.p+!A.6ZVgpQ)!(R(-Gl'4'!i @ % 4W%"!+'(H( 7A( e$$ P һ,A%B9" "Ka zҤ$Af0Z 6F a !6S^"*XI`;*rL-VLFQBE:9P(Pm U @"@QarCQ! lpXu0XP T`0 t@`EnZL@ua pvP E@ #Ȃ<Ȅ\Ȇ|Ȉ! , H*\ȰÇ#JHŋ3jȱǏ CIɓ(S\ɲ˗0cʜI͛8sɳϟ@ Jѣ$!drZB`'RX!-<,X^u6. E:gôIw,W>6˄-3kM)_eͨ9@U4\d6lThX3=k Bt8|pp^l ZCpǨBBK&dƎ ?_e3 Ql@O{YY@}}<-7dG%7&H_9/"Df쀓; 8"vqAIјΊ7H{0` cv0&3{HB.9 >AJJ\ ]@Z W!4Yf$L6dpxgITv:J0EQgc pGdѧs4S+,u# W EÁ~K7;љsPx z:-DȠ!GWQ"k[,1}F HԢfk*\qEj䰒; +)88N(F(f(T' YSԋ!F;pĺSFJ4XܯNX k*`/OoʙeEXA3f ^(C;E+d#?N ]E&Q5cIElM3D)"I)T4:O72JO=p6 dADo6$fI<0rRfsJo`Nm6DBE?9;  #7JsI.?Muz(z'%yL' H(iot riTѫI?-|-7 OI(82 v %@a Mh߈EB!pyL%K#RrIJw'6 % ͼ䀄R,"-C+El|!4#2 " S +-a%`b0  ZRÁIl*A-q`Ht `&-|%!b08C ] OxPKcTKZP<#`d| BYQk !GD-Fx(&8P$@4M‹<RR@L>Wj2A$l'-T#$/MU9l@'`aEa’ 7yE2Oe0΄τ%JB@sG'MQ&Ȳ$;3)Tb'x4 ,'0>k3|$3=4<4P==9&1(S+pFPAt3zƿ~jV` gGh2(_XEZp$Q.U+&LDIZ'r+x B/X!9X._`ld?A%́lgKͭnw pKMr:ЍtKZͮvz xKMz|Kͯ~L`bM2 0R; &M&]GQ)B@a Mk#:C4YP5;a->0@(Ïs<#Y)epȂo,2(0e q&:PHY؂7H< # RH78čB\p`8MFv!nut,ԣ[  = += FEWa+<:˓9eX~@^#&Dk /":v"8Vg]_BDvȬ%(i),^=@L<) _p[KC>л)#}@v-D |н} gs{oi$P KsJ|4!wy'@ubo8'`!вqw,X7l$hΣD,T\OHҞ2u`|!|w4` .TBgB#b`|u/.HPR!T@〼æ +wѐѬ;wfT8)sA x aSH,?zȃ̢ ͧ m5T-xR;RQ m:Qpa c$&R*qqc`q4Z R;4HP io Y zh:q&dQ/x SphvRi8A &N$a ()r3A)fhK"M"i0q/ &KK  @)J%D2k΢J,B2/*AۙI1}n.Qֆ.pHsl$G)!Q40t, 2 )pl"nâ2z -џ CEƀ^61+CA4D5(z)i0f&!|),AI#yoIkB?eE3.p4nYE'qzB?oM7?KCKnSZ>1r*%K`#:8w$08O3 !Daz{)92S4&Kww(1r:<C:%#L;9%1gZyp5tG0+ ;7#4&A :"0)7O#8a\d% V;!Rt3%V+ @"@QanuCQ! l0Xu0XP T`0 t@FnPL@`ua pvP E@  5xxz|~! , H*\ȰÇ#JHŋ3jȱǏ CIɓ(S\ɲ˗0cʜI͛8sɳϟ@ Jѣ$!drZB`'RX!-<,X^u6. E:gôIw,W>6˄-3kM)_eͨ9@U4\d6lThX3=k Bt8|pp^l ZCpǨBBK&dƎ ?_e3 Ql@O{YY@}}<-7dG%7&H_9/"Df쀓; 8"vqAIјΊ7H{0` cv0&3{HB.9 >AJJ\ ]@Z W!4Yf$L6dpxgITv:J0EQgc pGdѧs4S+,u# W EÁ~K7;љsPx z:-DȠ!GWQ"k[,1}F HԢfk*\qEj䰒; +)88N(F(f(T' YSԋ!F;pĺSFJ4XܯNX k*`/OoʙeEXA3f ^(C;E+d#?N ]E&Q5cIElM3D)"I)T4:O72JO=p6 dADo6$fI<0rRfsJo`Nm6DBE?9;  #7JsI.?Muz(z'%yL' H(iot riTѫI?-|-7 OI(82 v %@a Mh߈EB!pyL%K#RrIJw'6 % ͼ䀄R,"-C+El|!4#2 " S +-a%`b0  ZRÁIl*A-q`Ht `&-|%!b08C ] OxPKcTKZP<#`d| BYQk !GD-Fx(&8P$@4M‹<RR@L>Wj2A$l'-T#$/MU9l@'`aEa’ 7yE2Oe0΄τ%JB@sG'MQ&Ȳ$;3)Tb'x4 ,'0>k3|$3=4<4P==9&1(S+pFPAt3zƿ~jV` gGh2(_XEZp$Q.U+&LDIZ'r+x B/X!9X._`ld?A%́lgKͭnw pKMr:ЍtKZͮvz xKMz|Kͯ~L`@J8%qR #SOGAU@ZkgbD@a Mk#: j ٨TB?/@AΨu#8p U0rB@3щ2F?F  :x F3g' \3 :4)}Ij'eup' O@@@P98W΋:&rz Hi+Y/쬡r(@8*#T[Ggy!= zD[CU@ Q;Ȑ~Q%NɅ ̉͠% Vx=jAP;ڰ/! .6<t/eNW"Kng84A>`ZB#2w}EMGDCB >j&SSPZ9N` #/R (4B$W0ِ))2S4&A)!*𢴇m,j&f*F PH .Z'gQ(!(g(i}7+r+1'!D ' X+J3s)\22`̹p p$RPFBʀ&,e%B9""vKa M%Pـo 7F ao »"5AS+I`@+d/sL-ULFQ?C*!Rt3%VP d@q.QFV*mPS 8@@t k?  t0 `TP |9[WPg`PA ƚ[B,F|HJLNl_! , H*\ȰÇ#JHŋ3jȱǏ CIɓ(S\ɲ˗0cʜI͛8sɳϟ@ Jѣ$!drZB`'RX!-<,X^u6. E:gôIw,W>6˄-3kM)_eͨ9@U4\d6lThX3=k Bt8|pp^l ZCpǨBBK&dƎ ?_e3 Ql@O{YY@}}<-7dG%7&H_9/"Df쀓; 8"vqAIјΊ7H{0` cv0&3{HB.9 >AJJ\ ]@Z W!4Yf$L6dpxgITv:J0EQgc pGdѧs4S+,u# W EÁ~K7;љsPx z:-DȠ!GWQ"k[,1}F HԢfk*\qEj䰒; +)88N(F(f(T' YSԋ!F;pĺSFJ4XܯNX k*`/OoʙeEXA3f ^(C;E+d#?N ]E&Q5cIElM3D)"I)T4:O72JO=p6 dADo6$fI<0rRfsJo`Nm6DBE?9;  #7JsI.?Muz(z'%yL' H(iot riTѫI?-|-7 OI(82 v %@a Mh߈EB!pyL%K#RrIJw'6 % ͼ䀄R,"-C+El|!4#2 " S +-a%`b0  ZRÁIl*A-q`Ht `&-|%!b08C ] OxPKcTKZP<#`d| BYQk !GD-Fx(&8P$@4M‹<RR@L>Wj2A$l'-T#$/MU9l@'`aEa’ 7yE2Oe0΄τ%JB@sG'MQ&Ȳ$;3)Tb'x4 ,'0>k3|$3=4<4P==9&1(S+pFPAt3zƿ~jV` gGh2(_XEZp$Q.U+&LDIZ'r+x B/X!9X._`ld?A%́lgKͭnw pKMr:ЍtKZͮvz xKMz|Kͯ~L~ #@0l#; &M&zv ``C#jܠpL% #ĂMk#: +r8P5;@0@=pnGԑ#@GHb )[FBΓ1|5WAL tH- MJ'LyI'b9sD6gN4 \FY2N*f+Y9uwԳ¨2 jmɇk4M#Fƭ!edHA\_'سBI7ܖX20=`u is[P_ڰ/! b~н}CQ g/ I^{L&Hʲ`ZB#ȭwNIhЃLC|GiF@>dn'{}50t7}7P[m_Z,\P6B%ޯ4̾d0d}a҈}M̀ S6\9~qe[>\/-%ax< ]P~ݱp +AW;~_(*\'[Yz|HX/rp央!o36nAʜSJEc嗈;l7Y? ' z&`'`" '#qPl0P@|Pe#1!`S4`A ׁQ ,x_@SER;#tVVL "B?x5biVkR8{PC P )#~S o  `k؆o؇~8Xx؈8Xx؉8Xx؊ #0p'[ h۠{xZXZ@oU4v_X$p0 (}0hFXMX5[If UU@ c@ IdP(FU]T[ GS"9 "09 ! {S>5o5T-xR;RQ m:Q` "%xqR4H740@V %ߠD0P(Q p2 QW@OhbO&d'G=5qf'`hD !@j5L$$@ Wp4q,;0̧CZO$,_p,}/8+c;χ,1}n.Qe,dHl@$G9"Q40t,`bDFmâ2z -ќ CEbƀ"1,CA4D5)Xy2tO@#2.(F3C" |E`q(qW4?rAN E[t'4#4W?J;$4ncva>1>%Kpd #/3R 4B$UWr) r <Q8OW5RJtc9L$S{ЦC ;æ9%1ru ;W::|#1J#ِtgW!l,  0f)*1k.o+!v|.ƙZ2|0q')(!(R(-зGl'4}'!A~ ' X+J3m)\22ЮƹP p$RPFB7,1\*%B9" "pKpa FzB%Pəgo 7F ۙ! {. X4+bRp>T >28Q?\d%5C!E9P(Pi[ U A"@Qan%BQ! l0Xu0yXP T`0 t@PEnL@pua pvP E@ kð@B`'RX!-<,X^u6. E:gôIw,W>6˄-3kM)_eͨ9@U4\d6lThX3=k Bt8|pp^l ZCpǨBBK&dƎ ?_e3 Ql@O{YY@}}<-7dG%7&H_9/"Df쀓; 8"vqAIјΊ7H{0` cv0&3{HB.9 >AJJ\ ]@Z W!4Yf$L6dpxgITv:J0EQgc pGdѧs4S+,u# W EÁ~K7;љsPx z:-DȠ!GWQ"k[,1}F HԢfk*\qEj䰒; +)88N(F(f(T' YSԋ!F;pĺSFJ4XܯNX k*`/OoʙeEXA3f ^(C;E+d#?N ]E&Q5cIElM3D)"I)T4:O72JO=p6 dADo6$fI<0rRfsJo`Nm6DBE?9;  #7JsI.?Muz(z'%yL' H(iot riTѫI?-|-7 OI(82 v %@a Mh߈EB!pyL%K#RrIJw'6 % ͼ䀄R,"-C+El|!4#2 " S +-a%`b0  ZRÁIl*A-q`Ht `&-|%!b08C ] OxPKcTKZP<#`d| BYQk !GD-Fx(&8P$@4M‹<RR@L>Wj2A$l'-T#$/MU9l@'`aEa’ 7yE2Oe0΄τ%JB@sG'MQ&Ȳ$;3)Tb'x4 ,'0>k3|$3=4<4P==9&1(S+pFPAt3zƿ~jV` gGh2(_XEZp$Q.U+&LDIZ'r+x B/X!9X._`ld?A%́lgKͭnw pKMr:ЍtKZͮvz xKMz|Kͯ~L" #H@V; &#SiL`C#: 6a9@l   llZ!<0KZQ\聪FP@A,xuTI+2H3Z@3щrIrM]oQmr0 Y+^ ILzM#F-#qi"BjG/S$x/!vBS8 Y2a^[KP{qT4#qBmY)]P ^ʾqψXa' hy.W4ngjCAU%F@A -(Bxf@DzeMGDHEY6v޳AS yW:#e__0o]a]Ož2"xƑeM0D&)!E;0p6K ќ !A8 Z$(  @hz7u  FЩI#EqPArvƀ G\)؋O"_12 Cr%B'H+x <`FxC'G; _(rHGDߐfeIȢG ,T_+ ݧ K_PK4 -04$'_@Rm  0{ hF_P} {c_"/ D ld SU@_B 6E#0%q@$u A`_@S5P`s-" Vp3!'`5biuk -$0}  O0P+F oh)#px؋8XxȘʸ،8Xxؘڸ؍8Xx蘎 0# W Z&[Z%YZ:D(P0 ~Pc7 IFAI 7b0 (}gGXV*&[JfQU@ l? I0OQ(FU]T[ GS"8 "0ב ! S`"5T-x`fŦ m:Q` "%CsRn(rmcЃ4Z :@HP~"9[h:q&dQhR@=5qf' +#0N#a 'B 4A)fΔC"M"9`0qj (Mԅ C )J)%`TRJB-B2/P(I!5)0m In7I}Io.E{ /z , T3F6mrnâ}nm}0"1,CA4D5)"i|2tO 2.(F3@D;" |m'7q}A|%@3D4|E'zB?oT?RCB z#4SSZ9N` u'8O s<7a@wp)?2ScvWoZoU tc9~*%S{pK m ;3;:Sg&1:W:uz#1#`)9!;5_CTCr'"40&!?I4]:qzF Upl'13FwPAF7}& P+2;C2 -}V5 [D)@*)0߰S!,} .yn TXF K28p=\d% Z+!Rt3%V큈} 0A"@QanŬCQ! l0Xu0XP T`0 t@EnRL@ ua pvP E@ #pUz|~! , H*\ȰÇ#JHŋ3jȱǏ CIɓ(S\ɲ˗0cʜI͛8sɳϟ@ Jѣ$!drZB`'RX!-<,X^u6. E:gôIw,W>6˄-3kM)_eͨ9@U4\d6lThX3=k Bt8|pp^l ZCpǨBBK&dƎ ?_e3 Ql@O{YY@}}<-7dG%7&H_9/"Df쀓; 8"vqAIјΊ7H{0` cv0&3{HB.9 >AJJ\ ]@Z W!4Yf$L6dpxgITv:J0EQgc pGdѧs4S+,u# W EÁ~K7;љsPx z:-DȠ!GWQ"k[,1}F HԢfk*\qEj䰒; +)88N(F(f(T' YSԋ!F;pĺSFJ4XܯNX4 k*`/OoʙeEA3f ^(C;E ?<]E&QcIE=8-4D;"I)MT4;72JOr6 dADMs6$ gI<0rRT '?PBgy~r{}`I.?Mrx(y'%yL9 H(i:o rM}kB[MDM Y4q &XPP$N0&-pW4T6 Lh1MZZ$$P5wtk0%y8o-K S8cB%v 'Lcrr6cǚLR(;cCȓH5/|Г3H2L>^@f XpLi'@ vr0i0p& p'UD5 !ɟ@S%q+AjQ5^@#L_`[<2Gy#hQխ.@2`S `!|"W  ,"R8oVDPPKͭnw pKMr:ЍtKZͮvz xKMz|Kͯ~L/@1mdM w\AM Gp( "` uKp  9`ض6 Cx`0@WLy|fP>3*xD[ci"_P2Lt@Hl ` tH`P O<8K MJ@Kh~PN4%t%) V" i t@(o%`0+X B$3~@v¨<'ga{no ,Qz3 dFFcH/"T-vQ#W_DH =$kwbY+m f`=0-d# cD 8B8ȂfcP6,H3zZ ^ʾω| H)8 nguC5=m}iF$i(!zRd\{6߱ \zq_r t]`Xԫ_0=."9*‚S Po)L"{K ? ߑEB&'+%C}C$;ä"g0y :2p\{Np1T1e']{q)dg#/ˬ-u2[6mt>e>MR w # &^ʏT_+ 6>p,ML`+ P`}N (0 6>0D#_P} $g_`ha P|Vߠ_0P0L) =_B :ur1 /` ^ dzR`_@WuQQs0DpVcpmU `a@XV`m 1T$P9  0O  <0 h)#`.v؊ #00AD(P 0 ~9 `[LfO L` : $h 1#%Ѝ2a@mc`x`zxi&a YxR8QXS A + SȐADlާ~2 I !z'yn3&o+9"z<6)p3p=)kB#8  v 4| 7iEC QW(W 9Ox` Е} uWrj ly9!?zM`C2v×8Oc hi'*>Bc?)W@DI@閍39&L. # W0 .DHpj5IǦao5 6 ST.ߐ3,lx4}G .z 8pI8y5aFhm2I܆*m8y#Xp Ӓ<  P CTDGt`'RX!-<,X^u6. E:gôIw,W>6˄-3kM)_eͨ9@U4\d6lThX3=k Bt8|pp^l ZCpǨBBK&dƎ ?_e3 Ql@O{YY@}}<-7dG%7&H_9/"Df쀓; 8"vqAIјΊ7H{0` cv0&3{HB.9 >AJJ\ ]@Z W!4Yf$L6dpxgITv:J0EQgc pGdѧs4S+,u# W EÁ~K7;љsPx z:-DȠ!GWQ"k[,1}F HԢfk*\qEj䰒; +)88N(F(f(T' YSԋ!F;pĺSFJ4XܯNX4 k*`/OoʙeEA3f ^(C;E ?<]E&QcIE=8-4D;"I)MT4;72JOr6 dADMs6$ gI<0rRT '?PBgy~r{}`I.?Mrx(y'%yL9 H(i:o rM}kB[MDM Y4q &XPP$N0&-pW4T6 Lh1MZZ$$P5wtk0%y8o-K S8cB%v 'Lcrr6cǚLR(;cCȓH5/|Г3H2L>^@f XpLi'@ vr0i0p& p'UD5 !ɟ@S%q+AjQ5^@#L_`[<2Gy#hQխ.@2`S `!|"W  ,"R8oVDPPKͭnw pKMr:ЍtKZͮvz xKMz|Kͯ~L/@1mdM w\AM Gp( "` uKp  9`ض6 Cx`0@WLy|fP>3*xD[ci"_P2Lt@Hl ` tH`P O<8K MJ@Kh~PN4%t%) V" i t@(o%`0+X B$3~@v¨<'ga{no ,Qz3 dFFcH/"T-vQ#W_DH =$kwbY+m f`=0-d# cD 8B8ȂfcP6,H3zZ ^ʾω| H)8 nguC5=m}iF$i(!zRd\{6߱ \zq_r t]`Xt@3: a 5ЬN4洷Р nm*W &(` -jU ډ X"H>S@E4xBY41|Dž\"L U(=Å LNV8'dLK0{aED.|S9<ʗ0iĸL"׻$T {gf A2pm,I+R~-al)30gQE)s1hR,+7g$F@,wr4h/'@&-.i.- xކ. Rd&sI3e0A(rH,'0Q)J8"q@W-AGpPI@lN'6>p,M/Q K4 kX-s{0 (0 6>0~"L3P1' r AhYp2*8"U/a &ߐHh0~ X1r4c73 jÔ` qpځ|. U98B :ur1 ` ^ dzB{3}5pUuU[@7@xQ `a2A538@XV G$P9w C{IGpz  oВ}0h^{` b(1C7M&A J'`Ti#w[YaI@3g)àRՖ ~W0t)>PI@y R#"  a  c-h L2z)Q(3Y1nm)I 0()!u ,Z@惵)ܠ ɴ%af'pIWg#& pP 8'`;0؉ "fp) HqZeM*q(БKd`'RX!-<,X^u6. E:gôIw,W>6˄-3kM)_eͨ9@U4\d6lThX3=k Bt8|pp^l ZCpǨBBK&dƎ ?_e3 Ql@O{YY@}}<-7dG%7&H_9/"Df쀓; 8"vqAIјΊ7H{0` cv0&3{HB.9 >AJJ\ ]@Z W!4Yf$L6dpxgITv:J0EQgc pGdѧs4S+,u# W EÁ~K7;љsPx z:-DȠ!GWQ"k[,1}F HԢfk*\qEj䰒; +)88N(F(f(T' YSԋ!F;pĺSFJ4XܯNX4 k*`/OoʙeEA3f ^(C;E ?<]E&QcIE=8-4D;"I)MT4;72JOr6 dADMs6$ gI<0rRT '?PBgy~r{}`I.?Mrx(y'%yL9 H(i:o rM}kB[MDM Y4q &XPP$N0&-pW4T6 Lh1MZZ$$P5wtk0%y8o-K S8cB%v 'Lcrr6cǚLR(;cCȓH5/|Г3H2L>^@f XpLi'@ vr0i0p& p'UD5 !ɟ@S%q+AjQ5^@#L_`[<2Gy#hQխ.@2`S `!|"W  ,"R8oVDPPKͭnw pKMr:ЍtKZͮvz xKMz|Kͯ~L/@1mdM w\AM Gp( "` uKp  9`ض6 Cx`0@WLy|fP>3*xD[ci"_P2Lt@Hl ` tH`P O<8K MJ@Kh~PN4%t%) V" i t@(o%`0+X B$3~@R a Z*(]ࢮ0*YcĞoBLЫ[1I ,Qz3wF٩LB|@$w";RF/r\GBЃDZBHF6 & 2=4=t@4AN0J;VMiBV`2٠%`֊ba"6Ki&0>o#&Pʑ $D 8B8ȂfcP@]>C>ڰ"j@LEX %%D7WFNa WWһ" i֎VdےQ %4j"a{VҀkGL3 !xOE Q](IHEsEPIĔw,}4:exL>[~RoLAf %2 V=꧌֟0= LhxIYG-@ f8(Q()0!>Or(17"ِ(A ~;\P0B4)x:T( \'4IVp@C3u"AIhw ;&w$s b34p3x F43f A2S8:K0a(n)3 sQE)ss`O Xsv߇pFb *0:$,!.Rrd@BC*- x.aua o0/hhp'D-Pۗ:A(rHD-'0QӀ}S+~,xN-_H3Qy7+ >p,M/@K4 8!p H8t`"N4BO2uir/'$g@ ebh8:Pߠ'b 30P0L) I=6v!o7!SP)Açm_@WuQQy0DpVcpmU `68rn^Vk "p3Ae|` C{d2Tq= <0 i)#0e)qx.6`{7Q&6♦I@`gh @HqFW8p+Q m+ Mf/O v?%1pXnWБPS Ր AP깞ٞ9Yyٟ:Zz ڠ* 0# W0ZZ[@oU8vvi`0XeXAWLf U@ 0UaT>[ 1H "TC5zpT-xS;B_&*R*qocPIHy 2uU&dQoR@=E r; ( !h_ĨCbM"L@u,; Τr^,_pzG5IxqsՉ I 0R(j^.їр^fFr^/X))^CTDGtPs(*r{lo.uu))qn%%(f(/кB'!')X2+\22ЌV^R$% 'ҽ""T7 G!I@hh0GEgPk*a0+. {_I`X&+rLE`dOA0VU V! lpXu04{XP T`0 t@`ya pvP E@ 5Z\^`b|]! , H*\ȰÇ#JHŋ3jȱǏ CIɓ(S\ɲ˗0cʜI͛8sɳϟ@ Jѣ$!drZB`'RX!-<,X^u6. E:gôIw,W>6˄-3kM)_eͨ9@U4\d6lThX3=k Bt8|pp^l ZCpǨBBK&dƎ ?_e3 Ql@O{YY@}}<-7dG%7&H_9/"Df쀓; 8"vqAIјΊ7H{0` cv0&3{HB.9 >AJJ\ ]@Z W!4Yf$L6dpxgITv:J0EQgc pGdѧs4S+,u# W EÁ~K7;љsPx z:-DȠ!GWQ"k[,1}F HԢfk*\qEj䰒; +)88N(F(f(T' YSԋ!F;pĺSFJ4XܯNX4 k*`/OoʙeEA3f ^(C;E ?<]E&QcIE=8-4D;"I)MT4;72JOr6 dADMs6$ gI<0rRT '?PBgy~r{}`I.?Mrx(y'%yL9 H(i:o rM}kB[MDM Y4q &XPP$N0&-pW4T6 Lh1MZZ$$P5wtk0%y8o-K S8cB%v 'Lcrr6cǚLR(;cCȓH5/|Г3H2L>^@f XpLi'@ vr0i0p& p'UD5 !ɟ@S%q+AjQ5^@#L_`[<2Gy#hQխ.@2`S `!|"W  ,"R8oVDPPKͭnw pKMr:ЍtKZͮvz xKMz|Kͯ~L!?#bW0kU`[0 $/@1mdM h+X<0D`> h)P<ML&C nSíX3hye)g"9`ضr8u &H/vmb >7`;0@(V<"- !la] DHYH;a(3HD( $1j: v'8鐠H$x(qDAZT9؛H78Ē Do&l`)X(LPN4%t%) $)bX1M*N: fnτP9$?=`V@HfoH!0:XOt¨<'gaN%-ࣳD$Zτ -1p+M#Hm#w^d A6D@HF6 %8 *!'r r`H g.kU2 Q xv%:sq9,0 |)y( '4IVp@%{B4dV{5wx |&po2)1CIC qn f22^)v;cpshE+2c 6U2' ;,щfBzSpFb .q~.ֲ,20~/'@&.1rq20߀tBѠNRҦttIWZm/.'uJre))6y D]h P+@o LdPmdz2L PP $ ?702 k n"N4BO;Q,P@(kg>`qtF;a &ߐ8:Pyk>aiFO+|: oÔ` M"ځp>D qXmW%@WB!S41 CcIa!HJ `|E_@WuQQ0}0Dp.VcpmU `c@XV m 1T$P9  0O & i)#9Yy虞깞ٞ9Yyٟ:Zz ڠ:Zz #0pZeYuj9Dl7 IPUX~Y^[yfQU@ UaTv[ 1H "PTCEzpT-xS;Q pqeR[94+#qoc@@PPn&8j_WOhO&d'p ԃ_ 4#"a )ƒ%M:$$nu,;0R J,~4_1 w匸H.BG .z eFD0R|%E2;^CTDGt`'RX!-<,X^u6. E:gôIw,W>6˄-3kM)_eͨ9@U4\d6lThX3=k Bt8|pp^l ZCpǨBBK&dƎ ?_e3 Ql@O{YY@}}<-7dG%7&H_9/"Df쀓; 8"vqAIјΊ7H{0` cv0&3{HB.9 >AJJ\ ]@Z W!4Yf$L6dpxgITv:J0EQgc pGdѧs4S+,u# W EÁ~K7;љsPx z:-DȠ!GWQ"k[,1}F HԢfk*\qEj䰒; +)88N(F(f(T' YSԋ!F;pĺSFJ4XܯNX4 k*`/OoʙeEA3f ^(C;E ?<]E&QcIE=8-4D;"I)MT4;72JOr6 dADMs6$ gI<0rRT '?PBgy~r{}`I.?Mrx(y'%yL9 H(i:o rM}kB[MDM Y4q &XPP$N0&-pW4T6 Lh1MZZ$$P5wtk0%y8o-K S8cB%v 'Lcrr6cǚLR(;cCȓH5/|Г3H2L>^@f XpLi'@ vr0i0p& p'UD5 !ɟ@S%q+AjQ5^@#L_`[<2Gy#hQխ.@2`S `!|"W  ,"R8oVDPPKͭnw pKMr:ЍtKZͮvz xKMz|Kͯ~}M hBh#B"VF~ *Ah؅o[aD8D08"_P} $g0bna !Ј X# mÔ` qpxځ&S41 g` 2 dpUuU[@7@ Q `fnYk "C5Ae|` C{Y <0 i)#9Yy虞깞ٞ9Yyٟ:Zz ڠ:ZJ 0# WcZZ[@oUv)0XuXmXA2A jQ4c_VN 0 )  T_A5TG1%czo7b&CR)"70@i_@%Q(Qpu&dQpqoR@=E hN$a |&)R%M_#$pRu,;pR eJG,~4_(9W._.߰H."G -.z eFhvG^/X))^,{Gt<@z81ʼnhCA ^!3Xė4:^'A4$P};E? 4@ru]^#46+i^45(2C4J^ݷ3{Á%:C:5楳GWӱG9n!B^4F)~<};7bx${^Z#4]5`K(E^G#AKS I535PaR ـ<2;C2ʍc^Sz -@D@셷 g0R^2@wF}t-2+cI_ҷ*F ^.JשR _2s-*)_@9+q@t^%%#!djI(ERei^(-J% `# G!I@Ŗn0G#F1mPs*ajܑ+0j L噰!Pt4%VU 0Y! lpXu0`'RX!-<,X^u6. E:gôIw,W>6˄-3kM)_eͨ9@U4\d6lThX3=k Bt8|pp^l ZCpǨBBK&dƎ ?_e3 Ql@O{YY@}}<-7dG%7&H_9/"Df쀓; 8"vqAIјΊ7H{0` cv0&3{HB.9 >AJJ\ ]@Z W!4Yf$L6dpxgITv:J0EQgc pGdѧs4S+,u# W EÁ~K7;љsPx z:-DȠ!GWQ"k[,1}F HԢfk*\qEj䰒; +)88N(F(f(T' YSԋ!F;pĺSFJ4XܯNX4 k*`/OoʙeEA3f ^(C;E ?<]E&QcIE=8-4D;"I)MT4;72JOr6 dADMs6$ gI<0rRT '?PBgy~r{}`I.?Mrx(y'%yL9 H(i:o rM}kB[MDM Y4q &XPP$N0&-pW4T6 Lh1MZZ$$P5wtk0%y8o-K S8cB%v 'Lcrr6cǚLR(;cCȓH5/|Г3H2L>^@f XpLi'@ vr0i0p& p'UD5 !ɟ@S%q+AjQ5^@#L_`[<2Gy#hQխ.@2`S `!|"W  ,"R8oVDPPKͭnw pKMr:ЍtKZͮvz xKMz|w`u. 3P0),E.;J=ک $@$8 _ƙ*P-=xa#8Um Uai bAM,p`vP|\]@HR Dʙ/y9xEϜOܑS:A562A&@iE"5-_AM EG )p( "` uKp q`zN@a mk#:$qU˔'1ṖvQM ǖ)gڰd)SRk PfNQI*Ce€  N:$a0(Ʉ'<4ed-wƄ@&D!z70蘐@LdV`%)2A|I%m@$Ҭ\Pqm=NI%`0+X B$7v`f,bW.J Ȟ5I$%ƒY,dq]J> qۈ;x),1g=2O;Ҩ ~ZI.֞l B% F@FZ1 _uEeC{'µnA4R>=YvUqQ=jn=-.o|Y"y[>C+!Zd h0Te(tq4m6/6"4b6O>){{!1F34PB(Av)'9E5 0 cy){#:}x)'.h9.pPI KfvB3;7,ZyՓ)C;8s9`e2w6oBP,Ѓ@(R}(Qd&0!>O-QB()cqj,D:v2P@4|B 4` 1,' 5w &N 4 Q.1CIC qQ ~c8g+y;cp}twK:@n4bTSD]2c t8U2' ;68l4%&R}S0c :9p` U@54,(!.Rrd>0bm` ~K5C9- x.(0s:ۈw7D-PpH-'@J>9QuGr_+ >p,M ])0 *2b8 p&>0xbP} :$hb#g0epa " XS 0fÔ` qpځЎL&S&p)AfpUuF/DpVcpmU `nVk "4Ae|` C{Y <0 i)#9Yyٟ:Zz ڠ:Zzڡ ":$Z&z ۰,yEZeYsFoUvI0XuXmXA2A`kP UaTx[ 1H "+TC"F(xS;Q @xSfRn,60@9_@%Q(Q'pPu&dQ aR@=!F r; M) \m`CbM"Lu,;0q^B,_`΂eIcI_%.߰H(X?䥐G K.z eFhTwGE2zp5DEtDǍ^ȍ6tT1.~4# E}INA3^^@T\Ј?>B?`#j^X=X3ihx^ )`'RX!-<,X^u6. E:gôIw,W>6˄-3kM)_eͨ9@U4\d6lThX3=k Bt8|pp^l ZCpǨBBK&dƎ ?_e3 Ql@O{YY@}}<-7dG%7&H_9/"Df쀓; 8"vqAIјΊ7H{0` cv0&3{HB.9 >AJJ\ ]@Z W!4Yf$L6dpxgITv:J0EQgc pGdѧs4S+,u# W EÁ~K7;љsPx z:-DȠ!GWQ"k[,1}F HԢfk*\qEj䰒; +)88N(F(f(T' YSԋ!F;pĺSFJ4XܯNX4 k*`/OoʙeEA3f ^(C;E ?<]E&QcIE=8-4D;"I)MT4;72JOr6 dADMs6$ gI<0rRT '?PBgy~r{}`I.?Mrx(y'%yL9 H(i:o rM}kB[MDM Y4q &XPP$N0&-pW4T6 Lh1MZZ$$P5wtk0%y8o-K S8cB%v 'Lcrr6cǚLR(;cCȓH5/|Г3H2L>^@f XpLi'@ vr0i0p& p'UD5 !ɟ@S%q+AjQ5^@#L_`[<2Gy#hQխ.@2`S `!|"W  ,"R8oVDPPKͭnw pKMr:ЍtKZͮvz xKMz|Kͯ~M`AH:`b@AZK@P6b`T 4A>WP4MEb#F&H+莌:ţ h)x<M0&C nSíX3hye x(q 9`ض6  &H⪖)O6b';ϛ@Ajb<"- Sδa]ʼn`(R' eə@%D=Y-44'g`C?O@eR  :4)}M,B4hLx&P8ٰhJ@KRYKa4B4;  t@(ɗХ^ә"D2_G $ ]a \ԕF=9k 6J!xKxxI ,Qz3_ Y b:IOm#wo*ej " v;#W5NVD\aDI9#ŒltM(VJnBkP/-d#۷n 0 Jp@0#,h6$#)&6,H3Nz$$񐼮A v%o?P ίH)8,iJz3]0!M_I9a$$liCD?+u$#h6=m%4IS7$"@f"T6g:s='2C?:''pZ$h'03|s04Qs Ӏ:c33{' &`H-Pxh }EPhBP4k`{#G(A ~;\PV4)FP ) '4IVp@c0zj'A@Pwx s%!pR(OF34pg҄T4$wt70f A2pt)AuxRw4ukQE)s:Jv SpFb.XA,/.Rard HȲ)0- !.()$8/ID-P / zQ .0F-'TJ8Q0A6hg2G/d_ |1JLЄѰ Fz#L`+ P  A#58 !ic#N4BO?yTy:' B! AnYpB%V7@man8:PXD `(P0֑Ô` qPxځFvHB :2 )AE21`_@WuQQP}/DpVcpmU `@XV@m F$P9  0O ,& o}0$yy虞깞ٞ9Yyٟ:Zz ڠ:Zz #0p枪ZeY5[:D(P0 ~g7 Ip UXVp)[Vbub4 `_VF [ c3vTC"F%+xSC pueRR,70@I_Д%Q(Q6hu&dQrhR@=E r; ) !g_DCbM"Lu,;pΤy^,_PGIbw5HӉ0E" I-z eFhIu^/X))^CTDGt)~(*Ko.u)Œ1s%P%(g(/0«吳'!) xE]"2p^0R$% '&0"P"t yc_R%j6s_;ґ;. Ǘ_I` +!280EAX[`'RX!-<,X^u6. E:gôIw,W>6˄-3kM)_eͨ9@U4\d6lThX3=k Bt8|pp^l ZCpǨBBK&dƎ ?_e3 Ql@O{YY@}}<-7dG%7&H_9/"Df쀓; 8"vqAIјΊ7H{0` cv0&3{HB.9 >AJJ\ ]@Z W!4Yf$L6dpxgITv:J0EQgc pGdѧs4S+,u# W EÁ~K7;љsPx z:-DȠ!GWQ"k[,1}F HԢfk*\qEj䰒; +)88N(F(f(T' YSԋ!F;pĺSFJ4XܯNX4 k*`/OoʙeEA3f ^(C;E ?<]E&QcIE=8-4D;"I)MT4;72JOr6 dADMs6$ gI<0rRT '?PBgy~r{}`I.?Mrx(y'%yL9 H(i:o rM}kB[MDM Y4q &XPP$N0&-pW4T6 Lh1MZZ$$P5wtk0%y8o-K S8cB%v 'Lcrr6cǚLR(;cCȓH5/|Г3H2L>^@f XpLi'@ vr0i0p& p'UD5 !ɟ@S%q+AjQ5^@#L_`[<2Gy#hQխ.@2`S `!|"W  ,"R8oVDPPKͭnw pKMr:ЍtKZͮvz xKMz|Kͯ~L/@1mdM t'4Yi+ 41՛ %ALb ΠAh` uXq#\0 MD r0 0mm"P `2$jd# r 2JM=P @AΨyl LPr jOj3L PfNQIcBM{ yGO0p!AAI&qQb< ;* ӨF>"e$ ^c.aKJB-S4p)v?ng(xCD lH؞>hiXIF {(JhY8I}]\WY9$g ҟ:͝sIy`C?:~:gB3@;7(1 (ـ 'g}8s9cS' '`H-@ SvGEoBP4Z4P6&A ~;\P W4(AwxB a'` )?3^dPwv a)QEI3)1CIC q!=IPUXc3f A2ps(qbxp0c Vl0' ;sK wSpFb -/cPvz+-u.'o80ʀTu xC..I<D-P0,Q .D-|,.'Jr!*1GҎ$N2J{LDŴNR(K4 dWMq9 '>0"$Oh 60' B! AlYp;!ka 0% X(0R(z].xt ^fF'sUx%tǪ!z5D4GB5"|B]|pa|!7ٕ *%j#3%R)%6˱B%: A9W H7{8]"сB@]4~<-+ݕ0pB]'4b+)D]G@I53 K# ]!3F0]w P-@D@E ?E0R^2wH@ HuF}t-'ae'~+H%dI2^+D[xu))+r%%(g(Bq楏 @@qt]р%%0!'^@ e$1 )&0"":7@ I!@Vk0GEiPP8ap+. ^I`v&8f/sLu_!Pt}P(PmY _a -Vu0XXP T`0 t@ua pvP E@ [V[5r`'RX!-<,X^u6. E:gôIw,W>6˄-3kM)_eͨ9@U4\d6lThX3=k Bt8|pp^l ZCpǨBBK&dƎ ?_e3 Ql@O{YY@}}<-7dG%7&H_9/"Df쀓; 8"vqAIјΊ7H{0` cv0&3{HB.9 >AJJ\ ]@Z W!4Yf$L6dpxgITv:J0EQgc pGdѧs4S+,u# W EÁ~K7;љsPx z:-DȠ!GWQ"k[,1}F HԢfk*\qEj䰒; +)88N(F(f(T' YSԋ!F;pĺSFJ4XܯNX4 k*`/OoʙeEA3f ^(C;E ?<]E&QcIE=8-4D;"I)MT4;72JOr6 dADMs6$ gI<0rRT '?PBgy~r{}`I.?Mrx(y'%yL9 H(i:o rM}kB[MDM Y4q &XPP$N0&-pW4T6 Lh1MZZ$$P5wtk0%y8o-K S8cB%v 'Lcrr6cǚLR(;cCȓH5/|Г3H2L>^@f XpLi'@ vr0i0p& p'UD5 !ɟ@S%q+AjQ5^@#L_`[<2Gy#hQխ.@2`S `!|"W  ,"R8oVDPPKͭnw pKMr:ЍtKZͮvz xKMz|Kͯ~L/@1mdM t'4Yi+ 41՛ %ALb ΠAh` uXq#\0 MD r0 0mm"P `2$jd# r 2JM=P @AΨyl LPr jOj3L PfNQIcBM{ yGO0p!AAI&qQb< ;* ӨF>"e$ ^c.aKJB-S4p)v?ng(xCD lH؞>hiXIF {(JhY8I}]\WY9$g ҟ:͝sIy`C?:~:gB3@;7(1 (ـ 'g}8s9cS' '`H-@ SvGEoBP4Z4P6&A ~;\P W4(AwxB a'` )?3^dPwv a)QEI3)1CIC q!=IPUXc3f A2ps(qbxp0c Vl0' ;sK wSpFb -/cPvz+-u.'o80ʀTu xC..I<D-P0,Q .D-|,.'Jr!*1GҎ$N2J{LDŴNR(K4 dWMq9 '>0"$Oh 60' B! AlYp;!ka 0% X`'RX!-<,X^u6. E:gôIw,W>6˄-3kM)_eͨ9@U4\d6lThX3=k Bt8|pp^l ZCpǨBBK&dƎ ?_e3 Ql@O{YY@}}<-7dG%7&H_9/"Df쀓; 8"vqAIјΊ7H{0` cv0&3{HB.9 >AJJ\ ]@Z W!4Yf$L6dpxgITv:J0EQgc pGdѧs4S+,u# W EÁ~K7;љsPx z:-DȠ!GWQ"k[,1}F HԢfk*\qEj䰒; +)88N(F(f(T' YSԋ!F;pĺSFJ4XܯNX4 k*`/OoʙeEA3f ^(C;E ?<]E&QcIE=8-4D;"I)MT4;72JOr6 dADMs6$ gI<0rRT '?PBgy~r{}`I.?Mrx(y'%yL9 H(i:o rM}kB[MDM Y4q &XPP$N0&-pW4T6 Lh1MZZ$$P5wtk0%y8o-K S8cB%v 'Lcrr6cǚLR(;cCȓH5/|Г3H2L>^@f XpLi'@ vr0i0p& p'UD5 !ɟ@S%q+AjQ5^@#L_`[<2Gy#hQխ.@2`S `!|"W  ,"R8oVDPPKͭnw pKMr:ЍtKZͮvz xKMz|Kͯ~L/@1mdM t'4Yi+ 41՛ %ALb ΠAh` uXq#\0 MD r0 0mm"P `2$jd# r 2JM=P @AΨyl LPr jOj3L PfNQIcBM{ yGO0p!AAI&qQb< ;* ӨF>"e$ ^c.aKJB-S4p)v?ng(xCD lH؞>hiXIF {(JhY8I}]\WY9$g ҟ:͝sIy`C?:~:gB3@;7(1 (ـ 'g}8s9cS' '`H-@ SvGEoBP4Z4P6&A ~;\P W4(AwxB a'` )?3^dPwv a)QEI3)1CIC q!=IPUXc3f A2ps(qbxp0c Vl0' ;sK wSpFb -/cPvz+-u.'o80ʀTu xC..I<D-P0,Q .D-|,.'Jr!*1GҎ$N2J{LDŴNR(K4 dWMq9 '>0"$Oh 60' B! AlYp;!ka 0% X /b\M7R2w8\>^D4})BE_G3@WJ,õ|BP O'\*}3T9b0\31\W[# 4ZH\:-> B3DdS) \!2R_p\@HuA! gZ\Zv2?u\>@v(C '\o.xAQ\ Wa\)[oڎZv\@@qkn:%k'Yo] 2i]orP]7@sZlՅh6st[QH` A] ~z. Z_ { i p۵f/sL[[ ] PSj ]@V큚U  @7zV*+p@9@t K\ EP vp a _ɜɞɠʢ<! , H*\ȰÇ#JHŋ3jȱǏ CIɓ(S\ɲ˗0cʜI͛8sɳϟ@ Jѣ$!drZB`'RX!-<,X^u6. E:gôIw,W>6˄-3kM)_eͨ9@U4\d6lThX3=k Bt8|pp^l ZCpǨBBK&dƎ ?_e3 Ql@O{YY@}}<-7dG%7&H_9/"Df쀓; 8"vqAIјΊ7H{0` cv0&3{HB.9 >AJJ\ ]@Z W!4Yf$L6dpxgITv:J0EQgc pGdѧs4S+,u# W EÁ~K7;љsPx z:-DȠ!GWQ"k[,1}F HԢfk*\qEj䰒; +)88N(F(f(T' YSԋ!F;pĺSFJ4XܯNX4 k*`/OoʙeEA3f ^(C;E ?<]E&QcIE=8-4D;"I)MT4;72JOr6 dADMs6$ gI<0rRT '?PBgy~r{}`I.?Mrx(y'%yL9 H(i:o rM}kB[MDM Y4q &XPP$N0&-pW4T6 Lh1MZZ$$P5wtk0%y8o-K S8cB%v 'Lcrr6cǚLR(;cCȓH5/|Г3H2L>^@f XpLi'@ vr0i0p& p'UD5 !ɟ@S%q+AjQ5^@#L_`[<2Gy#hQխ.@2`S `!|"W  ,"R8oVDPPKͭnw pKMr:ЍtKZͮvz xKMz|Kͯ~L/@1mdM t'4Yi+ 41՛ %ALb ΠAh` uXq#\0 MD r0 0mm"P `2$jd# r 2JM=P @AΨyl LPr jOj3L PfNQIcBM{ yGO0p!AAI&qQb< ;* ӨF>"e$ ^c.aKJB-S4p)v?ng(xCD lH؞>hiXIF {(JhY8I}]\WY9$g ҟ:͝sIy`C?:~:gB3@;7(1 (ـ 'g}8s9cS' '`H-@ SvGEoBP4Z4P6&A ~;\P W4(AwxB a'` )?3^dPwv a)QEI3)1CIC q!=IPUXc3f A2ps(qbxp0c Vl0' ;sK wSpFb -/cPvz+-u.'o80ʀTu xC..I<D-P0,Q .D-|,.'Jr!*1GҎ$N2J{LDŴNR(K4 dWMq9 '>0"$Oh 60' B! AlYp;!ka 0% Xk7@ ^60@igk'^m+)@]V"&p Գ;@v}])9!(0JeoRl!Y@q];0R p+!1DpjH fGβrٕ.&v c $:]0 90z]*F t*!z0Q C]7]\?-E>#]'4KP)q{;ѥ7(](4װuυ:;J ]H{8$C65{S'ŵ) 8\(4, 4 #]>+4~<,9BC4ӵBz|o0]-% /X4)nuXe!P, 3pϥ%кDdG3h]!2Rc]@Hu1q g]Zv 뭦]>Ъ (3 J-YШF '؎] Wa\9.׫]-q5Ki.ߛ P/%'i*&0] 2i Ov ܕ7@8q]@Vk0GA`l k^ >. hP J^ k28QF F!fYe%V큚U PT!DPdpXd&_XP `'RX!-<,X^u6. E:gôIw,W>6˄-3kM)_eͨ9@U4\d6lThX3=k Bt8|pp^l ZCpǨBBK&dƎ ?_e3 Ql@O{YY@}}<-7dG%7&H_9/"Df쀓; 8"vqAIјΊ7H{0` cv0&3{HB.9 >AJJ\ ]@Z W!4Yf$L6dpxgITv:J0EQgc pGdѧs4S+,u# W EÁ~K7;љsPx z:-DȠ!GWQ"k[,1}F HԢfk*\qEj䰒; +)88N(F(f(T' YSԋ!F;pĺSFJ4XܯNX4 k*`/OoʙeEA3f ^(C;E ?<]E&QcIE=8-4D;"I)MT4;72JOr6 dADMs6$ gI<0rRT '?PBgy~r{}`I.?Mrx(y'%yL9 H(i:o rM}kB[MDM Y4q &XPP$N0&-pW4T6 Lh1MZZ$$P5wtk0%y8o-K S8cB%v 'Lcrr6cǚLR(;cCȓH5/|Г3H2L>^@f XpLi'@ vr0i0p& p'UD5 !ɟ@S%q+AjQ5^@#L_`[<2Gy#hQխ.@2`S `!|"W  ,"R8oVDPPKͭnw pKMr:ЍtKZͮvz xKMz|Kͯ~L/@1mdM t'4Yi+ 41՛ %ALb ΠAh` uXq#\0 MD r0 0mm"P `2$jd# r 2JM=P @AΨyl LPr jOj3L PfNQIcBM{ yGO0p!AAI&qQb< ;* ӨF>"e$ ^c.aKJB-S4p)v?ng(xCD lH؞>hiXIF {(JhY8I}]\WY9$g ҟ:͝sIy`C?:~:gB3@;7(1 (ـ 'g}8s9cS' '`H-@ SvGEoBP4Z4P6&A ~;\P W4(AwxB a'` )?3^dPwv a)QEI3)1CIC q!=IPUXc3f A2ps(qbxp0c Vl0' ;sK wSpFb -/cPvz+-u.'o80ʀTu xC..I<D-P0,Q .D-|,.'Jr!*1GҎ$N2J{LDŴNR(K4 dWMq9 '>0"$Oh 60' B! AlYp;!ka 0% X`'RX!-<,X^u6. E:gôIw,W>6˄-3kM)_eͨ9@U4\d6lThX3=k Bt8|pp^l ZCpǨBBK&dƎ ?_e3 Ql@O{YY@}}<-7dG%7&H_9/"Df쀓; 8"vqAIјΊ7H{0` cv0&3{HB.9 >AJJ\ ]@Z W!4Yf$L6dpxgITv:J0EQgc pGdѧs4S+,u# W EÁ~K7;љsPx z:-DȠ!GWQ"k[,1}F HԢfk*\qEj䰒; +)88N(F(f(T' YSԋ!F;pĺSFJ4XܯNX4 k*`/OoʙeEA3f ^(C;E ?<]E&QcIE=8-4D;"I)MT4;72JOr6 dADMs6$ gI<0rRT '?PBgy~r{}`I.?Mrx(y'%yL9 H(i:o rM}kB[MDM Y4q &XPP$N0&-pW4T6 Lh1MZZ$$P5wtk0%y8o-K S8cB%v 'Lcrr6cǚLR(;cCȓH5/|Г3H2L>^@f XpLi'@ vr0i0p& p'UD5 !ɟ@S%q+AjQ5^@#L_`[<2Gy#hQխ.@2`S `!|"W  ,"R8oVDPPKͭnw pKMr:ЍtKZͮvz xKMz|Kͯ~L/@1mdM t'4Yi+ 41՛ %ALb ΠAh` uXq#\0 MD r0 0mm"P `2$jd# r 2JM=P @AΨyl LPr jOj3L PfNQIcBM{ yGO0p!AAI&qQb< ;* ӨF>"e$ ^c.aKJB-S4p)v?ng(xCD lH؞>hiXIF {(JhY8I}]\WY9$g ҟ:͝sIy`C?:~:gB3@;7(1 (ـ 'g}8s9cS' '`H-@ SvGEoBP4Z4P6&A ~;\P W4(AwxB a'` )?3^dPwv a)QEI3)1CIC q!=IPUXc3f A2ps(qbxp0c Vl0' ;sK wSpFb -/cPvz+-u.'o80ʀTu xC..I<D-P0,Q .D-|,.'Jr!*1GҎ$N2J{LDŴNR(K4 dWMq9 '>0"$Oh 60' B! AlYp;!ka 0% X3^'4Kpq{; 䥂7# [^30:A;^Q{8$%^[7]_^e8 {pO ](4 4څB5L(^;(4L! 07;+D2^IaFB3 8^ ]:-[:^-@D@0q@W2)#E rhTpЩ"GUeઌ*p"/YF k;'j޶^ W2˕"rzw;O^@(i$@z ^2}B ;"0 ^ 2iOv l/h6spa45Eep+.  Qr ;_ k28P4! FfmV S_P(PmYk>@MEf e @Dy@@t . M vp M ',`5zʨʪʬʮʰ\! , H*\ȰÇ#JHŋ3jȱǏ CIɓ(S\ɲ˗0cʜI͛8sɳϟ@ Jѣ$!drZB`'RX!-<,X^u6. E:gôIw,W>6˄-3kM)_eͨ9@U4\d6lThX3=k Bt8|pp^l ZCpǨBBK&dƎ ?_e3 Ql@O{YY@}}<-7dG%7&H_9/"Df쀓; 8"vqAIјΊ7H{0` cv0&3{HB.9 >AJJ\ ]@Z W!4Yf$L6dpxgITv:J0EQgc pGdѧs4S+,u# W EÁ~K7;љsPx z:-DȠ!GWQ"k[,1}F HԢfk*\qEj䰒; +)88N(F(f(T' YSԋ!F;pĺSFJ4XܯNX4 k*`/OoʙeEA3f ^(C;E ?<]E&QcIE=8-4D;"I)MT4;72JOr6 dADMs6$ gI<0rRT '?PBgy~r{}`I.?Mrx(y'%yL9 H(i:o rM}kB[MDM Y4q &XPP$N0&-pW4T6 Lh1MZZ$$P5wtk0%y8o-K S8cB%v 'Lcrr6cǚLR(;cCȓH5/|Г3H2L>^@f XpLi'@ vr0i0p& p'UD5 !ɟ@S%q+AjQ5^@#L_`[<2Gy#hQխ.@2`S `!|"W  ,"R8oVDPPKͭnw pKMr:ЍtKZͮvz xKMz|Kͯ~L/@1mdM t'4Yi+ 41՛ %ALb ΠAh` uXq#\0 MD r0 0mm"P `2$jd# r 2JM=P @AΨyl LPr jOj3L PfNQIcBM{ yGO0p!AAI&qQb< ;* ӨF>"e$ ^c.aKJB-S4p)v?ng(xCD lH؞>hiXIF {(JhY8I}]\WY9$g ҟ:͝sIy`C?:~:gB3@;7(1 (ـ 'g}8s9cS' '`H-@ SvGEoBP4Z4P6&A ~;\P W4(AwxB a'` )?3^dPwv a)QEI3)1CIC q!=IPUXc3f A2ps(qbxp0c Vl0' ;sK wSpFb -/cPvz+-u.'o80ʀTu xC..I<D-P0,Q .D-|,.'Jr!*1GҎ$N2J{LDŴNR(K4 dWMq9 '>0"$Oh 60' B! AlYp;!ka 0% X&^ 0bv( .DY^I 0R(q .z &hs#A" ^M'@C]$A)]$z^E!!E5L]\?!6&Uֵ|Ba6ޥ788݅:;J:]F{8Z nX*+(T+%p{ՁBM!xI]>+4~< >Bӂ޵B4]쐵rDD{X4p{qwDe:"@! XޥK2w6^-]@Gv+^!2RT‡TOt@^ZvJ/ЇdIr̘mF g>@q'R] RJQPнڎʪ,F%+)b$ͦ^2}/=(F0 )&%a PP`$ xcP`QC ^@Vk0G5_t :e`s{1Q vWf/sL'@` i*_P(PmY ?b.0nkp%_XP T`0 t@,C1a pvP E [`5ɒ<ɔ\ɖ|ɘɚ\!, H*\ȰÇ#JHŋ3jȱǏ CIɓ(S\ɲ˗0cʜI͛8sɳϟ@ Jѣ$!drZB`'RX!-<,X^u6. E:gôIw,W>6˄-3kM)_eͨ9@U4\d6lThX3=k Bt8|pp^l ZCpǨBBK&dƎ ?_e3 Ql@O{YY@}}<-7dG%7&H_9/"Df쀓; 8"vqAIјΊ7H{0` cv0&3{HB.9 >AJJ\ ]@Z W!4Yf$L6dpxgITv:J0EQgc pGdѧs4S+,u# W EÁ~K7;љsPx z:-DȠ!GWQ"k[,1}F HԢfk*\qEj䰒; +)88N(F(f(T' YSԋ!F;pĺSFJ4XܯNX4 k*`/OoʙeEA3f ^(C;E ?<]E&QcIE=8-4D;"I)MT4;72JOr6 dADMs6$ gI<0rRT '?PBgy~r{}`I.?Mrx(y'%yL9 H(i:o rM}kB[MDM Y4q &XPP$N0&-pW4T6 Lh1MZZ$$P5wtk0%y8o-K S8cB%v 'Lcrr6cǚLR(;cCȓH5/|Г3H2L>^@f XpLi'@ vr0i0p& p'UD5 !ɟ@S%q+AjQ5^@#L_`[<2Gy#hQխ.@2`S `!|"W  ,"R8oVDPPKͭnw pKMr:ЍtKZͮvz xKMz|Kͯ~L/@1mdM t'4Yi+ 41՛ %ALb ΠAh` uXq#\0 MD r0 0mm"P `2$jd# r 2JM=P @AΨyl LPr jOj3L PfNQIcBM{ yGO0p!AAI&qQb< ;* ӨF>"e$ ^c.aKJB-S4p)v?ng(xCD lH؞>hiXIF {(JhY8I}]\WY9$g ҟ:͝sIy`C?:~:gB3@;7(1 (ـ 'g}8s9cS' '`H-@ SvGEoBP4Z4P6&A ~;\P W4(AwxB a'` )?3^dPwv a)QEI3)1CIC q!=IPUXc3f A2ps(qbxp0c Vl0' ;sK wSpFb -/cPvz+-u.'o80ʀTu xC..I<D-P0,Q .D-|,.'Jr!*1GҎ$N2J{LDŴNR(K4 dWMq9 '>0"$Oh 60' B! AlYp;!ka 0% X@v,sIF g'@r%&, j$^-qج/!˚ 暑<`| ]2}5 @ )&0*1+!, xc eT~z^@Vk0G;Hzݺ^ ."5ph0if/ӥ8?f( lf^ 5 %Z U лBcpXlcˀ@@3t KB@ E@ vp aP _|ɘɚɜɞ!,!,;napari-0.5.0a1/napari/resources/logo.png000066400000000000000000005340531437041365600201650ustar00rootroot00000000000000PNG  IHDR[V pHYs.#.#x?v IDATxkuՍFh} f8RH+ ZrbCo~j_x#vWJWK-5KK+R!RrtݍQO?cP$M,} 7ӷH   ?к>(^q$  LI7( 7VDr3%  "6hh]\N$/Ք   (VFv*I^qjJAAD[}w{$kQ6"   }$-fI$AA"Q , .   "-+jREAAAt߈ղP$HAADR,&! אH$  bwcWsiuS   zB4F-   &?{|7t͢u=~    fAy2fP   Rh߻~\F)9"   O.n7n~1\AAADqTBqh^C.AAAl1swSB{]3g`ǏMKzylhܔN#U;k OwB\n~}\Q+k-B2%Yچ9JWpWzWE)=x6 CuŎƢi! O;Naǧɉq'+qOݲ<mk݌z @_~˷ qFSJyac~=':2=^A`s3/蝄\DCř!+ZB[%$JY%U'r?KV>IT@KS}Vq-M}(W9=sΜf'OՅaM ' pB QʴΐrG[5Bi)<8S.]~E<%Qb,eHC)VoD%Y[0.@(¡8Ŗh)g' TX/^ ~K+\\}YUX\O;v855Nαsg7t4}~b WYra\ߏ UW=&Ʌ4 o[/?t liʖh)f碻)O^p=]^f=c;qQP7y!Eqm." QxEpp͛-ISBt?5;> z:JF-q ?۩{|ZY%EHN -oLjE H==3P,bo~L. dX ../Oc|-lEwH0. Uŵ}Ajȶq и+2hz\tF7)ͬgåCA ]-.D"Z)esSȢ(A~p%eͱS٣%т8maXǦ_,Nc+E 7uV~p(wT,E9bX9]r!n oQı)1ڰ@(:Ȣ("caMKLٽ.q1e{/*E16kb^z6;sS6HSLF~mv~ "/ŗo+8c涡(zf ؽ&,Z4]B,q* 7ͅ"¢ܚxEv|8mԩsG;s/qfSry]_,CNf._*9pz}K uj948~]Eɖ- L[DU dz1D>#D~ObppPt šit{\O4qRlYF< ݫDp/YxB|Z<'BH+0_щ!ηE15/ 'c}\&[Ũv^ I tH$ :6/fL_E/?b'O]_B-TB3ة3#7<o WsQҵ7J[%a¿2E^Rڠ@Hrԭ1B%܋z'g¡&v2G\bC3s&Y7nr+gySyθ_ѳBrK:H,5lBתz0R`*`k;:UE|L8Ts\GOΧE1itm+:'1";ŢKe`r2PD Ҫ1졈AbOqh",GB[=7} EtŅGI/Fվ.>tIeVw } 0?$\Db0Wͳ%:%h& lULZ(XMYyбcbv٥6ө?%fl8@ўd 7Bb։w҂&e&wӸlULS(2?dľcͨW|Pb.*D<ZgF8T]q絣'ИJnK"{՞u 5aeQ vZl17s ׇ]*] G߅ 4 k=1ƺ6Љތ``?۪ ȅGj}I(r)sƯXnjʡh$aEY)s04ԉ\NEyɩp8q:.kvdSTS_BUݪ2bM)[njЍ vY$"T E(r>"<({`M`^w$7|vfoatESCTrgҤڰF~$YǦ#\8Y# s{[Oܜ}z:/~2S-/h"j}.3OFֶPZ3]0GB<D,| ŷ`[e%!$َbizXXۥM DK6\H\!84&K#+{Db͊؃0Buo(zeՆtc&݀=Ч @f451>+`׫o[cpՅMCe ElWDbU)TLB{?UM-GkOD4Ҁؒči۷w=L$uڄI ,hs!݀>:u 7V>aQ "cQDD.&.mC:% 'IMqt~}xbvµ~ΌH̝%uaE3n/E4:zzpbDOzX\}S>ؼd#J aJvv(ܠSpbx2"ISg+}9HW}IY$rK$S=kZ;>fWH.@+' ȾD!dUun98H!'2.Kµ}އQ(,X}j^a *}O"qV݄HyF t-d#G1Rj2 w]] KbKqI\@HxY$jU|呖GyI M4Z͕ a__>/z -djOr]R\$ҽ(F@tCDk!I r= Fݳj/%X˩[\P X`zC?X\ҘIXVaD7$&EX}R6)UL\Cԅ,j$FJ=Qdh@$IV%&3\H`ÛHtM"FtvAcQu^ ڜkco\ G L:-:sw:zas[c54%`֎ R0B5ƱRdP U7:r]+&KNHq^Ř8~(Kx<(\i_Ǿ42̾]hb,O֪.^k};= LtN,@/2Ж뚴]"$8zJ8hh^02iX$z 짨6u)Lj4֋8P '$8% b_{kg[\O{&B,nlrύkclEsVUӛ1NIT@BWˆo$rAȬOs=ţR{{YT DyoVDag#K(FuF<6~/pxAj_ 4Zn +͗pDZ#}-kB]GZV|{bUP]BCi ݀"e.j8 cLڣQ!fMޒ^̠J?IJ#mr[VO_P;)c#lZE.q!گ$*bpKؼzAHt?i jicsЖGYh(:+bUچpCpDx"s|ь5b.J ̼{g_>*<}NZINGWpG:̜ NI{jݫP$ʝ?VA/nnYo>.jjсF>FTlJ[{` [< Ї9J#ii~zɭplyREC|ϳpR<) u,h!!à(jQ`eM;qblMD61nH$B <^,8ޙ皿!%kBC@$H4PwAZidxR?1\CpЗy2$KI$i588^g{q yyse%*~17IKIJ_FnZtdܑU߄VPSrPK#Al*&1&=f7iG,NIi/y~6.(⩫ 0 ¬E2ag\<!_邘H Pdji;CED[+q:F֓,[ވZN镇RZD0~ X[c=Ell<:@)#d"&v[lFw3a4?@G'wOXg)5R,:?cy@"1EWMQsؕmzbuvPx"\jC97H FKs@非ZY{.?kұi^ ]蒼 cc8`_V;C,d5B2 Qھ=1zGjm]cj?=(~peW!Gۯ ҡXV.oBDhDpjpFvs^} " !ϞH1IvH(R`aby8\Π<`g̭zeǨi^" ra;k W+yBҘ˓b(Y ') #dHSDd=hqn]pJ?YyugIfhMD(Uv˸ji#ku[$3ƂH6$ FJ4?1=FEɲ4E35HpEIi<; CA"x'`\0P8&%A8\LI$ABQ |NXʤVЌYg/q4>pr>vO֕m1FG~IirNbjo,D;$ɬΛ*\珐tX0JtS,jSmblH#,BUAb\#Bpͥ)໪4È {y_lDft𾅆 +x&/sj& u.,3{\AXFl9P[b6!(FG>!&E+bt%T" `h L~l-{X,Qq]8)|{08q}Y;k{;u􎟙b'wen_/orB/ᚉ۩K飈DL>D'9>QqY@êhdXl vv k_vv&gKϹB,Sπə 1y4gljo=>Dbh+LZr4E1ٌ]jvAs6ؕ E4}>ʏMlO,H~m RHSl4;z;vd;ؑ}qŏpB9,lGg:*DoG $zOPDߠ >wvl0\LE^rVb21uM>5@;r;HH<ƹ/%[9:v)s+O=%;}h xׯXd ܠ¢2maly{?{v7xB*g x<¦{;Q[iPYXIZS HC4kC ϝdaGwJ:mَrY*"'m]bNj.YyF!sXo[ZO,̱6OFbq$Cb N9z냲KK ٮܧ|[4r!\@<\ IDAT^"(zQeHi+(\d\/E1z1ͧ_#ӽ ƖJISX/l<8\g'e'wU*]4( Fw$Pd]w;CӔ['{^BƭY)A(hb^?OA筋*2&s>xB8.qvqϢ1I6h]h(ExݍJd Z_ `s-IV  ,¢}1;q b1Y_[a{l#r-cJ"b()"D1Mexm׸6?ŀ/y=XMacLr :SrV qEw%){agh?6Ît>1ayi B ٘eVe9ZT^ylyVkhUWC &{SHbQBE8m9bœUr/Mh,4Ν;f$<3E/[$h(bbLf nR.IDK2LnZ]S@,i\s5H vbNǃpN]f`'ਟgh a5ЪuV'5+= AB#q)ЊyƧ ?ċ[l 9@U+ ɃI=cRx6^~!LJ b'Z4ӇPP&LQ "vLH(jR撡.(T|aOxL *[}HVǷ>bO|v4 اc[0d՚>h\3)&s!B|Wp EQI/Nk!hT2 ɉ.J{g qaA\iQ 2IPɍX:kJժe@Fqz2w *Y!$ZN.)zMd+/dG3^aZũH*&il1p;>z[ $= EX(i ac0KoVyoO1᭏9v\m;> juXʹr;:lXfvS. D'$K2`+(J@ђ(C s3 |&)5p?>ER6\ q2fR`r%2OD!q}H( vnQ 9YmH$J,,b2W߭uɊHNa]\s`uT^'6v pWM4kđtb 8٪Dd9Q\P۫m뚚WWH,Vͻ8*ODjų/]a[iT]ƅQJOr$J)۩hgc; -1{II ѥf"YXC$yGnR¼GYHdY\̷ӏѾDr}Jcv)6q@E7@2S,D'B1^2"1ҏ*%>򳮳BhF8DqE Oz%MȟŇ1Nk/c{xQct^W/F-bmgl{|돗G'6ŋ={z1>0 ;zKKj!?WuP i3I#ӡWE5KH`g\ScOk{lwcG3l$;fMLNNٓSV8ql]\E>2çlk{=Y*D>x;ml{SpA"Wɋhfk'8/#!:)V.'zTA XЀ`줦N.9ǩ *ڒFK z`Ma |{r$;?;ŦzlUogϷ{+ⳍ5<>:3.cXD Fl4D #uü.nǂlS8KC -$J@BAG4p@ Fbaْ4*/?aKIRy{ vXwzv~zk YxjoX ln/iMW>[ 2T+)Y=2&귺r1DbWHgÎb80EOf td &Vh99rvλno}2{va/pǻ٭+wuepEv+VEgr${'D`Rt=&`pq[\ n :̅Ěh\HA<XY0%M?ŅwB~[:fg&:[O ^lsk}p{"}ygŪ;/]b&rqM|v}h-`ʄO*gJш,S-]@/Y I@y#~fHB:JE~lc)9v/S1I֍{YѸ|!ۿt?zB8o1w 41\El0R2w֌AOtT?4ΤOǙnC#W.̲n\d_Y.*%vδꋇO)ve 4A hXF H 'HRDLH%p߳d{H(v{X7(aDZ ղhQSwy [c~j){|'ZKM]! /Kfx5'8lֽ8-)=?:+^srXi Mt78`}?|B ~S͟2p$S͛/~"8X=.T]ZglHLj[&]{jzx(9{Ƙ)Hm/փ0RG.]sJpͼopǥFWpu`"⡴2[nvKb y`g#C*~YqR?q4s|z7u] /yK"1XMH$ SM ` oh? ?5~,pњ{KœE..%H AFçI֣/%/0IO]O||7b&PV+Ѥ=i $@iw $|mf wPm ^[/ZS@L-l+Ͼ{+I cq<82ɮrq%.$Jڝ\65loɤP&g2&?Dސ>=+n%>Z*g1^,iIŢ5^ vB0~cMn2{x(4u#[B+eoȬQxҚHI !(bvJ u#'օ/}ɊO~}@KjwvR[gLz-Lpq}‹s 8-|h.140oA0B `w8%$P=1J0kR_i-ilZ_|c`"I{0eӯfy_s:: ݼ/EtBMMCG'V :ZktV(B6:oTEQ0fWv&{t;B5ʵцD)^Hjû+/Ħ{(ZW[%gy&P}@&CˆHkM- D%zaQH$K0 E/ȸ44x[y /}!rbg譋+gKY9Id8&(IqͤExv9C#D2z*/~7¨NuDEfHɚY!Ebf͟Ʈ^~QX߼譋JOgOs}x=o|xgҌZ^pJGe1z"Q $tWՆP$ E >U1@H*mkXk6\IDbW>}K8T?Np3<4xGVkR$sU'SS#mH($,sؠ6XGHj6R.>~rBKxQ<ğXo)=Suk j0{vi -TD5%cTn,]@36gҔľYw[?Z a{~p;VDݦ>[צ67#8ſ|&[f'ԯ)т%Zʼn΍#ŚE|G&*^Zh( BX$!Lo#+Օ[m'@%bE\98v d;l-UOMfɁs>c;33.aS3ӠYElk.=2|[C4/H5K7z E)!aG G(L15cT39#kIZ7'1`;?8 ${?\-vx-v.M(~IZ"Ŏh]Q DzT݂ JeI϶WggطFR+>ݟfO[{L#Wu61r-x6.Nnǘ]Q6 jV']kjMlcjڊax3Ǥc# N(ڒS$2P߀VR$2v+ϱo}$E,Is}mま |Uhaehq?ա??>p~d]b/ٌ]N_:7VE}v!R ( c\.05JgϕC&MDVzo8l TBO w{3v&:h|Y 1[|Ň؈4u=ɾ7.c^.bd͓ :Yw‘h2LE?_,_?D#_<|\{kxZa-&?v5JE5o$7Z@6E%*Hbhzlfd\SJh. I@$jzgwp{?e,,L R<[GVٵEshy̮rۀ@}-vtWpjؚ}GFrA\.NSb*@ <ẟ63<ĘW5-ٙebefEWH^0\'wvى9v}r}njc~"7O>`?)ͥe>;v?$86ۄ_ 745i$ 6#@a1FPmQy)k(@kwF-/~r0_,6 Ql/LhZFbQV >?>7`&_xͳe6UQp]@czIqFN ~, I/HhL/J I~"2A$*r-=GKl2wl,_{;:X}2H^B0ѐB0B;T\Fl|oK^,O fhV#\?u kjo=ODTX Mx8Tm48r9d v\zP!%UvB8Ɩjm?Z[b+;˖b>biiP,R"wGQާ)]Ȇ?LtbR0< .PfxXX1(Y\+} ai;Zق#Bq8X9Rz4fX4CR(Z8bߴ $E?(Pmyws9jKX),Yi<ϣ0а"rl<ƱŹ_Ot໻3_o\bI7w VŝI'̕:g0 :%8lpPˬ 10`?&˅I.x, ŞbvKf_{v t ~(JOmͱmjٔ{[AF)sF̱?޼Ȟ*_|3~ko˿dik SERXF)~0Ѵo=\tȪȢX a]0SJ,rs0.&6]lՕuhd T x-GQXf_L4=E;'5P/ Aǂ{Goc= o_}R쫸kiUTA|b{x XCQO63qJÒNE$"nb--XyR dUmu B$>&_o.[G(rUaTjGR(zjwNO?c&x|ՠ+.s1(^z2,>;i}ZAHeM+S8BxF]&dMx\Ou|H્**f-~V!oƱ@reDO?X?* ٢º8J; %Mci] 1|E9m ׈0zE`y6FRdIT 6C 񝄼.X<"o".}Ū"P$ 6ڏw1}P챜{ Zʦ+Gٟ_`}m䊏_%3%Mc(y-+-:Gчp.:?Z᲌5TN't猛Sua"wۜ"!_m"˚U$*Z#Z P4ħ;@IȜne C YPW6-v<{o(,^.Ԟ`ze@QEaBmcwOp׾t]nϞqaM\\V,&z^W5,ʱq<mN-0 ټԸ"ѱRr]r $#6*1m{%(=vPXEs1k mQU56tgּ<5+klKW5 IDAT_E6XOݚhAJ5:dM S9(8M/E-dw-GWb!=Crg^\D\>} F | @垏ͽn VE-\PCŅE/⹬Ӛ*c\YGθKQ=;.l,2:" jrRH(Z4k-ݏp(p|=s' -}\`0hdEk9%zVi^Emp7uLâ]Q ߭c#, =>yT2 CE0Bxꕤ"y^Yi}t?NCOϱ9` o2Su@"$\DHЌXZ͗>N<ƅ`m}݉@tkPj*XuIJ wLSE5wSv$!hI}յs  ->k ) Wocg1Uq s3e#~("fςsE)DzPx .= >D8'+*̏j DiM#ҁ"3 !1 PbP!Z/{].<$!EӽiGk#ވm-[WZ'`qn5<7E"hgq#((w~݅g:\OHߘwLJfv+뱝;w>)3]]&l`~m[Z׺[ELלV  Fur``զ}CY=w0q6=t59)@ (FӍ8} DBmHGSn ]\BBQLS7Σ{Q-^sv~7F$GC&mo "VMÛQg~Imýi O}E bAs9SOD@cImTgi:V_8 iLkl(Sֽkw..ab EOpVƌ̎aʇx C7l|6AW) (h"7$ V8NjӃ9.W2f|!L_lYKsD].DDkyA& ŚH|8F0XΓkzGVP̀_<4K5tK=PyKp(B?_;~+Y";#%#%:qjpb :f봣F=WpໟлQ:Uy1\AnJttl,8F0I_/|M83"'Ϣ!+-iZByB`_bL kɯ'bph OE,GЃX>+@>J>_+\6mЭLP7N vgb#v1t7y$I3<[ʈM׆}UzzE׾aBPYMۋ z<&HӵM[v N4WWQ[k|wk 4raY"71:dQkknaCM+D&NY&MMeFʢC18}4G1RiՍq%-!bǻlGf7bB$B+j]^ Om)'`2"1\O&iV\k6ߏPC,EzQ8g2 vڴꪋܑpȢE4咓%Q~(뢔5uY(BBK~tIRV_S_dlGDS/}uO/Ë3l@~`sa0/pC펻ŽbQMs Ey[t> #iUkq=-&Բo QC`}KND9%t絥Sr'մ;FBrV7\X( ye"?oկ5տFĢBcqk G-qޮ׮ݷŖiffh>+d蟥Zg{wsD]@4:xypmedt")n7swVb>oO'{lLaM4-n.8H=u lKm?Bx`_~^CB q_)?[*F[t>DžXԻH\G"v]e lF/ X2Ǭ8+m{;x^*=EwUE)^ۄlGw;Xr$8]5yi^Va<'JH(:КNq2@pP`lt ^t$KO"`X0ob)zEl>"z.;b˃9§/xoU.,Vd*6NlהOOܹ7AS Es@6 % PEb\YEږoh\Nk|pʉ*EOcTFٵ ɚhLN#H5[wiޢ !: )D)L"oq[b\Zɉ(ʴ C$vH(HWGg*8Dk"P0,f:KHB)_¨^`^p?h@W,:&bZx-6e𛴥e SU6` )B6O3Gc\AT!ZteSC3,eZ.5+7K|{Y'z` H40U ͛oڵk߽i.- lsa)`۵[Lk1.Dު e{$ml62h1_oEs8 D}H(zH8Lff8寽λ,7YٛbY[츮12/bqj7+8,K[ĮLpM~{\X^$Ajɰ$DS 2A5 {~0ܯhp qdE4b$zhd!p?{2X>4żķn,x)E! 4фX`\}̽ GYodY?+Ei;?86:ߛml] [/D4uѤdTQ8q 2Id%dӲȊh EfrwarV.r9/k XEUd&B0jbUkT\ӂyP+a\ozrHBC,s]>m>LjE,0R+sP(e)H 5=3Nҕ3OQQ+ BPΦ:]u3?6ѯ i6 gC%.",1 $yiѓXDu&:"[~W>V^rW FmѨxT1 e_yLK 27*v%/ld1ݸ&C6O18sؠ $A9an-}XZvw$ P*ve׏'n?b\N7X@Q0VοScB Z{@D9,)'Er&CQMq="%Qƈ0 ىi-ѱ()FYfx}EHd'^¹3)IP h#Aj֟o/bw^̱<BC0B+>1X)` lW:Ctdb_<(?ͧ~^Yc !WU?'|Uᯔ" |e$GuҦL*[~E}WwK30xce h1?uɋO'{u7;5]ReH `39iUʗbCq҄#Ή:)kWoZ%>6 =x|XDGc-#5}HGoݣ?o9B׺|6< nM,6͗pllgmea5Ơ`T5OʎM !q=!)n0U7L}@Eb&9/Gcoq DxCc(Ѩ7tXk&nnra^s{wiaԽf. &"ZfF1d(W;,<ŭ 3&/n/|!ͅē]s@/aoZ&iyD_P}=c>)j)j FhȆsܺD]n=mOC1Q zF[{.8<oC -br5x2B4wd:S!<ŝIztPEP%R9g5+ = ]NO@$a)P#~)FcYVЭM bT7S04і`Hr*Ep'*]PQ䥞N>p嬗xhf&(_p?-)f` j#JIDcW֙1%.'Nsgb_bv4auY4Qhie*.Z@ xQbpyV a{'[;:( 1NKA:ezT>ˌjk KX.0۩4ݞcːMM"1<b er3(EEmlr~_'0lK6c!l=qx,jWH0:r=rb:{8BAA#O侣^m}@e i9& Q?v1,ỞuC3:L t4bi"b$'E^BAFz'gQN-n8t->|RX-\/`x0 E_&rHQd lXrٶqF}_T'?}GyU=Gc#$凾f$ S4u7b |8tg$Ax* 5yϪlx7^A+YvܚK7_0)\NqKQE,URFukcFڳF|5A kLGh}|}muqh -؅E\@՗h"SXd [zO:iBB߼y sC8d#=LX+\O0^ti)DzVhP4_O^iZ r\ȀR0|P½ A4pMT1'Ђ6n YPSNlGg.'=3HBIK1I(sȸ)>;`O>}?֚)haBM w*ni)W!Uvx)beT.(j~Y8kb$iΥs~S,h3{8XfI<1D VP ;hu)+ᢃ2B%)>OT='bltHYyӶ8' vDž&ΟvgM|s-)RԮ0[729,BڢrCMZ2B6"1+3P$2Kn#;SW$6&5wAre{1JcgL@BQѠ숲!]0-[BWFsĹh' 5Q{%bUJ#}esgvET*a<ų nW&nͅ5e9tsi 뭔bWǠ8~'oItB>_=c ڨT҇/T}xbn­Q`!RM.|( .= -ۃ?2Q>iňȢnA:}s-m#}N67b!~0vQ@Gң9*= IDAToq"Q" ^ A&A4Q!lQOo+ ‚{Q,`֙ Qr42 (߶ůt!OfAû[^PG/!TVW\V]X@LrEb4 D0(؆PGgN][ bh/D{v~U. I' k1R-asC#Xv.P׃Y>PUUvc)ZЦ ǀ4p:GVrVsJvݠu,rw,rFsie9/uz!V߮0JFU5meGpgclei ũ؍Wݹ`mf!*J0ڊMB)ßaWNy> וm­\y?(v$E`ȢXQ] ߿1a^BYM1a)Dgb)W2f$lbG= %Wnnb[DqRp]e"v[**M XfUVa%͍RVY󇲐铂fiOi? F0ZwM ̹m6@Lډv8@,><34/b>@ͨF1ԏzpKGn,pXUwHTJ]˜iD|@42к^ޅbRscQ<5?wX5̀OIr!fgmrpKBFF8\s2BXkY Pl/8TDs\{c7WEB_:~-16f:&n4^iù\y%zs,,`U>-o<}lwӗS`εS^]#с>hu[-HZ"áŜS+Fy p߿WeXulxVJ1.&Vsa>n0Qκeܗ%UW+ OkS]E&).\AIyb\VòpqF$bL 4O;PjHcO؉k> (*z ]9?B7}o;c=KWi[Jw*< mdmA~jUTJsGUQ&j \qO͆sYUY÷(d7|j?ΔQ?IjQ@?0NJR'Eb`ÌXaDH]SL$8#.niktc͟}Yn*ZB&U59vAS"1!tx҄y1ŏ 1Z-h}].,-R#zXD,܍tbpCXg?^iJ+;Q>H(Z;}*L0l)FŌ(x㒛}JVWn9A*cT 0k@0$jb;y}n9([8dsck~_+u?d l<9H-՘8tyo+J&XRՄQVivփ.b3_| f9gaV7 ZT\[0z"N yD,lz^(+ lbT׸t|pk;Vqb1CzY"VN{SlEny[ی{H)H pIq?N0G){Aj&8#)}C!E;2b!"G,R.]g>5υas\?|``PuXGdQF+ʫ&x̡e;2K ʨFL.O/z:~rd,b &#R@QfW I>}a&Iz٤iH##-v$/qq=&@? p8/e| {[}V ]褛4>' aҢ)-) FS u5}y"$-GLe=E5n01UYYF7_EF&ѢZ2N0Vʠz-0bH ^-YD[M"#VOIFULuʅo_q ˨rQNxOZNYQ-0&"T,6Q&)$esD$vO{_==cP;\bۍ`Ժg=(M擁gyk  HnM HڑH 3(aˌP=} 2 ?՜[ 2gDc<~%$#{-BRDxڱZߖ+H GczjM@C1;#{eQ"bP|[qޙ|N }\7,XB2_ I&4NcDƟnv4>x}׀n^W/\9Vnb}8:9N5+\Z  D[#wτ9r˗&"v~R[bغ#DQPErsѨX26 ]m2||b K[~ BOV@GlwD *3CJ p8REȾ%ei⥯"RcdZKԱP/w9sV޺챜.-9KOh*b~ꠥq5.V-Pm39rB,l'X&*BpBkV?`woiuꑃ/z%2K?:Wc{|ipxlV4mi`|&'@@.|*0 9w@q =]_ƽl?%)=ʏeF9nmĩ. @r!nq>?A-Ewh 47_dP/KY/^Z`g#?8VG{Õ<_GUQtŒ`T ׬60ZxY# (;ǏM~cnG򩟫*U\=mzT$B% ]jY ޵4uWKP"16õc}00PQPļN-_xEki@4T<Ţݹr:ǠdzBvRyu$eeV=h꨺i!_nV>=;yV>f|_۽NիyhAY%2- F:8]4 ȇ-@rnM4w1'HgaE2\1{5S2ls g@nXG8dBMc}F$±i}s,hM]ݼF-\ʧX\<lfѹ]Ha8:B +w0m. Ą;"6mneDqhlnj>LL778?{6q/wYF'wmOC &З<(Mi2X uhwAH0NzMq%4akt/|<A~b5yAnR<%:G56c-U?ptgykj@(R-nokO/zzpq. `=R6K=gx\t.@N'g\obh =RCa Ƨ/흸pv][lv\&I^(-0*)ѽ^lFX?iE;yS\kQ,;gm)lAQSpnOP$F{7 =]z)p}xSA2ƎJ*F~^̕gϸ_/;cx;VzTVqYQ,F,B־d-ˇXP^ RVƀ&Y5I^wM*;Ρ³=|xv^;.v>Pg3"2`yM|"**P >9Jaz 7bohVffPӷ>1Dža{-տ5pJIm^J]x9[w-2V ݣ8|]F;():ERF1l"1b XD7 _ aJg]OӐTA..0ٰA!k+\/>םt{};t ]K wƬ7^KC2`94kT=꣒U'6Y y\^Xgx;wt5ٞ4uPQ>J6#P)z˂duq)RZ1ĠszꩨYUo&q!x|8W v. I`g| ꏾL67^Kq1.V Eyјȿ} Q`heyn9pͲzh>Wje|v,hsI.5`g ],f(cCTUz HnvI-2 1d+AvQ2n g8} YFz=^,g\[`e$*i7%Pyo@ NIV_|G3>y㕐evј sEcϟ]vbR}r]mmZcNJXM?dJ+};'Nx( <;(qV*>pnK\nqĨOS ̦ iYu H ù2<1Ҭ%8;wA=,YQ/Hw%&tK'Ihɟ'Ƈ(S$Zd*[%+ 1GxD0H(*&eq7qM2g_1υa`(>kDx '-\GA*@!(@4Arm.!xZ0yc )8uүuooK(&zj!#GaC{ ׺ՄޫT#Q#$P{DCw2g,Ź@Uxli7bN([Q>Ӛ6V;] is H@m9a$.lSW>}΅aP2p#۪3}dU,cjњ(y %k"ĨEA%R;K.D@ɡ%ņXW6鷯ra{'~$;Q6Ѕ4%`aqhZq-|t_Ӂ@DB.zb65A^̂b=e`qv\_-7UTZb9J[* Ckcm";rxג)[C[tZb5K[kqٟ~INމ-CDBnS͏!6EWa М?{okɑ݉o}c*Ed7ZjYYƒg4xЀ, ؀a`,`iZ;l܊E^jy^y8ĖyH}sbſR\T4,XoHx¨E" Drrdk63D$&Ѽ /A"+1f]\cGcXNi oItcv=Ub?M"=%oė⃵CRQDa[N"6O }$UV6( OZJ N ;Ϋ0&T눌 OUrIO lMj:Nb65Aʄ"Nr,$G[ЯQdh9_<*vXV0?xks.OO!@z -Dzu;+E6KZ::ϯ~ZMEPa$YD 9|-]vruT.guD|Qģ1{/IKjP͋=E&Hk.m$D&(2 lwea _#%|Fn: [mm\)qen D1+Ω U%3o#Y\nXXޘHbnGf -a,S_E;Nb 6B 5mUC$; .}ݛr]C` [mm(z#4@ĪZ 4=hL?ӡF3Lf67qRiA=| sqQ 膏5DbQ{J*Ru9|s %6 I)MZLچC@l$;ϱ1(>T{Gd'PS$$ycNVhHbO`kh- IDATs=\*W!t֣hn|*DAVWWQXx 8i)v&"*N5Q^ |&' 6[sg˖mdN"}ŏPnPD8!WgO׻6 &6t!#s4L9[f !C㠺=ޛhxx;(RF2YL2A穈^ŕPXkFђN;9 &FgZ 7@&!q{PI$"R͑hsA%3iNTUm8/υz_W-%5#O-on>5XI Wz)67ьcj:=EW^ ld?iXWsMhݥC^Fm+jڜAgB.ENʬ0 qMaZF~xgN|BSk3}e 6bt{S}'/A~PU 554CCǰJ'xxH kӝo?wlnOސ5V;gOġY76g5n:iӺ0};Hp=:F*"OUuZ(γXk{t`y_6XjYf$+^E/B\JɃ=Ь+!ۅڞP_Ê3u @cx8T8%7؄BdWCuq2ێAlUPv6Q FW7t`Cl;hh͓#DFt7 w3z=QBB:d,Jz\ #P}^ HF'^zp-iST|(m#I4(%DHT(9ON5`ѲfJSTE9̺#c]k׻)<"jmz)86 ~zD15FD$3OhoSZ f@$=Ixt(At\40~B!`eϟ-5g}0gB煷K)v )ZG'?SAB = i&ŗFȳ“Rz`{߹"t{g/l )Q\ bAdZ@p8i._HbI$I]Ew=+v53mefWS'~gPXy+*7^T֒DT#@}x~;fLfi1_֑σeqA} !{CTKўablպ>B>*~ꩮ[p!/2Fij} l{K9d߲%6Ҏi 7 .T'd™[^HկBIe Y>h:4^ue826 nJkGnYi/j^3pN0ͣX7Qk[ Q2{*Ssmy# 3ThYBau Jvd ݻ셊HNJEGXUjф1++%lZǷH]UqՁ=I<5 +0T!|^A:T3wQ}Hb%h kԒvWޓhBw==A?«Sk"1>/=EK6ǨnJ^#VCco`HΞU.FP[B;a SP 2k hɢd( 卬emx-\=1@PPW GWP3=UPE`Ymmgl do۫'6Ã{c=>9 enfO76gQXL:^^l/y`  ~.3HA~ي`BԯTtLsBgÏ D֒a!IYxr!3Ϝ*Hcq[|V#'y+e@66@ikTy~\!\R*<D*>%F$_D4P u2f73)3٠0+uϖr{,W+zC['U1(&Z/M^rQ<~8 ?bpS/d>)6СlUE+QePl/؈RU=wR׮Q}HXO]G@Xhf#^8B֦Gl*njjU ; qeeR, 2Gp4_,$4 EôW/dg Sfo!'.Z}3 Q4/U?&Z,ᚋW"S*!lR>ڵjhgp_UO Ccx>)0|DH3ьGGPXUl`uw xw|_v%K]jA1b =BUzvH=u mvE a{$Sr<ё"zZ0OJV4ͅNXf6byg"5;:v*/k48&AI#E'fAR&Q.5Gz4,o)CmڢFԉ^^ qOdB g E.Zj)aQ=l $]`×ӻ[@ ^C1F)b#=majjXޙ%ۗBt6r u)?GHG)H&B/ـ.%P2r]"gW78VC>W6 v"=6gHGd G&W퇎DѱE$b21d]/U-޷}[1 l9l}3Oo,%e1}}ZTKyd;X 'r-@[;=Ir"Yv6*,DO~bɐǂz d,u?KιX\頋Orq'Gg)։6y YKus'lrw à5V5@֍6XQn|}t0X=`E5m-(2uE<1hsՁ OGh׭; HD"C޺^㦠Edsvhl5=4YkJ?SJkU>)=RVLG" +%بEҎT5ZzOvj #DB]V-A#2:=;qziMUL31kkJyyI %cxP=aRvGdbN6 D†F=_d$t]|j&J#O-Ԧ.;4z3't_L ^MS?VS[ȶO^o(s3TOT{51:5@] oĚ!="cjƝNU n+.ȫc-NsBۣw=Ձ|+>ͬu"LkuOϜ ٺ2ełnS}%8B [_QW,=W!#c7DY$L2Q@gɢX QllLQ)>צŌn,(obhQ6ҐXcXe`Zأ@]V6=kq|4rԿ>1ɣwKps Ar~=GiGTաDJ1tHގMu4/4341{V'X賮[ (TYQs A=-ec(N4EM*E.iTrr1蘆i pfXS6S,7Rjyt %"\*KL)7J`k%C OFa?T<]A7799]٥}rmgvp5D=`Ao;2 Ŕ=~BM.&ʐ+*'b Թ\ԸW75'aAi %k0'#اΓD29!WB,y0uU̹<[(,S c݊G&v~1"7`%pydMնϯoR0!aQ/%,HKvcsF1bx $a,n ;Y6KE_& Ӫm5R> ptX&lR]48Kq{u5Iy2  ʌ+@O"̪#?;QH-P}^U2^^^- L<姩~E)KYH1p$CHrƑ#73.("C\BH'r4v(8 Z9[kԫ ~&dgOS *Kr-+qg3>azbǂKO= [jQӑ4vkL){^z “4>%֐8׶ ҧՂm#JFi3sCqkGFyDOc?Nrsj/h姏̑y8BT\yو`pH}b*ǯ::Tۺ٬|@O|ѥ#uuc)L7Dumެkqf6jk #2iT7P Ӎqë% {$2nR[ ! BF6rǢTaQHM;TEu&l<^1t{553O/rz($OM$Ԅab%|(}L!D3tx7{/p"zL/~L5ՀFQ޾D"97˽"` m󥢞)봹n#:$H N8dS{s&eLMxDSOSl>^E}@J}UJ"W`c<5Q*XtX_ = "1Tx1Q/7տPWI~^: ˳ۂ(Hr鏄**gE͋,=}-p=#wƴ[XI ԣpJ3Կx<D IC8;XX۫* 3#0)ubE~2V8&UH&(rYvd˘֬zo\# ~3(N<՗. k )ޤê9`HrSњ04+L3s Lt .^kK>vZЯQF5/a aԾ҅rq/ZH>yj&hk:p[y ~#oȃS0Ԃ˨ǣH4izncN<՜LhSxGvӞP: d"NkiyZUxPh5kU4 C83`~EBFjѥ܎x 8ngO?lf*E[J$:EH&8I;&YLT!BڿU40<4f B Yiohb`0Yd36: IDAT~6VI#f(T-<|trN@I4 %j:cN#01C6S]zCB҈I0t,fQ>LB:nN0 ]\Aa0:IAjqq.?D&o8}>!Z4QZaԙ/8y?yE##-SN~L5|ήQ=j(c8\ghU3 պX36æȲ<;UnaҐ.gz"9LS[]eȴC~qQ_SB B|䡰.<:a I{8\*}c*vYE+ /]xͳ TgvEG'A<'LjHp=6j\>{pB̍$⌲ץv%>e}sD"o,hOzB=Z[*# Zjȡ̆CMR|]L=_\Wy(tN!׹DXSJ+ 6pmώXz*{*'ղ/P:lb'r9llC"m yhEY66jd=4S'QXM?J[x=ħ~Nڃj{5U.(> vv kZKRDfrU爣](ĺDn<ħaе WO6R3Jb Jzxg`BmtrʫQ\lçʛW{wsgPXN?eVQAjVB@b>v=͠iR}L .vB#L:} %ȣI?%$_89\|D0>s"&B@5=7`2ز-jlTt^ 20:ɏ MЂ*ڪǜWHYg8VZb-n?O3caei1uJm\or/-M3y<7(3/Fì3Viyugaɑ;TByNa Ĥ* 7C0aGwԪ2}d~c++.qQ@QǴl".e@{#.634=|{`Sox; 4.[‰Jvu{Pã66҈}H](O$`4inQNQ|*JWnY+ÿYlԞ.#tSTꢈa@3:0u!T;Iz|iK/>tׁ6``)KlBML;tפP:!n:%6{( h•}j_@N,f?EK_@q TwL%d8O!'Izly#r}B׍7Ё'[T#p{;{ ka-poE('o36?4Y[n:Tl%L>ȗSO]1c !_DP#1׵pAj鋙͈XCv$8$rV^FڭUd1CV|u~wfk`)W8{( {cȠSe kiS B]OliUWtMSɁ:H'Gza%h]ɉFvyD:wvaasEW{ ^ӦŹ0,,*X0jzFߊN9V<scYO{( i<([ a>2; ~tδ*s4(9ባ<-HG>5nhu!/miTɟdu臀@;y4A%}k;̲+7،3_|ՅmX>0!hj4NDp !S?uzʙ6D*7Jڪy8[7 em,qkM\ `<ע.^QWZ}r+u5 Ħ(lK:bå[);1ǏBuauuQS'$>la@ cCPDvU:Q, 0B'{C'!`!18vr07C\ I?Ɍ@=cLr*L2*Mzj)t. zSOa%vM D 8NvA::"d-5Ę: &>TFYGL ؘ0c^&Ukͦ{V. XD/_FauWX>N=Ahƨe<2a 7OX kgk$BN͈s=Qu t1HH27h]]osΘPor55I "A˳aFzCCY9k-l'lt[y!SˤWיkfV0 KO:hA muHD7 ͩw6%r%2y*fL#=0ȱJ@:ZZ*UHx\=z*L.)KGk39vJ ”0R(Ii oxxcN?ꗟEau"=Wf u= _KCwG&$`dOPlt1ݳ$ % ^veq/ГDS347="דzaf~x)Ԡ9IkFgl-w AfDrsTf$ʃLOP$ۺ7Ezk铧fS'Qxx|! 4\np(O?5_f=dhQ1]Lmcx bU ꧚1keV\xjMVsRʨ@8rXB)XZBaUqI6jE G\ὔc;TMc2^h-d0<l+u^3'm նGNHd/_Bau`w:HGʛ`>E/:4A j6C˶΍ڢ11 Ӂ~3a[t#p/y4 +(, y0q `!)U=>}mjiYG9y-_tMūQ㍁_+#UҋC(N=X57B;}#}!97^?x QD"lPWP]-+GOE[=_D]wU9Ry#{<^Ga!0~J]-D$|W&0yl*<!PUN BẦ,P*iS`]K#TiB4ٶXwD `hxh0IGFMY4Q(`z{a1P kģ؏yTzlNS'ӂu' *T☧+4y Zř(n조8wdBGI) D!FTMKE,0">Zs~q?Qca| &9;q^tGexcG[(|fix TSob{I_oOluS÷jha{z耪rD -d&l8~#2n.8[s8X >X:񻜅r^P 4ǰ!~Iãr/V"QoK~i5:F̢ԯGNnyGe."%=2Tkxmå0C9@է!\@Y }t 8VړDwL6W~FS s{Rfg9tJCO:b+6 !BG4,{Cpg f`8TiXdlω0Ӿ_<߸W1ŝ[wY}W;>ſ1(G1ĉ,uU!S^E5eDSYxFAd 8=EC4LLT!*5vziJK!k_5̄Qz:gPXqA-ƞPg[,%ΤE`%Rj5j4j 3h=fp%vCb < {?r6R Wנ&;?6-Fu KSǥGPHklJ`nRԮQT .\M">%(qةu/ԙbe=LLRcz3KMxJk1Ɔz|y^p^)i,YȢ|8sU  ddmpc⽖~=W1[`4| بTpmEjP՘dzW,Եvi0obr#8eN,N)re'0O>JLw>ŨDALS7I_.g FK&p(3W.G\c|4֋02O4~ෂ02H$W_; gO73^훷a+n9j{ߨ'rIʚK  rk~4BuuQ5 ¹NZG"ZTQ#Ԟ(FDV w42lV>o+8op<`} L 3Դ,^eo45<8K12w!*`Ar\>`lP%mR}e5Q)~/&W=,}oO+ȟnҹW»Q3da_SԪbE0&ӺDqWO񝓼p(dQw؉D{+(,.vNJ4,=4*1jWVgCa20T{'i,"llW䒱jLx2 ro\U|1xWR+lq^f-4? >\5I"sM)\g5{vi0L8A]O v.15LG*c(\d17!ZSE,hEAxTXHH+Q,`X@~,>wI>zyܜW%elW -ߍ2ac[{oK8.#ڃ%jE}3aGtMj6J D!Hfc&u肪Yd> Ls})*?/L(?+ 22gfaPW׶`u3 6sq]@Pl6ґP&:SFzrTUyH պjc$v+W1YO}=e6#@ZȨ'>7{/3QPt?nX1w8m8}$Y7VbK9`Џ8G6õk(,. Q5'ۚ5-#|':mG`<*J[r,A[cej<-(=ךmRܽvVvQ?fRH&X <-oKA$=H UѾ=E2*lۦE6xDQ Mrq^I~#õ,)x{(,. UC9jox=' *EsEȫBB]ǒZK trrVDTb񟿌šĝOoMYDr5 k!>%I)F<|֓2pZKB@w+:Iv:A^`5ϵiBgb aS<N,xB ~ PUZ!M~ ##u؉zYq| w&OevN5j,46XmfS{ (>/&,èא(-lI@?&3Q=HB,$YĀJ obhi#KڴE^az>^]XWe(DCl;(<^:ϫyꉁk\FMcHt`E2AHI_B?Vn>1aǧ6z꧃(% Y iр7$ZboԪז~3(7- pf)d,.٥ٗXh^gժ,推!.4RitrhǚLZA{d]`ɢHCЗgv }]㯜oA2|rGxܐ+/^׊tZ&9z`R%q_,Z9oG;t#iCt:D'u ?Pu&}t+¾9ax mR(,Nڇs2jMKjf_7< bTR by}(“x8=+JM ]ˍڒ/:ƎUؚ%b[9 xa\bg/ ~9)Y n!E*I"gL .P~ ]_S3,;"[#tڣ%8#S(dʯQEpH?v(,.Ղ#(]Y$݀<ı޷P6v)j arPQ0V%yCi vlK\<7htO6p3+5dE؅5y,Fc94I,N#↦␐zb U>RA7۶Û EzI^%^!N68ۛ(<.ރ#yġC IDATW-6ebZkdyꐪ-m24薵%ӂກMi(RO, r{wVWLn܂ s3KZFTP 7Q+Sh:'d3_c8!i+"TvmYE6j/Z$\yiOD3YL+9ã5tMӡ -$ؘNʗSR0=y5NC!(Ib{TsT ӵMF  542(^f , Ga!_>lFzhDmM(̌.HYyy4u& ;J;%=\U 0cf@(F`OfӪ v 2Mo˭;_QŦf& ;Xmd[\\z";Ej i -ZȨ;~ ōM {Ek{3$ER 8yW>|C{h6'Z&1`y~B.E1!!g.ZC$NSdQ>OW2c/c|@%i*jw!P&V-=f$zS[~0ClZ'YqHELGO24'H:XCYΞ~Յq=«]%&>@ٻěP8InB3JRN Td0q9^m&g;ʄQG2h=dd>VYDR|nn{J+wBͰ=`'[ݬw?aˇ$n<&c#ъoǛOKZQFLǍQ8=(R+fU<4\G1(O`/*<^wPx(BC>1:>eLHs*(%A2\;֡c}aINrR.lڎLcWIqU;Q$i g+xs*ͻpm>l򮨆ZFvL^ a.U|^Ic%I #k2MIlQ t}myd$&M+W2Sm<ܦ6'(,F^E{aY&9@67Eu5; G6™rZxLK-F=@n&+9`Gs].^FXԔ,>ۆᮨW?zJi]ϳGFթ#( 0i(eJۊ=b ozh=X">͚F-ψME/>ґD3LyU ݕkΣTҹ~%Z^p膁GatyI"[K{JQbHϯu7 p,IRkKY]Өڪvݔ=QC]ĈC3yέhGe]oϛLsrPrȳ6O?X,b_>&8wq$tq r˴Hk43E`#!^n_E5"=pH?om$On:U񡳬Q"7q 5 Vf;YvmĖ0 B1YOg \ǾHO_@au!=*op+#ҙfʉa@TÔYO9 ȣ^W6Q^:aw*_5IBJϕ`Yqd<L:id1Ńw7hӰbD=!ccV1C1z `)a{Fr =Q XB;:jLopīvan09A1Μ5Rd!&L%ŕH#Q䘍#d"?O 6]22ūnJDx>Wކ5QkLf}_P2cR鏲}>r C@cub/э@j]>GZ7ȍ0붍"o"L(YL ݄CdqúQ%zupMYs&VW/"%@Qq2;%w5m@BxEbQ+= 雅߬>ؙ)Oq>?8aݠ҈p2okA2(]G_8Rh?.h4ّ8D`ҤFwbhT a|(?Wd FLh=QXK`+ 4 ;2fEԺ $jUIT`D b`z)>ilH%&`8['zՅԫw?j/>sΜW\my*q 1hn\0Huj 3ie۵TYF"7_.#oif];zz?redqau!ƻނ0)~X`iګgĀ^ 5!ta#&f8w;# 4Q`ѯQTPyi1-wH "|w!䟚tt&Z!5)y8Մ@ K1Hb3'~+?C?< 嬬4wTF7߽ի/\#wbŊnҨxt-^BG3IEYP*L V#HɖҲDv"ѵiE{'H}X60&/t,4-˸ k0~pnE=D!k7&My׺A$®k; u؜K-]X/!|)qA8d$2YWzzQ#u_A4݋]5K(Nwunی}tƯq$o=X8IQwi5X':Xga$`JZF 7%fQoNS|^QuH2>u>|x6\ H]^M"*4Ilx8j=ީA~Td#'&S(d2_Y7IcԎyюZ%"LIIʘkЫU_|fN-8?1sňVN|:- ">c#BfIgEUGfZٟ|$$ Y&7'zv7{Cާhzs˒ U"blkţPD]*W-hq}QX,;:9+8Av.4eNM~"KpPo[z?dSP/]XCOn~-='LW8.̪ԫNb B/W Y t!]GeI%ƥ;pp<߆ {_G:zYF d >R{X$AUkQuWʽQ<D3Lف.w"'Ԕ `֦HWxӍ⯿qmN>/>Q4(SoQCOFdEfؓQ@|\Nwyڻby `T]RgU~`W0q~،g/]x ~(X\Wso ,9ԋHFxĐp~$${!n`5ÓW_oۛلQ)D 2je@m3p|;|o?Aq>\:UoὩ0Q^:HϔTuzA2]_NW{ ?d_ibW<\>,E4ISמۯcP\zy>|1pse ?V&W\5< o!h:wRK'*K}PAvtۣnPC%./X躧Xm|鋍{;ѧ(<4~8u+*;J0ZѬNQ¼21g$bP]2/}^xM R|?8ބk~W>>^7`9كM<{Fx$Hf{M^1"sQe*v`Kp ^D!2AwAu:"FI|=W *&ů^ ^M+02CEP A"pE*@RxXIZFMVDlW92M +A[gDm͑t*7;jg7qgO0X6.N}61PHe`_N"\GFTc|֓C}; b u4֠F$;}:՗S"k@g{K+yn߾{ͭ{xׇSb"k௮oxv%DPPtD9&I'4}gd|YPeOL$BxUy8B "ތ@L`o?=|z s0?)I]8H@8&B\ %^ xa\by#07I0ϙQr-!Ыxu]Onk=:UU/߇7n7;6?qzNLzV(#  @ϑTVWkDlP-ZX" 3y>Ox=(9d1.Ggp)yLPGC0;;v 1ikc>;O[H-E"=%f{0)$͝<lmnA`k}V)Q'‘cG;~fggavfggw<)B "e=m@?aH+^X^֋<߃ݭmN?Gטnn+EfS͠YvA#wDH Oʉ|haH"S$$cF@$G(:B%o@#0gd.=҆c;1g?@qu"cBt> |N. QxQ+dl}B,iSHL>E҃!A@Ji }C0DCNJ!%``~>] .~^>~  s=`g}sxƉs k(G\Y^~Iܡy8q>} ,eސƉC<Xn'!<8Kw5*"O>|rƽ.t8 v7` Bѱ$~O +\^ =1Z65LU&omF&B}YNq,"rU _[v$ |9^d;o{:AXY|$)nՓĩt"r! -)uKJU9^*E Ytj=<bn58uCdgۜ(|4Q}4D*1M 1EHz-i@_ݟkyM^<Szn߇?+hHcj;أ0QD0Ry\H+S?8RGICŸ⯿{n 1،I 4/ idׄ&:f)Es5 #$e˜/e%y8h@<|vE>]vk'0{$eu RGpe]t_gzأG('//JGIÎ莾zN+ (l!oT[&^YfF?r%^|-ZoC}S&a 6qda9nj2)|`ƥs9aN80ʤ2^Az3N³]Bi¿ڴOYeB8*-V@LH ,`uF$|DY QYm jwWgwZs"07_ѣGa$WZ$0cG%@O9a@xs+By+4K/ܡ?}w /&,JS!"G;H<[ɋl- J<56ڞMr@IO?›ƒ'j/Iɢ%H+G"mz9"P@v'u;B 2aD0zmdbS؜J@+ˤ6+nʲ#V_?:tZjeѣG{q[w~wm LP\R;1 HbOK8+D<:ρIP@u!:K(b+Ͻ=ݭp僻pWդ ,7-Abd+ ( ܱd& v):;8duN;Gc?LyZ ۪Η){,|>|>o#i鵹"{z.j|p1xd~n<n֣Gv>Gg/=s{ݛ:ؙZ50Pwe0E~[ j MGq5"%zyEWa;jdnfWYL{j~nVYcLk b n)5jpLKEo>hˣC6~oLE _zp ίڣG~csmbRPof#A(!P"ul^)C9,$/+_@M"*p*8y$HyC0%9{ZmBߺ3G5yq!1_.:}7izpBAJjYkCaZvqqlEzأG;Op03zĜrZ'Qrm04}PFO5PoȣD)/sgH(pEØ,R,%|Fs],7%|i4YeSH;\D PT6m$di<ː5c[9xw9I @&Ω=Ń[୏\7ƷhR@mCnPYvj{hc :6jf\4Ɇy]z //2k ~/< ᑒxeX)lQw(І.kJ2_f3"f/n"ue x*Ӂ"`U,Aq NZ,6RKF.tumWNC(!9mC~{>Qa=zL;6W? |'>Z*vs*Q4SzK(^'ʂXd?#KT;_(~?* B. ,ֽGL_}iNFZY^*|tFW3U2Jn}|})3K/(l2E|BEB ´O5e}V Ȅ39@ޖNv$s "Fj]=4gBw!?‰$xP]>W؊=?7/ރOjtjzڇ-'7ݕ="w?矅xf܋uD+جS0֯dN{W8 3YUl,ٳOK:2#+y~7_Dq1ů\ރ7%E< w&.#k",XH!W(Ө,BF*PnI }d+JU-*EIXzRz˂FV {$bg"5RCt`_M 辸9 7gұFGp^{&&lo瞻L'lDmL=`<&d5bci@|BLi4FPyE1ҋlk,`vf>~Fb(6. In'MVO܅7)E}vU WTxBU"\2. ūzn# ŷVg0vB8e>\xV׶P=z#=BtXId5YCtlf!L(.lu2M(jr c} l&c9oZv}dyYawl"~]oF9QJDۊHC⑇Er@j *Ҩ >g*N#5`f&CYx}>l?ڮdLjއep'=zx %?Z]`0j$ZЃ@g"Ejac<}!%'暑nt/R2I?tkBɚFJ- f%{#9W$N ,dBcT .t@^0gcٲvT}Uk<2g:I$D0euO,v|.C 3]U3\w7tvݻnR$0?O>1ڸh, ܩjn<^نǂ#ݭxwd6(BオpTG&4f<`uy):e"%O%FR+pWQ{a03knSpt>x&Y,{BKx=Xrg;DVD:qĊ 'j623aUt'ƙHZ+M?֓,e {pe-0粍lqp6/\ W6.o­Σ~ \x .'`pFtY:`V-Í;+C9}BGkYyjUSAeP]| u@3NR2t:Ff4C[⡄a$ĞAdg/<kk=iuЂ&bz?}ymgY 6t('J1(K'ҿ*qz껉}<&VnZ-m{ra#z <nU.NT9.9γ/ǫs,|hӛ)'2Rօ3 ;c<hyV`mk]啍a]XN8 'O'NwxiG0;7;NLtYm̩`^z:%e ]^15HY}ա(RBF厠8>0a##|$I,rQ/ |w}u@d|_}q~ ,FV{9X2̘;VC8pH@ح-~؂G LV*?]6f>|>c`N ׾SwPXeNl{JPN>ϋA5|ns /<||se X6L0ԉAhVpm'(YPBe4M|*(d#i*tVe2'>hEGIeDY䠋 pv~16`]HpypddU /vm \y\ymtxs0 ?t7ً㟼O(gݟ: \< IDʖ9]O3LUNjRIMfQS_Ij2#$X#JbM$h%ԛ") Aqܻczoޏsν|{NZWݻݯ8]pJZ%?{w')Lt1L1aүJӳ5 {{N/~Yw//qȪۻcq%{@! 4_st;VBB5{MT'#t^Іj_4<Ȥ M;tWVq|G7V> wluwhbZjصSؽzI->{Op׶u}_Wu_; Y#%"d³ >"'@ih19Rʺvۨhna;vBf^00r{1YA'HcIB3H$L ÝPw%CřŝQo]2h'wLX-6f`C1XemmTT)Wۀ0P @(>!4ްݷ,?~{;w.o]LM]`]so_sܓ0WխFР_Iڰ)MaNĵ-0uK+Yl[}ɹ&/]q_p?.[{蚻@w0q_9k11mh?-GUtߑ/"݋+[ݵ-X`[֮'^Ɵ[gmO2_jnWI$K76T֑F$m E0X, IMdq6Nge7:/LdQڡf1iIV^iA g6Hb }k<&KKzMε%޿=n{ceAX !aϺ#L/=^{:`n{ zԗs  MD$];Z}&43Gi5oڽ=yܓOWgJ(ǎ_0(&մਢKѨbbv>by׿8$v?۽p~AX l)V݇QЃ}z5PLAx}cEI( KYA\E"ԌsF68h܃2!Yzf^7)ƛ0tvuBD=DP7~L/:rM 6O+\)q]N7!S|nZJk[ݗ/ps7^Yv,@SlG?oc57ܕ/s b |<< 0 G I55q(364Xz!w=cğ7g>-6w-b]0Ҁ4jd.21whLBk*Z5 sbe!Dx*nDT"iH*;,4 mӴ@W 3nq/]n\޹U-qܺ׻v<1Gj|rk|Юݙ?693fO0DGNNdnJ'Lmw W~swft֢ ItBW1 T~caN/EcҊjC-qIfASQŮ7<Θj71iDHyۻXlq,g/_}weٝ_]6.@*Urxzn7W"?QvqaGvbzydb'#}/$rv)i FM=}؇;<"l_̫3#nrc+N $&njBΒH'p0`Enސ5UfTͦ۷M$4bLWKD /IiKZ5 |e=ߟve4H粼E v=9dٌ#Ij(b8JHidiQI$lF/Hki?r1w=ěoo`~Wnrs+140]%E[ibC]I$P6r"UC'd1Lf:(/CSA<BbR^ѣ_2%n%][v_}>' * _'瞜ϩFSPӠ>MgDri]8ylm0i41HU4Z$ۋN3WDO>x|]ޟ~ɍ+;=_w V'vvI& R*&iRP$N:'VGWWv?}~Օ=wWkX'==1wSQ]45ř 4jj7TH} 3iDm`c1~Y6z!Ãſ}~g6oݺǁ!EnBǤt TtH.G6"h?h8AH?RBb>sP .&hvDx0u]$ \*7W׮|eK_s^](q͍r/]QcSPژ93\06+Q#БƐ:* IDATcHgJߩ$:VߜN/y7-}@^;HNѮJ@$0:.`ڊ&`[װT\$nb m545.˂~tDSXvOmsߺӽxmʍ➿v6Xv>5Wk` 2L΃)1,!WԧN/&u ,}ZAτۢ7-%hZF#;,~ŽN;c)RϿ;s̎cKo(iFhǚvX=LfCFwr.MnH{ ]ÛiuV> t?&+yH7OV+SѤ:-)'v8tP3=Vwmu- Y խݵˁʶnm fֽ;o(d>c޻-sA]w[/m0s;}\4+R8 3a#'N ~#>b8㉔iB!{OB>ݮ,#s!i> z:!/-w%~(,0fN[]_e txUE1ըr1LەWm_IA Jwz гC<=&Rn^1S7و"WuDsK[~Ruέ /W9֓} wv VvW~f:ʶ]nm0tҲ4e]:9{]]\mA ~2jؗb\.?6µ|[Z趬^u˗Ϻ-.aإsί:?so/nQsOm\ņװ[MPjCh~]w#A{CO/r P=~-kYq3}dyMMn{;Νx 56-̆m -mHJs>BSdnd1i' :oa, ](+W xH*ij,˛YF8W,zms~&_w[ݪ믺nw-m/Vݲ&lm$-ne*Gܶ=A;o\ZYy(!T-pV(:[v33$;9tmz܈&; # +E] 6 &Aݩ;hiX=DUqQ-,nΒ{>Ubc[s{Q#FLv+nɍ7ZbrH GK[v-ne˶냭Ί׾ LA\H%еh##AX*$-ݷ{nvy{nsKONi.zwǡܮ5kLpɩ 66,Qm46S|mkcv_B)N<q(c i|ܗKԻ4 /O ?;'܃Gfh{ ?ڤcdwdR2"c=,Ip v@YD"6U7?b'C3! 7X{Oy֦毥C&Meu^˳x9布\߰VKG髍J018-Unk6hnm~rLD.TƧAt"4G*29yFĴpMbXvl% $=u:8⚼%ok˻{w>>s{?9 ۷}sq(kx u+v x-Hb_|( ɗx*_2K{q33nH6 kŗ^ϼ.y-Y%R1LXd"w1 &\|YDD<8L#3>b<}qL&C@t Eّj1(-5DMI8CPbВls=rkF"2Т2\{tYrN?Mqh?L}ڕՕɯN_Fd@Ұ +z;(f.nOӬGeaYnrٍ wDȱ",([ȫNIjbzY&$QK@( 7߁mÂej,?2Bm1fCĈHKz!(bѭMJkd$!ï}E^WP!8U(%b#T{"(=Ϻ'~]!aşD![z2d aEU,D*&"ySM%0ȧt": SRr=FD\C w2 .ow<nr}G]Ƅ,J;)Y]UJNEUɌ^u ᇄlAvHF2*Sf!HpD!u0pJf3f ]i;LLdQGpPEbVKq2SD!BؖĀv8&  .:sWs>6)Ϝ=p&F+Uԇgijc#P$NXZ$HI ݴRAuU.#6UO=/Mn~3_qs3݅{RJgo"6$t$m!!QAw@#5NN"iKBȲh/CDL9:tIG[kQp W'khZiYdLQm) RCnx yy|"R$keMc #\O ^yvE0bܩt1W,mr}?~ؿkRGJ~YIL.P"Ў4I̳i}t& iR.W{=|d]EWnrޚ&7Rw*efZ- 4Ȣ t$}#bpˮT7t0LO"ɏe a Bz8hh[1ȋMɕC%_1Bw#Gf꣋MQE a|%;,b2$*CRI"xm{.e,%Y,mq^x+ky;~5Tr($z EBhJH3@Ң]Ai637Uuɫ.rVyϤ\Ow &Бѫ%~+aʗ4yy!2Q GhMy1} 3q29/_AjRQ!mN?41\jkݴS5m0rH QIe谉Zs¨۵mJy 3$Aqa|C,63ۻ\ B֝]MEE" ,E1z!Y$YHU$WّMfA9q?D@6 Z"?wy#%~䃦Үr"Bdրt+JETx⥭7V¼52 ^tr!jP!;OLu*G9߲WO Q+8Ü3.ޝ{!kɅ6Iz% !=pǞp[8_7wYM #x̹=i`;JjOk F3LJES:$-K &:(P^S-qv/>q4mʖ>QB>1 [W>XM=UguPiG Q|/ւ[nrkNu4րH} +'ybF]y}C 멼>IyygդQzezm`r-Qiy$lcR%$[#rt (& =Eo(LA8kC޻biٽOs⬧wD Qj&'5J;AWR@8!-4'y|(^R!Z\޺=nM7 W&7srHݪ_RNLЬT ='#@MIF>fxHCWO*:9gHgvt"qp OVrhiYyk_IC#_|Ѣv<I+4tgL9k>Q'~n6-\<{]L$ ,b(Ji}L.aV'] i :5&d%WĮ`K[sǟpoYO`1_ۯ5Ӯ58Tsh B#H6,6_mPK\5zIQ4r Yct>D2,IM$:;ld)#FV)nx8av)CxC"1ă#%(hmZ@܉|YԀ驔4酤 iv1@BkN!$v5PIy#mO;<"3._^qWL79r0F]R+"d4T ׄ5ɑ641H/N#4LD:DHQ>K>bSenB<7<@ !Ge5߼Ej6fr68xi(+`}"RT KF"^:{G6;2mN#wnzmqAiG-GeOTZQ#D:%@I#AG*w5K# 5yCȅ?~~:;&7iaH;|Q%'݋MɢuħB7ˆ|fD&ъMOl>p3;cEP F!*nGKxW_Kvd2"|E'M8-##{ĺH:ļO|k,w<~ 9 YzDa΃TuaIJ5HnaigM/IBnT;w*.-|saUyVk7D ecwQFHB:-Ʀo1X ʀDF} :Z>Wk"j*xw[|s "IJ F%{E EDTqENCбN;Px%\Hk/9;+FL+g I6 LzA"@s4( DGcX9Ƌ@Aèlw;G!$c &_Ǔ *%D]'5ٌ*O~6,a'ذDq}A%w#,pHfO*1iSTo,xO|+2|-}JI$A2}faև|sկi`={m-9vI[pcd%`] u`gSO%Et $`DH^+"xD1Fhs Fm\'WZ4)r>"'ȗ2H%2ʉJC3d(b ,B~) /hDYQ^~wD:]7lTͷah KH]9Ew$<գf]M4M{ K{d2V1 ZmhOuᎨ&gxHg1擿g %z&.x۲EDȢ!L5:(ĢSUG͜ųOa@ޚE^~U.ƕ~g` b#m]ݗo䕓(A>UL:[%eVTh}b 2.H44a >섛9Ƀx{i#[q` ;OͬFڬ.uG5DQbi ??J`Mud2LW47y{W0נUQϼKMT,L:jO+W/_:-,fn) KGS/9A:p:YFYi!͸*&Dt4kVS; b%Kq|wa򕐝(4T$\&kQ7.*rr&X,Nhݐ OAepuø az~5(Ll}0(9P?W$Vo]v}NwY*wKX( OU0ؽ!!/TR'mIEk{lq7[z2>2r$;O?M4I_*kv=:IE,T԰W,\. ]YF\kLە "*$BI~CLrn#Z h 'T{bb$;J OXH>,7u<_F6U<J ٵpuAw ը@t#~AHFzpzi,6Y(uK8[~lh`z@$Rڔа IDAT('{xl]J/H-Ae뎨/ݯOȢ+ vʹiF>H<)yn. qa e~QEFQ*6b'"#_EEt+D[Q=/ql?U-H_y\BNe%`R<7 Tk$8PD7O3!}9#5-RQ;IԹޕ=QOfeIWxdƬF/=﮶~Z]A 6,Q^,'\hՖ'!}BpA qRJ&0jȾ'~hzS]VA1-"B'z9<uPgO}͝0MnT8Uu%@|4A׫̈́" @ `WǜO/#df#aMI%K h+H|),;<>\+Qv.s0D곋NLHꎺ8=𞔳$0ҍ1a2s!LV!B/<\ N)SN;%)$od@kUO/@uұgEXըg>a)g?6Et0J"Cձ|PbMM~rҧcz '2ɮ! pڃ{=_^q}cL=$|$ aDװ΢1{Ƨ@.!!Lߵ6)(mDz}OEly]B_4 W5ܴ]_.V)s3i'5+2wn$M#(E}ͺ(a:L^w] -o[;~?\.Ȓ;bhu !Ȧh1ʘD6L,g3^-j]/Hc*FtIF.7~gE\ػ˙d5%fMC- S܉t]sDpis4U"BI>eDf^*X!2-bx\e7EE)ce|EP~,5Ǘ;9bv |՛`BlpC~WJyUȥ#Ov[܃G1s9Ryq(RNMWq<_grDŽI7'_KL \({!d!^CMn>ܿ;NܩGשknd RH80_e#Il'3mS4gT"bMCBcPOz9LS,tHr?,eb2Hv*I&=($B!\)XF,%(Mn^n_,:QDFM #dt9YLYH{U'nq*VQJE<5͖qz_[ SiZ Ŧ8N?r8g*x4ph(Qp<$ki9e\.*@$vLefCT|{=xL(vT XT@8FQ062 g@8J;^O Z,/"4EFؾm&7COǪvT dQĄCtK)XV'T4ΔYy nJʽh%+P'M{3LJ)E7"|DEЉ hs#HUN۴Gq(?'E9xXe`۠ٛKB0E#ظJ}I@ѴS!ΌzX.;x"o~,G?0N )zCfsxRsrGB)քF ]y^pU %=_oʆU$OT<Ғ伓QK6 cQ7-tpSmJPq˫D7ndd JP;-hΔ+!ǘty`i R/yD<{*tP $Ŋ@. NM=)dCZGN '^^߾].>vS/v-y= ]`cofc< }ЬzQ,{THYݦ ꄥY\QIK 5b #<@^\Y<E c p<;tPDc~0 8<đF=ݛ>v!:"&d!=“ޓ;1{*4,$y:WSk_2mfG |cH ɈLE(p#F VBH+JaNGJ裉u1Z F@ل'0` 2lj33/$,Ha?Oj@SFMwI)i,KE"9gË-R6\ ȠSB~ яn>h('=cu w.բm:(DrcQi䧲( |?|JPQ$!@E~ƒ@[A-Ci.J>ĝéCEJ XD:(~Osv=a~gJjލIîX)d?x{")#ba]k+#Xyj<G@ 3#g@B=l YS @P2T9  m;mcw5Z :uxl R wX@RUQK @Z3S"MWʳ'v^.DkZt;z [+?A]6:7@&lYAM#dfAA5:vƘE f"$<vDL%Yѵ.p^Zxw4,t`t$٠U5 P2cM#fvxa)b#+،ql3X YTZ8eҥ[u;wk̂,>zs{?roM7JT`AG^ܔu(*CFq[V h&d?Ftʣhԅ_(9lck!rvl>P(߫| -R9{NN!'K2!T棌,4}xva jWs,v?vjxLsMz>fD0.V Ki[,YLrFIC"XF0ZZ:9dqSz.TtI@OEt#U;)>j*)GL (2WߩNv$V&ʨS%OMj#9t߫ ٖ!#/H_ ~ :@}}nzS?gb W'YPK6;lRAVH*|O' >!T ;BV B CZHFRPFz0ܱ&ĩ cd)t&bk|ѓu&;{n 5զI:mttaq\,ZN=H]$u/$d İ1tbЉ (dƇD'߅OMK-(A9N;N(,1d';xk_QAzZc YR)D)27)º,v?;@TJ1s @Kp@(,5=$nA c L8clvho䬿O[N}wܽn_ݯp`s@e ouocD#Q!P;)ta\mkQ謢`40VeI1u mɣOE _8B!U 'U;Zw%ozU^|ApW 2rEG2ԝq]`8t {ԛ?Rbjgul:6Nkwa-AGC{F/KF&!OU"J d7\ AU`x8=uq8uDx_nns/]ڒM@{iQ@q6y6dd ' pJBU~2 hrN)בhju?"tohRXAP,[.Kज5F@@{9 !rog*cWp~z2E5vͩb9yBۦFЦ kdyaїe\ol*M>MYJ&yMGl}صpʕ v9l7<6.Q4.b5:XEȪ(i聪FdXщ唜"Ll%mSRz^NDP| B".>fnKCߚJZmh%V:K*6"5hB6rY"Kv=EhD2OIHdD? \@51')FȔ]$P T[Fzf W`4x rnFQ)}ZoӕT͈\v(=8O~_su27<͸jC6D㒑FLHɏP%yFA9DQ҅5dL'lSx 43/LF c=2w/+]0~_"{X"w@-X@HV0K.R#6%P_$Y Z0#SGʎW.k§i02WNeIБشdU> S%˰Dmfr&x }~z鞩{#?/1d/kT)WWRG/!`F"@T6kxPƙL3MqQU!ǁ/ a y8r-/ ?GGz#uT1@y fM Ucz̤+p(7$w= \IG;=i!6Juvbvloduo eDM4rq&ԾV3h>p]d`dT2Uga}bnS(z0hpPO qjT !0tM#{Ԁ"cׄTY+erd8<'y3<Wo]JZ[)Ok' 99"ō$Ү@bڄyMgUI b PxVhE>h&@}?Ȏą@4čdHM;z!ap4 if#hv)xk.&FHkQ(q Q b{qlD7 )bdH_13&뭂[OEљ]7+Դ4i)8)0GTMXmFl5~6ϕItAgqq 3\K΀߅>Z6R:9YbKD#MZ79v _*wG$mskhHd$/jT3_4'+=5@؊LBĪq&aqPN TlN&$2t?po2ouN_i.rAH[rhNu{(7(c hrFxLC\5RpBwm;Z'?/@H- Vē|lė 6ک^"U5pc؉ BaxZ.Njو2ǔvW")1z|vzccO=>-Sɳ2[Ƥ|-B)DžO!|0:ޓH%L ΌE5#LA2d=xfx xNlz>ASru#=f!K`('R|(r#7{ԢH5hZlx@@ jXXFGaJ !DE!Z9/R D)#}ѢvH{]n^^(kY館7I`P(b4(e,.Md ~gWNvxeˣņ+f0ޔͱ[ФzzE[wI2k};,)|Y%aA{Qm(,DF5Y2I:U*\ƕ=`v⧔R,WjFzLջv2l燆v t͡F͇|TVm]%!mHbgΏDa8?C9g[I˖UOTS0~2UaU+R=y=+Ovfʄѩ\8Te׆Fԥl4[ kSGH/C9}S<Νϳ.\!4j .؈@0N7AHԫ=uB) wސ1TWoSFAͻDT}-):^rZ@Љeo>ć,wB|Cw M 4 `Juռu5h: $ʡ{GAL=ﹺ0&iYdcۅ^{ղ,eu&#);VA1)rL~ՌK%tK9u b5\I$]U=3 rJH4EZ9&[N?aqm~O+#V\ &FGǟ|hLQSK٩H&]H.GqU(lN i˻g8mv.;w߭zfjI+4_Gh2WnloDFZVmu1 [?L2( :0Jeֽ{Dc y? ɒ]p-Х di u;Hsw_Nփ7>n`3^{{ i lNi\t3a"Eɣ)i$ynC( ʸYShوt+ 0&Zf`qps_zWw:u 3K2m(']WHB^9 nihݵ1:PGIPt2T! `8J_ƅ-P1 [ ˌ'gxЙu{JS$`S_c#v,\@&+-8zF\WXʇnS6 {P`8UzZ=^Ea%pܒSG w"ri/1BGГ%ʂ. %L_~׽VN0jMCJ#ҿT dkbicfLĶ5w$Jز&!eœSjGTFM$CHbכ8F4?f@Xa֮ڮ:0;SLni.7)NKRX LȾEiCSbMӊ:ޮ L ;r]l ||4Dx0.hx&@ ^F)/JhFI M`#$+"Kb7s$KZ`&"XI4T`@U6&Lm_HV{5R>ΈGր)TkݙDHHׇBU:~Lq1I7Lwӌs=cm-}zƪSLI|dV{bT,DŽ*Mwi=-.c3lR`=3Ιz.4P|$ˉc6Tj5!`7%R6\ J0!(e^bi4$a|?tӕT=3&?|8f q5 6bV46UlcB BB"X#'H@][g'XoL:6]DA:TKK-!8>åK舌^AlMMHGTIu'"ɉw)@J|@Y$o('W]60F'' ,ʞISX^v?ŗ7]đ9%R)]9RO?x'b2փ6=2BfF#:<~gC {YO8U鎌2Uպ!SAa̐b'Hۀk U4#eaf9U\44( Mœ 1wunioiohNaHwE۹a`iiuȇ #V0q a(`*ݎT(-z>aeu Mú4|a,ؓ;/Ã:"Ef ҇:>TWU(? IGuR>LY*ȈǷ] 7mUs(a9D&""|c@3B|1X>~?c5_T? w4Ota1y񥳗>mFilRa#h" SRh_ϲWO G9 GmAӠ 2,Dp(4J$gRzHqnH>ʈ*0t6[gK0Ȣ-' n,+~s!lEQ BѵN 뜢 HO&PDs-FS@N'jB#Z یF_tYOG{rH)PA 9+@R&wP%^#9YV>LbA6Jw 7{nC} RZ[KwHQ|Y \g7;gCL}`<hNR>)Hݖ?iqL=Ki:fuu)˕Ņ43upߍGYbxdF߈K֋/h>[QEX>^s(7z͐d 4sZA38ϑeGMFǶ( N*ui= 9q|WJ=nFfj8W(ґEf8uÓkϧҺ^aX~XeDdxNb _AI_B̊<dž6Ct\`MF2iq ZFE7Cؐƨ w^UwaI҈!F״pZ2$,P˜вIҖWyi]Rؑ{ܑ;6g|ާŻ+Q5hg@ Zb*EQLW,˨(_Ci⅒~$0v\.g"ќc#d%F#߀h5-ivʛJ1TCcKji+H5K2)vuOw,Oyrq&94hMM?͉O6 vH &KuB(; &IqlCL:J<^fFwݺGoY؛:ح3jì K6XSaV)vh=ٔF Eãw\ ֖cHwD@ CI"X^cbt`saXa`,{+T =C!c5f޴l/,8vߊIhD1/ lb5~=N (Z@K1 iOtwuI]ށ0X݆y @ Oo'{h3Mo!0>n86cSP,mmx*:с?ԇf'Ծ?v[rAȪ+H4F~PӤbM5=GZtN'IIсsNx*%(>T̈́h/dQ?|z%*\ AIǟEu(FK%J˔H1hQ#W}H?9G'Pz~ov_.k7]=x l6Ik Ԝ픑ؘT2fZWʬ--ʁ뒔sctU^̌V#sޑ{ ҅$ٕ\>i]8E9|Dщu QlQ+ЩދfL#7[F~oQt4"$Da%Éw-+֕u/&#TaV3D7NwѹDg~xh6SDǦܳQ [Y|{껴MŴȸtn^FuΑ>GN|$-P0 OAFD\)"2"X1-UGC\̄iUA1u5#;Pi$dqewsͫ>^Ye&]a^8BWEBS@AHM'CEH`c ,[U@ e˒;2_ي7>Nrמ)H pn}̄@\7Qč.=PNr_`|JE Q(jaUyaqVQxOlE_=@.0)mMMrðbt;*6!n89 0팖Cl}"0OdnG^yknC/Y}*nٺUu*/``Ah!d"x>Qꞧ iH' zIu;nM1BKDPJDK%2ĕpa\dTV:5YL9SpnrsW_׍mn?<5I WVBOHu9(/Ҝ 'ZVPjxSq46GG`ϡr+" X(L/mq}#ᙕd$Ҡס$ -.;QE֋k Kl j;D'SSTDY-,*#yW@a=_xieE޾{Lɢz @."ICF` ofs)SmqrC \<`zNX.CU >xI0eD:Oob J )t"JxWv;zV6!klR:&} J㵖FW+KӖV,H('_2W f8@yuD8LW_ >9~6txOo#2(fza؃I hv)$OH/nVh%tpB['\X*0wv!,$K[D1B(uH.凎NAa!;M}\9@)<P[^i "%Ɉ픑fRd2}ऴm8鉕LpdsWh/,Xi'$)i$P;)?L$B~Ϫ`M;EeIN #Aqyhdm$lEamZ9]Pe>-zo}TV@"OAP4L:Դ hL dn񎲌!RzjOgC/|! aCdv0 mBD*n=P}FO_h @G\HUf(=Ȕ.#PyI{R>Ok7*6ՁZ8h2AJ"+фkd7+DA򫹂XR"$|>0$D~`d'2"]S3p]B{r Dž̴~o)6UH!> i;c#1Vy;J0_Nlɓ?c%zzo90Q '#M`An,LF9g{fX͐җe#' EGp?M\p" 7̋Dv,p])ȵǡۦԏaTlsi2""U&izڸB@_TSa=o # \G ~(ɫU=)_F,@f<(ɐZ.8&w!>M}?Ul9e3nD7m}f7"TSM/ S]riT8 /;^xнq“R\5ٿ6c BpC_w>됔zW[׈=<V鰱RL }bEk&gA`WA{/rmq7MC> iSO-"8­.H#]:I#d){RWم@^jpw@ˀox&6|\#~0g>J"u%DR/;AʒxgMU䨮G3AbVzb^{ 2m;DD^Kl{S8S6{puu ;V}iٓAEVa,n FkJĶI2=\D e _RvπKk! 5 ֶL6v) 6[{陼F?$0=;=uܪZ;ϓ|H*. ^L]]ȋKo]G<d36h>_XBmX-k Ǎ+Zw([qMuHMu)JOX70HmD`!B0mz91R8+e5 N.A Uemy?'JmYr׭DܾtUޫT5'@@Ve(101ߙ΂y.}صo@t_8,>Ddk\Є9{y 5kCOqq [_3> ϛ' ]D1nH @P#g8EDQŗY v: cc:ev dy@㿔$¢(9m=Qnd,Wr,}qOUȵntKD`Z TY-][PE<GYWݏ Pu>`#k>ͤ-DjjBC#O<Mzt|Iq ޣdџMGgQ 1$HɻB&ԉ;/=A}v$jl PM %B Dd\"ڪ.kzM9m,^avz~Qś6njTDKFe+]wOފ{^1u ,%/k}'''yQN컟|.]g }CziumUL=A-`z J_3BgH`6,%%#jR~#Y^Uzdᷞ^~g-;E]տܝ}q8!7R'y+^R|`ď {2,@D+102PtjGH#79AN?;6gQŇ _ o 'p| n+`Nv'l^8?2|pym|0p:+P,^v$zkDIi1tU-{eCmcF$"y08ާٗÌދ{kJ ~_ܥso"zwQo{.q4T s$lHNIF BŋZn+*Qim]:PV$ajl`!`,~f9=^J:Zi?`Tq&$Y +qvB#IQ0BuGBImmkԧr!MF \:@.2F,n'bţoKv.əmƎږ.6p2i7 }7LK`Q^_;&~≧=\e?N6˾mMwV|#K]7Xl#6@q"u"܏@njy/~pe$edem̶'qjny,!4o]7ӏiv K͉fڤTS3Auz?Yeos&[l@眨.`4r`+EӑϪhR)U>n }xX2 vl͙uߑ\k$N.Iw@ _QY%(QR;Qε4[z^b&bՑax:=7msiR7@o%H. k8'H.1pǨ_)1ʄ^"0Z|*Ć![Qzv鬺V $"Nq:7AvHOZcb;DL ! ɯN~_ds#En13N^1> g4$_JN}UzɊdGEp'S[!<@k3&:vIrurN{3ʤ "䤅F'nY};9 (bJ <([Opѫ [10wp"!8#*eBɭ1%o֡oY}f!&*~dE$HvaAޤt@'okk҉n{C2b'>2>'řII%9.wڞg ˃f=^ ]r қ;; *YA2)QRmiڲAHMrl'd&WY>+iNdza/-+$y9-%qYIl|V `MIގff(IN&xf^|5@&$"?^˜%'jǐj(](۝QFބ1U+Ӳ%чal"s F֋UL/Rǔ7UC9_/_מG@* B ._㸱 e&3\؅,v%Tv(efmow,\;*"OA9׮vCq3KIP!"Q4G >v<8E zl벰#Xԣ6Wڣ;AaREyM-ܦ*˙! HG t i{ơY 3&_y$MHKAMDW0b]Fc4!]Za_N7oCa*k7 9LIz$XWufW ăBFX$ SPݏԓ[b nsw*ų[\h޸D0 }듩 SъfXKu9-\0~}g:# YfVAuy 3| ;4.2l<#Ȥ$H)%eri&mF@6笢v’F$ u!BXoL!BGF?G劘nvuccdeoTUtu,J5f1gfd Esήǭg-H8A e: #{%o(yie0TƄALܳX`W.;"U/]cg=< "YmQ~!Sk#7߮ޟ~uqᷞr( u4N zdPut aBŮ&%02C}m C02Y0C0 alz,:|ҨMMe{ /5\M+E2=o)4[Yly㘊(mw7(Iy6D'Ǽ\S꜏TO4آ$($֌*~拂m'd ?{.':m`<.TH(ըh"JzȈ^C2!lΨ({hY82_}v0uWcGn \AC9@ =e uf ]%k= ~-D ?~9($dH FVClT;hyq?(gPэ ?!cr0"#CS% -n:~ٟHTAbizJ'U=/Rz;`=2=LO`j ;Od3tٝc<R+ڻ|XꀷN`  1o?(g K/Gc{H닟{iD2E>um(K93e Kz @2" b;h?ILY *="뚎/&8idQFy# +6X,6lJ0@՝7#}>$M;y߽yܨ~0'(z18 儠 #=s^ļx[ߒ bB/5`9ҵ \n=\T)Ix'V'GN8 NDqf[Lyǣ+IvNQK@QU4D)Ss&*KL'h_c-xڹUǥG#aG1&? _ aLbK ޿L %َ0/pѤ[ӗYd Cȫ !-nN&>4 UF#rbg}n߱A Y2`r:4c,'>Y^k)jcS$YeG,NR[GATZvdռM,.>Yv9%3w_L~ _֟g2G=YݑęV E >pT\ݯnDznnY+XH{ 00z cIY&fArّ>CB@VT_ #Ib9$JzUO8{OB=\T<4"]TFȕa*< Q`q =eH҇Y7~%cmY2vMe`*怮=5"dѦ}4?=E4ɭ[l[ nQψi{uhkc `WS, (GX?4pT\ڈac"4L8jHc!Y4kRHTLqĊ FyRIxm*fJ _EY/X-"}DP_}g(Ȗll$O3AHSv҅g]qiD PugC†Hg ,ww'^q}HOjx< #H1 HG-D *H@:+[e8>hi<4ax'#_)bQ>uN*?l v%I…$oiԯ@ߵzιQ{\b2U%YL.ch4crۖZ #*T x3+\#Ρw?N~*\J7q+W A`cH~9>ls< I"-ou/55EZ.sˎ- ŨzБ3:m``C4!i5UuTkҊdN=ML;8D&[G[ !Jޑ}֘# T(E8:8}GØ].F]te0n_ aDo#%Wң׮Ρ߭V 3$s>ZLd>1yX*tlzǭG"dI"N6Ȅ6<_9Sl~~߅v@Hr׏`o vm=@e.%>SB [ODSN,b[ NүW^<sp"lQMfJ3lJ'8FGylo ٓPy$mmKIB1*ͿLU c4d89o?|ՄQ7 P8{{ī7gL(&G[ bDF; 8Vc$ e{d x=JD>.BB␴,o] S 툪}"{Nvue?acP:& xa*[y??F-xcV{-3ㇲ^pB0Ï ®-ܽ40&^/,- ] Ic$ 37m,E.+a4dR,42H[HMQup@"j'7ӕ+yU| j3FZO8;}뮃9Lvfn3$6$[Vv?nrfkiE!X~Y\؞t$-z#[ԡra~q3 [2gc}6 &X^)W$ ^2pƒ Q1<LT(ɣP#}K7N+L&ͯd4&-826cyq˜Fb[{ťoi$iܮm"YH*\̍ amWaPk̳gn١?[S& *T c’33LF3EDd\5]C$DrG@4ٷY٫*r9e4A0cE 42-,g r8_O s`xx:b/ydDC]$ lv:3ѷ-!Ys 5œ.۪53qj~`,_C8-++22f#<: ka)렷&Meu(!0pk۾~g#_!H([uw '(.A9RJh#%f{sK橇&EĎ(D&$~:a`ua/OEt0ޤ""'yf+YLr̪ltjtkӟ@^iXR lo0f)Kro{o>ƫ`Ũ:E(rڌ<u~_HP;Qq=LaN9-_U*C::>hZ/aژ"5l[F> J|ȯpq#f V'*$a4o viW\`i)"D^+h!N'QQByfr%K$yK+6 bMփ5hďH?^q瓯W)5NEnDtY+52-:B%Sd:QCo'ce`)tTe 3C-LJIr+$F8 +93FDQ7ZcwiPtV :s{ c]%1#~f!bHPh9V.4wP1خOBڮ. IkN_y_{ gʸ:7WexR1_ 1Gx9 BY=$9`- +~asD\+E}Wh4; (Ȭ-HqJnH~YTβeDd': jF.glK)mqj8ZvկHiAxŤ{ Y߄iHGIl,>Y*]Nӽn^qH/?{UOC/cpq< :zm2.|dxWUmӑ9k#ⲙNeLe.)$\ w&˽50m_wCѬ=`eL[T["$qwDaWATm~MӁ6":;yzXp#LYN?BQQ3Q\dSު>a,i˶Zhce@(}3eK}Amm631 m4ڰc{[ ;~?VY k&]ɖQ.˝47m2?)(i ӧr3ܲy2#o񧥵Fp t[I_1g\uyjvi;GloayiE٨,9! d'jɮe K[?^xY_~ռY+Xm2HKkT4(v0qaE_/av<6'!iw:y6Bcw<;lE7fU+f(G t@c[O>!Mvd(1XhGumM Ɋ h6ɩ'}x$B`)Qu!!f*nj$Ca(Ւ_r/ 3 ݎ2iGr6J>M̵LK~ Gjc\6Au<6П> dپmǦ8Ziy$dW.{[{)]CmKkQA|vmX~%0[ADOF8b;]G9l=z&gtFI]x\쭧 AkKx9- Dž(= #̄;Rӳ.4F2E!tkm AldB >Cii>`ilaM/63W'Y;i_7dz^4+B*D2ʤ^K_{Έ&H{GPW5Il*kb΀F'2y|'%\ʵܝ ^FBŸz(RObqʍ=# *ٔ74r!C8 gIarוUԑ\t$]rf_ ;+xSBN_GVĂhRk^9ڡ6gǟ}%=/#?Gu^[|Gm\+ח$1K!#B٫-usT2:y*g AY I3ɬ6[.X %0wO.C 6 -=c h8`QEhcT Tfwi|0&? kN8Pq"~ՍpNm߱'N@S/Fiv ӷ_j4p[ecAp(&[0h+ȫadߝdh{*b kګr(?aPH?me_l6z M&hehwADf2kVgA8ؾ/d 8N}Z9grPJ$C~u4`wuqPGZsllO-E(UBzFi]zMnw:D4ݿY/''쏋Cf#4Z@$ it\>?E-@nChQ 8֏EkYܧF%]DL/#Hre8/xUyi1B4䠷 qB-95f%?iRrͻ|d“{LJ`8p=JuԪ=? @2U#b7`T/O3WJֆQǞ({.U ~3us^1bapp"ja̻Ő[ .9n oXO sKZ)]~OqmH/<,=Lt_/qo6 C gDsxArIxbH|!'6$Lp=>2%h%FِJ>4wAtl'v/9h_Y=7ǁ+O!_DEԁ|2A?P,@YT{ sqgHaֈ4M}O1xJ[uhI|7 0rM\@Jo0J~E &krX3- C.Z0GESsozCm~˷W_GW|FZfXl4IfՃ\rLmYC`M'3 |øc"6e` e1Ya<+dŔ?YJgmک1ƠJj!Pwv3YرfȑʸWy|5[aHW>y'0(Nܳ,I1cr#>/- j $pZ6pL>4&ڄQ |Bs%l7[A-~{HK/pj8&"ȃnw_]?ޡ6o뼃gbg~DZPnHՠ?6a6:<Է̵AQ14&9ѻlqkds+/3$wۨx.w$%pK;,P:nUD6C\0r?L+׬CHPO|{ԎcK$ڗtYNTVJEq: 6L"bI1Ԯ<&sS KFKRQz#V!aNilFn\6_-2Dy݅\A~<-c,&V3I6z4/z l /S yI~ݫtJJeUGlsPFl=L,ZXw]pEIΈWHw#.`jL˴#'?mØq"ϯ QFdCD ,.ko#/ָo=ZUzY}V( `w#GՋYxMEyy{x@6|m`Lb@ H1zە}'1oV`,4C q稻;5IɴD9Ao{9>SnBڡY9ғ"me) `{5 .Ab3>Y(d]u`vkD^/c$gِRs.2zz-lo՚>٦5aDvTKkFj5p2.ގRxg^~{}J_TL:ڿx [U2Zhk7'x> s|C 깷 $&3V)hضUKPiX}YS[vzrY/hw? ې:"?Hf\ } ='oe8eaH3*`lWqV~ E %4RU _V=u@gb(yS2XbctiT&zQa8!sJEj71WWa?bAEol |+OA{W[Kz3|zQm"jbD|j$5uk%ʇ@1@D^{R&X*h4ZT*P:LJfmT2du o]zwHYF*++D YM`l#n;8 86ashoV^7NYD p6JeKLIC&gZyufUFX#I~n@\d*[a(`t$_e;FMgoʕ0bSH٭+G':cGPJw(wS+ϰVEtE ›J$IgeaVaQSV( Ϡ#Vqsi$Ɍb;YSjlgԦNu`(9?znmrM9$i!3,%CԓB< 6} ŵ'Yw sh\2}7i }$E;d⡂[PBwf4&Z+'?՞?%{#l_-%f3_/ #"XpJY Ibomcwy.[Ͽpw*˿}%)]]a&>[/躀 MosgdYqKz aBE1۴(_6M D Y?4zr~_~J yw9aI\(Õ3nO#$:B> iD:b8QGUIm䙇h8YLfͣck}vCōG5;q\(RW_W?T4Mгhm9=|b.}Kf»xq®m;Y1xfFͣ5?tLz.Sгd-w`- !YfR, 6!nI-62{DTmantFt}[^.VF]m]S^7n(~/ t_-$cm6{@SźVo{i>%KK4jäso k?v.^dc.v)3r[U`Ŝ2o\~Hcd2\֧|}uۦ㗧ѡGkG3oÈ'םD~>!tA.'ZV iܘ7: 0[5H>BsE{#a1X00+9Rkߤ SR)H _^w>Qߩ_읊==&s;F͜a )_ۘp6.YQX1zʤOUT"J5;>Jb9Vض=A"Tbz2-ce੻_*/)m+aWǭhGHធLC[DHppQD /3Js&F@dOrWc|p}eKr _?c"}.5)\H ߉鄓M L7a[Jf*ىfNtF,YJ1.dDt߁U=3D(EN?"x_e$&ӎe0Hz1502l_PvcAG-ƭdiC1E-YCX5G;Am׮<<UJnn LY0 {f,qĮh.AN|OhE'|U@ÐRE֜䌋5^*;]o~Rے%*m`/O'[ۆ U&liK/BАm7}Pm&5wzi#o=Ӌ`Qȉ0tti9h9X+A B>N!dBԖC߀79zxhehvpo2 1%qM]$ M@((nz_{}q0-Y^6#Lg+x nXEE\)!@v,ɻz* 2PB[䪙1= r7e!GfƃXbYQa4sFJ*kYAGjE=!Z(2[QahHR>+|(ON_pÃe8`rص=@-~/RQ vw[V[JHm*,]a3N"RW*H.p fs2,ޭy2-/J7'CE&EPhAVo>~{? \pH!?t+.]1ILb{iG{́0kRxiXYٞjnUCTPF༗k dt'3iR D -(%k ϳU!>E6Ȉ"rɔtZdYlݾbb|0|J>R:pZr"X,Q`=-?<'n`L -.xD_F85.w?FOd00s 8,(ˆZ$O6){ƨ>FFB; ÒGP(Rڑ8T޵gsM.zj DNvy/~<'ka\7sqdumm$Sԅ#"Y{V9j#.R0L|~@>SJpu F@;,[k,z6/%kUOG؍3^ODp\˅ܺ?!Lhc瑗S$AX=ԫ6s[/a[9EƢ0N"iRDQŤ 8S.!j'U!kѱ$ 0dI7_>w ;+π o:Ɏ״X@FtM4q"4Z"&?}g۹&-yv3 kVJziP,+V z:z;zgz* I91+-KG}HZ" Iw?Ѿxq^Ȅg q1˕k(xaH(̅IwJ >nHKmx R c56J Y5yt2n|~1B>zhO5(+:dߎmIҍ98.:=Đ?bd|(KltԞ57v$S$rTCl8T˵3 k1?K|4uD@M@;˲K!@Sl\5BgڲqݥYyطTalm˿'="^s:DIeD"3(qs TI)9I2K}scX0'L^8P׫Q/`A8Y,z:jHWKܹvf@&_}xTٿċliLȈhiD w-NoTӛID4 ₥3>R`@s5/շ>$xb(+`~3pLFpFWQ{/eTml,U~$Hc,C#&:G/jz</#NGHT:`7BHH:x%H іQC@IˈJV@aF unC:R$3>a}_S+xXإoʂ-Cea>u~w!ϡp/wW~;x?Ͷ)9[yN>0r30!6VPBVT H @"!?"(l8Ч"rZDiZI]G"mʮ?/$D^p{QESm0.Cs %x?puj$.H$߮4,*uCF[IF"D$u-FMBGlS #%}'#ޏ C]wv ]\X_M=YH1甞}ꙣn?oaO=6Ǯ8F܄͑3L0 FE.ѫȌ[vW6`f !.pCF ͎@* 6_pUmAbrrJR9ؐ[b1 {\Aů+Wf/ٟ?s]h:æ_lO?;O>>lsg]?8p_z3 M]@a7cq 72 ꉈv01a"alV:+C;d1At_GdQ˦g (i~Kd`׎W~7}U\gjJԦ`Svt̺[dPy))jT_Q3Mx;(UA0b`_xTehԋbP~\> 4?25qgK^H<?e\G~ ?q8l |pūW{Y`/ND1܆һB"#ݩ( 16G !$j ACV1I3 #Fq[<ƷN(" cJm- #Ӊiv?ܞ4G;.e#qKa[/"dI G5{^_F}^GO; l<+CJjm"ǥ-,DdmNݙ3iiDE]WmcyUdZ?|+/ڦ߽%O?urri=#KZDԂU5($ ~0:hI#D)\Jb5aTl獛σ%J;ES;KG\li! Y/(z09mItIl&D}n?ƣCz 4c9poBu.a0_tӼ+`Bv6 o,9TEe*>ۄrC#v\#E~ڕ-CMȉ(_kd0L ||RW=-G.q')bC1q 5C Q&YCq9.%(eț:U0qYBĪL<@]2>20]]LP3K#flΠP06bjl>sjO_ycL[T`B>km;EI8&ŗN'ܑƝnlM a |:Vs> 8A.YJ=@UTYavg[IKo¾W>ŶZ"^] W IDATw>81^~}w(mÂivi7i]oX% Ļ)!4 䆪(0¶TtƄmn7 8LeC+#9{%@$h1DsN19-QdgF$}̉.v(w%.*G2 C^7yi8}a&Ed2n*M]B N //Rd% E]ۊSYqRYv&cS}>fbN<؃<$:6 6J\c!l燹 yH`.7W硝^zqh?YY[9ՓeEm/hB֩MS{PGQ9 l9r-^t1?O:7j%(MRkaЬC$U\ frӸTAsPq9sd[Oa~:>k1~psdi$CU6bu*nVx>Qyqbw-/9雃QK"G$·fM8V#att]T" [IH|Nғcf +/ w$P?e|2]xM!p 6oE}_q F1b J/^eT6JjDӔ'Ldے†1WoD.^A83+h* qO $3/JdM9j¼s| o1/_udᣉD(i<q[V+k\cÁp&I!~$omń1APVohQDHqw)ׅ]%^GKc3FFKm$>q2]wE~mM[Q0~|~;wx!> LT~>rk*Rkܭ(eማ| TT>#Ih q!J&O;ǶTaOq_fԓbz!h΄z?MWy*]l):H0AnjX-G Ȗ~UAѺ䄽p)"Y(kn G܈C,ʘ 4s/aKO=fOE6aD`%[YE$CToرzѷF!5/?Os&aym&Rpرly{&9_oL搐/ch8"}!+2kk1S.^6D%"~*F~:ul$-l=U0p3pdr`e[8}M`dp`Dyg#GQwӕRxyIve/%&-Q$ȘLv,S2v<2 ,D&vё[7~r?-1(W;Uo J{W)E'D:F%m¨2͝t]*R6v`ȧsY$#鱷v:>X{ kގ|W}2 fy;b]Dz; O@ `^3_K۰rR09 Hf.&цٖp&ڴZ7flKç%Q{s8t_Rt#;'΁~s?\`mIA6'(yC&˯H$k.ią-7 ۃnKEn_lŻfe0|*8^68zd1s8cle l)Jtl#)d٧ou޿ %ȕ^x1Dab$:u͓Jd,ܫ {H&cp?RBąx/8(WۃV$`CIi_M"G{S[HTQ-Edض}C|ζc=x".3DܲYir Addg9QKdA.@p-?dɮxG\scf쨛\WKd|X#r,^Y4}1U0gѴ.}b5jbJIfrcn?};+ֹ M?Eu_mabH'ZFgC0ma*46O1YQ|،.pϸo^~ h]'Spڊ_#3v\ڎپ=xSE=Ig dL%J/Cm ~qw'E{!o TKƱuG( ~&p*sU+%_%~"ЇݰbֵCJҩH A$f~zS߷}S #Aʇ!OtȮŋ_I',CdQ?@0KoCMLmG*T~6`|b67]+θ&o0_`N".2{e@>˭UB~F1Q~oyη/Rkc?+k ?xD1&sR?duR4 pɢ*g찙D&|FH<9 :DD,+=FN〴-ž]ik|V119Kx+*.v1 EJ~ y~|tH7/?"{ _l,_8W6 -$H`y3c:`! |}u/$tVivNr8HO JӞ@0lWNr ^SS< ׃ur-˥/+ĐQEƢY 2y?Yn >T2*e>\, 8ZlqCX pFk4c360EV&5`*aE.5)#](l.ˆ-)b.TJd| ١m?}I7 5T\mBy* NQSG0!;'(A4t/QT40ma0C+ &'adԃĭTb~&2d4 i414cRqA{8ۤi'CG?4(*q.FKH_ q"}'5;cbl=z_Kg{Ķ1ˢ^=R`?.ytrkG%|TC|%9ٖbk(:J/߽|Y~州+&@ނg:2,gܗhO?֝o@ζ>wB?֭p3QNLO]+:\d4$WXz`Efq"S@1Za?Ł0[%(֢ԖFXlSko=_{0`nmJ7xw %4jWzbۢ'?%;f!\LƑ-9ʑݝuUH427HsVRh+%BI?fAWʪN+Zc%,ʛr .=o.pl1Ogk @RMn!% \EG ʞ_e\E$JPH5G{$ǗlHⴸ.\|PV){{F;űRxZ|gVf\d˱b܀^Ws+=z}p'Q|!=yn:]טd@$dJ/,=GU_ )2%TeaH}sҝ/~Odj缴G%ْV! ->Bfn z]"rny$kL*+KO?^xfaon oI!}p_/94jFYa6DycVVOS,!GEz.UD5pg@v(m?}㝊j5ډen4൞8FԬkOrBb~&<ŢcX&[, Z*\`?O<؅-m'd V=`2bT]F IXM]Θt >)jn&zȈypO?R?yk5_n\k<Ȅݦ%C>C]$0~g~KIGI~m30Z59YBN]׶ YńTXWD|" )[ ""}EcGnM,V}m,u[dNgmu^zd Rw")ٞ؂MqhtCOiqfa{&^Xa߼#TVmg\ݚa=<ځm{)ex8~N~*2(@d^Bp>{m_8'~Kf`qY^(ބoKRR)qF"P#2uRϜk/ ڧ>{ij3V%=hGM Jt Q tIQUjcwg^/fZ=cCc&tevt-YCPL6dԣC@pZN@m(cz]v;g޴r4 ;s&[Ó?x}F,9ru'fkL2ƺՄ&pvT)b]0F&"d&^Ec˶!͋,S:4,b% 5ѳts9֖K񶟆}ZmĈmnkKn_1yxgT p} kRE\ hd4j#(~J70Q y]9JzM93Y)ɗs`: x;`’$KȚ΄Kk+N$-i^ U=Uk3:aXhDŽ m r8~26*#,dxң\OijNzSk N=\舢Do5ESY ٴA$"")nF@t]BVj3ܮ06Z}S(i=JDBprȢ#Bp,mS Iϵ^@.F~/:=r%WGb8rLzݳ;:^ 렮eo6rd2LG,~CZRsi&=u b_~gkz`^= "j ~v;+r [h j=%HWp"1NpȣQ`(W9ko5@8ҎHXj5&QU"2`o5Rڊ4&amN'cFވ.ibUX*eS Q䳶yl8"*ƁC7Y}0 >ܸ~#=`/]mmv-9ObxMlG%R:^.NbnahP:HvL2ؤX.!^U"=4'4!a į 9ޠlU,Bj7Mv-K" |H*=ncY@dt<{F?bfl;Kr͌o a"ƛIkHֺY uĨVNJPbhS IDAT*RdxK5gc֧'ȫ+7({ ʩ"'iom/2fڳ,Ҵ^}Cm>l)f |~zlʜgH_DRN8%~P:zY!.xB"+.iH_@BԥlK芹d{*J>ߡtdd%}[s,}Q%eph<77,YpT7UCam$.ID DE)WmUZЖA&ς[A*E3AkwnCC\*7O[񗥍)$RaoTItO_d>&sWNE 7Ɲ?L/ +,-2/b vG*"(;QIKfrcz/X `B.ZLumk/]gc^ @"i}l#Yކu 'vP<<T=,/?IWG&Nv:e?o"XE>nA<",n6L+-fLtBq- l.P ;'zd2aY0B00 *y\Gd¥`lI@ɢzG~/}ӧr0K3HBR AU:s=jDNU t"PL5\`S~Ŵ\j 9 `nƨSadc(hs}K "V'Ӧl/m'fԗvDCQYV5~jMnvo)5ȗ}j#d[/8l=d/Nq.bobq$Oo0&("唅q ]h`Fl… Ie|~mbs; t: ǞM9G` eS6)7<'+n?={4A\&;חbHA< 9 d2qDiL)a6vvRs%zyCFe# V$5c;?oE3?wkcdG?s[ _#bl58^e#dώg2_>eR!Lq=YU!!aD)B#@N m (i $( zH/}Ӹ YDe8s")d,H@P_[ o),h.^+WT, ŋż(H)&C#L#'l=3g=F$:#9a6SZ}o:C?he @PSP\>cAwI'-x_{KJk h(bg'babGW+EH#j͈I A@hw}627󜛕U~0^7oS73iaDuaָᑮ8 :>5T^&P˦O*rF&BO@ey Ft1404_U+=R$.Vr]>td'ˁ}e > OsP68-逆}z1t#>mWB39}K\:M?IB^NU"#)0qaƒ$e @A~7SYsF8e}~ M Q,ȋӔH^c=&˿8i d֭S+!oXqeD&aCΨ(KT,c9\a#ŲTrR^O]'! x!ߓTZD*{cպ>pq¨B&udq`oGr{mjKԀ%VP]Y~0 N kdx>Y-AIM o Y%SR~59!Q" RDS54⋒XʇZ깠$ՒHkbX$zqw2x BKa]VYHlrZa--$5iʺgew!oNM9;fđ剆E؅VZǘzCICEQIU%iR02[T3_"#5C2a:쀷~U 2\^N|p[_0\:]d;p3~Oq{<;egctֲSzn\d. 7VK0#XM@V$1cP~ײ|(9L/pi_4RZ! 0Z$zVI8dxBT0H:, od .0ֿWFcyWߜ^41=wZ,EԱ($H1L¤aΊ }[I1Sѫs#:/LK -l6%@DyDht+ [7=g Hx1UK $uh>,2O xe5 7 :'GU\ ]$2y?:赓p HR~.;ޚAUj D<jI: ʡh\pji78r8 e ϯ_*dŚ> )Ўu"mo1=ŕ)6cץwXWMuo}{G"m6|tPX.BFцݖ0\Ȇe;aTڥ}i_"9EC XtQۀ 'b1¨}Dʾ]S6hL ǎCظ#P޹#_~zӳrjLn~S]=2e6ѲJDʻ r۵QgXhC bѻ9K^!3AHl;8}#Ѷ] MP17κ=[Ak;+QQD/݃_='ODMI9XbNC>`r2bna}7qQG'XL[#%!vyoM\Jl(Sk++.jY#30h+JH9NbO8rAQ |Acط쏌i"'?…3enQ%qȣ-~syH Z c Nu0D RkՏq`N'1Eϼ]fL VhKX&&zH*+$ q4"mQp<,XRko˖?d#?|烩Fwm60BI!ò(NwH|` 6ZP7^VX0 m0$*0 RȈ#!rh%*DMqA)#ݪۄ辨'ec?yOܼ bTxEt{%SZW$Y Ս#_;BʕD$%e;nc̲A`ƺ(zÐ6)ܸzMn޸)-ATg,5`I <"Uź3Jf|#4c16*OpԈFݎdBR N|RξmeO?W:lRV{%pXk_"i pmqpItİ _c|*=T;sӘ%zeW-A(1_Xhڵk$5EᄟH&x F<%GXc f"a MrmX(";F iE[r*)T O{V+*0&.ޱKl§Em# ÖԨi6Fg;Kp`5EsIftP&?A!6}\8t/E¼F|[s c)0WWV%z0πzDz|~\?}$;XٌaA-lەӿ@R; =([[i*B0 'hÊ,Ҷ:QIZ'W\!r=@ڤp3Cm^deT1c6Itt X_v ?bWo ޚ2 ;X{֐N%"RL!~6?,!QcYzFVa\ "HԿ=,8>ő-ސ9< z^ ~_re mH$Kk>qF /&$jɣ%hir^߸-)q)E i5jAkq[,آ>N۪ j3(5Ymc~zLb[ޣOoiM󟞓"' )S2~R[<ւC"WSi3HSzmϧ1)6KXi.Ǫ%`YcR e=%pct}2AEr'JdiUi ˆ1/6iػs xΚV(đ K5ql5.ßEd]w 2!ZD4:U:Sж "s bT0VU6H]8Vʉ lj7BM`xW2/ Owcn6H*82D[hAq锷m}4ײAz@Y:e oalP*0 slDϚ8"2,K^2y(4j&cC›| 6$~R-?e\4ȈE[k6{唜xm"ep=Z2qXgph{DK#al'jyC8 `D(9N0[9n8" R""Ћ?{$U>% -vxOi3e}4?KrV&nh T1;٣?ZB @)LN;IxPz E3uuԓH[ -2PBv8iM r=,Ğj~:ePrf}ODw,YS^9$$u!ho_o8{yA~H4[6nqxm,}r#{H-fы0:oDVUtHX(Jn5JSm8FmPLW \jJ=UqPԍOrjl Hkm,ߥ#Li7|GD?MV}iȝ$>=z_ڇcTFU|`5qü.`zVs$ΫSECuˋ3G +/Du^ _/rP_#$ ҙ0U0`0 hD!Ϭ-+("Dzn<m5GRSd6̨⨡kn_w"$1_;6i{}1j2^z'`Ό/֖ 8z Kљ0rΟv"p|v5TV;woUK8񥾇ݗ]n " FLVC2h{I rMx]UcQ֭Ѥn;d1_ VgKQ+Dkڶ M;ijE# y}R]k+=2a[Ep-C4H} N:gTqm/wp8D^ SQ>8 /Uy.P#!J>gz@QE/b3#611S /Ϧ>~Ey#h2WAosE*<a Ցz"j\)B,T?:L15v]-#2A'w'FsշN96n >p?Ol>3U$`f{(%" ;Fd#@e1ž$z",=Vyo .d$ǗkYpn溔FSsC&OҔô jDz/ߣQ`YN wX6HTqߖ?vZ RlDdƺn1<lPJF2I61.<9Ox]z?8$,VeY&Xt.TFȔH7`7ui"ʖP??Ũ-Hi^zɖʦ㓋KGg }x{gU8H@ ^!bxq{z4q2_/42{ڢ)r1{<2TH$HGcRؓjݟdѺVHq0"N Bww`T?G*YhZI_7'P1d a~ԆeB IDAT׏+OM⠑u:ԃ1a K!@&@舾JSۈ~o vc= V&IEmXlyHڼ"6" ć\3fu2Q-io/Gt|[r)jAR +v7GY_s/ΛJQ'ods$X&?S0h"#W:cVacfcLEMIp@&m4 pS1IaA( 2cDI#p;(>$C8|pȪ%1R Hfҿcƨ6ûVPV'#h&SKQ<x=t)^蟄Í$m9#k3K0phߤiЫ73voTg'9 IEX^h$ޯH !d 3Α1@ҙѼ1 Kb gfAZNzC؅FWiy$qIb?wyH+{vlUr8oanRIa7yR il1piXI ܦo9bd\4p~>~ dD@]FT4ߘF% 2ƨ-2 鞻I"*.˅1Gs2,m $1:,A'!5[I޿n-i+='k* -( fx&lzc@ߝÉVD| gL:~PK)8&ƆFldAWI{=~ҧsO=Pd_0gJ},[ n|0eD st*A8~[y$DPDR<2g^40 ḟR6녖P"{zxsV>N\Y>#{YМ$Q#w<)m IRACj\ 4dh$D=K&i%R/bz4!l;pHpjAzS pk18"ىeb3 SeZ?;N|ɜ&Zf {9 J c&-Ssˁp:.ݔ+EN^9Y6+A= "9O *E^ "ʘ̣[b-meHBGz;b*Qj7P'Хwaa|)-C+ڎ=ڢIn }ZغeK^vbμVpl.b:03>iƉCKEi]&-Ap48!pTԡH,xjJ@_I$.7` qz.%GH RJVQ+,<jrT |:Dez"p$ͲC_dH n|O^is=fn8R2G|&?!܌h أ2d*M{`-Nz-c jr 4FT*'$H`BJ%ՠ ƍ_#TjFGd3HN&.RQFۢ0ʎ];46Ν:#/_N>i m>6su5EZmy1v>yED^4fkTMSQCSz`AS! Y0X* 1fdo?I[#13HtǶ^þw'x@V-Ulx1 *ɣ&\DF hAa| fVw0sy2kĕVP`5G>zA:y|M~ &` Y=JH=Trw?ؙ4>~~G/;1] Ix CɕF-BaüFkZ fivMsd v@|k~Ι+F횄 &/Ɔz#8XG =Y>K姗 t*m瑟O8'/M6m {wDBfhшCoaFBF^Rjuޖ\!V82jsE)a !E @ hD/e8\M-C|Y}vHt =(5 O='7nh8^\xY.|zVD]g;o+>/A7\U˼^-=]Gi &SВmh#?C/?>.Ǐx1<أ6i?SZ#| 0r (vlg}2e0JhƔ Ĩ0VJ@N0F?d|)*}CB5,D},#dI/ \cdu .%Q5laH'V>I"Hӏ?iKvp0AڔE t}z'm&WMpcTK)0ã']9 RI&CaIɺR*DGj C$HGA,W02# kd{ߕ57>#+d?⟂zÒ&۫pXOf@9Ew;ۚ4Vyl s(c*aÊV/ f10um!at 1F@b*FO7ݿ_t҂,J Ylˆ4a >YCR3˚Ő}:|7eG!p䩩0=HcɃ<m"_?< `)elZm4o4TD:ا"] #].E)F2_ ;Xzo͍'mB)?pXKOa<IM.SCm>M$vK)bF\v-hS_.2%3#s {>*B^E"Y}5MvaKڶyE?o5&Sm޴Y|4. ]EΎ>眬+n)l"谹R.ȥC?$p<&mOҨn (VYUj_ [""p ?0yFN菾7rX} xH \ŤRRƊ8v#%jIc \D@ò G,o4`L=At6n]p iȦJ~h NĮiH moֿDXY*8AS8fm/wթ dƄ#j E|/5tH"^ m`8B*MvH02&8 ~MΉKQCcNBȍoj^Hvz[{ lLΙ$k׮/<$be`y#4/Sum QƸRkFxU ȢRsNZGq mT-}ګ>j=p2 zdJeI[[BTl_~D5"Wnj=S ByTI#?擏K}̚/AݵiA>hw׾h ڋH":6UAٷpM8X3؊69p(1Yr ɈhTc@G88;!X|[yImd!}S) Oi6)9A6ߡ(U" ۲Ɇ '$?R~ {Xr*Ql#< X/U9Ʀ*:X‰ 'Ժ@#(VrUtu|A2ɰ "#H˚ú~d_Y m ^Oy$YA[? ? zC/ʭ?/߯Xq8Sх$F^DʼnNRiey<=F} IP/գv=jmM3 KK.Dd1Ma ]l!%M"yE3Pg7gڨ/?fߛɦ2$qۖ#CF8E^òY #CIdJ<$7] #d+W4YR>G&_uI:B$!BuZJ1%DT;Y,“+~glZ(O߁=xJ +Fr*GuO. b\Oh5|Ԁe HAeY9V_aNZz :8K̷g&&%:wƎҚv$ڛ/]^ɦ2$1ߗyHI[Xe$HaU=6ߌzh@IAsjYb1{)ɣd/G܀vPC_!OՅz6drΖ5pee_E'!жqupv{-oKM'} /4_z\GGm+ľ- 86!IO<i1HvpkM]&Uw8K P#/HYRZ[ut귟h &X0$Ueܲ.g%b,mɎO%;\O6'9yQٱ5߰@``iby/=X>:OVɜ,%DD NJm]$VAE.tKI^Cyk'Iz]ȢOqHe1DtLˤm%u t!]dE)Ax>zCsN%y's} M/%Lv!UM`O.b< / ld_d;6q(acCCٝ]4QuDђ_~^J?ñh9We%@;_ZF!iIr'F= `Qdđid4}U1a_`8sKO1b>7E APEgbϕ!AT"2Wg΍5E-ta; 7,(Yh:a 'hT%bLǿBsO? @{%-ծ {fFd ƪhpQ |e8KҙW]x|b_APyeܯ._a䅢(%Z6Jp!cN;Ԇ=^{яdӠ:)3Mw.<SyC/}^L@Bha.=% Eq= \t}5vO4V&X"S`Be׵&1DB`/Û]Zgj4EDb'aeUN>Qϣr@ًr֍݃]}ߺFS V+E>ݦ5\4Hڳ-Dg)ΞgU V;,K+ 9I)ۢ\nr~nU Hi$F MWʾC?7Ȥm3K7m /<7aܨ> #$0 +*K4\ {u|nrRlFMK*i̠͛ ٚ,򄸞Ślx/Ȣ9}Jc#\- _h5^|cC,^?YO.}p9袉)Xa qT]1p)+H2- I؜ݤ Kbag~^@'Bmz>|FHfg3ua"vyWvyEba\cĻZMy'e!_4FʍZڏʟUFЊ4EYڠz paL9OѤJa="2=J,9W&'&I~|q$֍Gz/&RbbH>5Ytg"l|'VuR|[7nʡ7/9_:طyA5G۰ t,5 (#.^@A.RLDE>Z FZi_0:8KQu3/,^Ӌ", -`ym_8$i0d@I۲dC`eR֬_IwC kvn6: se `,U˗(đ"N`Jja无I^$ӬOQ~=65olI]-Y ӗGYϫ\ HvKcS|0zDmXF7\Fgc'漓w&_r`19^KC ZNPNTjj:({qȇȴ,D1,rKl^L#٥"AL,FE;%{eY#$팓zxX0W *cZ!wUXl@-lKUC뤽]q[~ēG- Yt`R= #R 44DKY"[ mkEev#thٍdQ E 2,ʬtʬ|.#_?zrd>}-ٶ>5\pıWrb1{д}aqK#}h+ 峀Y.b+b󕧚B6J*!3pF IDAT4$G Xe#9 Rj$b('fhY ݟ-O=ʟ{3O-=8Ow:z47iq֐D\;X,}E8@uxǡn6-(Ș!e}J9 @A=ey 趉eXҸ"u_"J Dž J/ls59A˜d+'>x!HS0=RD&$kNfG.tkm}21rQN VRL\.dTbza_"u Os%Cy0u]_B$qs^M9qTh¶-[sO< 5/[%+"Hcx5Ant`6l;o2 J>Sl3gX_t =ENU:#SzW%n-:E+YoEꫪ Ĕ6ۡY-ًE]i9A.Hė^": ݱsvq9s\ٯGתulޛi/'7jγzl 4!-<K&SHoD D0"QbgKkgdG?R37K??; 3\{C@&C:bd(c@[Eap؍7s~؍;0zѻڟHvdUOv cdyAFxM'䞝's-9SY,%VW§dQXlٶħ|Kɩۯf]>k\ D t"{#1aI؟iM;HBiJJdEU$*đIֳtḫ%!:% #A>v}O}mKMs7;rtjF1r)2&cYG,hFc4Uۡ0v.rTT?va!,F/:NTG1MJhD"i!'"<xpսb\.:M}ݯ :-+Y.Ln⮍ .vGޥ&8U2S0\__0^' qK%Mö1"$[CZ'FІӟ{,9Fpr.3&gOZiFœur0$MߺUaf?_"'/~~cQ2ޣ@svC%$IKWc܃rfϣIXbrw-G NR*v[1w~!^09쩲AN+Rj.y5*!Uy~XgVX)=Ր :=–G"d1{ϝwB}^-Nnb[#dU#IL.4 Y_?S"Гc^uPc)gA؟hZ/$ԝH&X4 ڙW cxf(dlvVv'/JDMo, ^[DGP|1Lav]ޗ;M';7xGeﮝKi&I UQ(0t∤n-&?C~C~D-2% /dƝIrb*FA#"B P %@pAZ:L`GmrDz,iva/捛7%SvI9}ɑ{- IRk>`jxR2dѥQEC!H"&p髲Gpin柽.'?MK$!} G![`mKVGy2{Rn-ˆPa$%؍"SƓ$:yHMl0ߛ/YoP&ߞCkJ$q30iqsK]}szԌKda$&"ѤUNF4fQ!@ c3'YIP Y4qA֒ȢQGJU %(N-/Uɢe' zҁIRמV'ğ2/<\?n? (%9Y|ƭyy캉n]72TOOC#I ?.o>*~M̀ O LCņo v{Ф̦S|O#[HZ6X*П.OTRӹ[/R^+7(IJL3ȼx=4,Hk8Pv':q6]%j^4mid8f2(HܣXuBI|BѫWGd!JC"PE,^h*Yl"9 @nY=^TWMBTfi#_Khh',t6c/%YYy,W~g>ğ뿒+sN=7cdѺt@ Q_D=տQQ150llՄ $;Rq}_NGʓ Xj 4pZY`i]Y;Z8˩/5qv߿9{,*:Ef?i&V6BX/qlS:8VcaBqbKꥨ2y]^8@2@SLȳmI' I"Sd13ۊ?"lUT[g'OH aboʚ;@&N|,-$zWmB&l!t3gV"rSFL׳6~9txx+ʼnF1b!w 'L!=R") OAZrB]Fʖfgr׉d;idoޔeGaTc9.|460z#;׉!$>a_0'*)LLo -eh!2԰r5D7eIY7K ~DFsEdR^N%KUPE.a[Tm_^PB} l{شqrɓŧw݂t ݺ&:Sa0- ܄`iVĚJ1*?:t#eЀX$|^ŸwXw*Mo?y{oLݗ6ٞDL2!Y*a` }7amPҤom:1D3X2,4/"4Fk7tWXtJ[,—e3>YV*~Y.K۟+wx.b(5 {a#KkU|98е I?f 8m?/(=y2㢿[Zg̑W:0<` JKoQ6#>gQ*\̎O)j>N r)܀E3X)(pTΫx Iy!v H~dѷȢ1aeSmfs̐1|=LBh3f?}g%>~Cz鲼~z\=GCN"6#tdGx:)'tF KH[Hbͺ{Oc['p+cGKW{$eYP mׯ'+"^PL|kGEu$jr+VN]$(~o4. ]..$!q 'Ο8 dzsЯa>9Y, y["zC5V$dL]NBHɡ h?XL$",BV]Xefʌ}2Co, t>Yg3 _{CzO‡S%CP5|>HB oဥqn?LzYC 88Y LU$:O zls7@m\tc:6]{>}C~gf4^\>ym[׾$k6@z&c4F:fSo] `$S+ɄM DhƦtUcʬZDDkZ`&a Ȣ&MW ˆ> ,'rm" @֏,:x4hwD,V!=F×}wsP, ' 9~qQ޽a*=cZJ%jrM4`P?qfcBlxb bEj=>ʉYbUl]*˩<Ǿb) n9 _lueUg`v[DA뭜Sm\;J"“ɩ3哓@޴'?~]_ nqsqM& G+X foj}Ps»Q2ZZWMC ȣ ")F5rdg'!oX5@t|K;O>91'$|]oTX{hc 62mFuT qktՋpFuq.S$ {UwZPFL@IK x`꩗9AlwGly;zIi/ @ģ{!9:$/p,Ȣ$ E>5|we`cPiFwn/rpuH$+.'v=Y$#@+=`Ō7>D97% %Pip&Ci4HBBS$lzߴEĽ'_Ix,Y C#R"[.6X 8"jgϻP6S/che2adQ4hڻαKȢ#V,JK@҅,_'CB"A2TE_Ay':49@PKXI֬]f%Yթōk- Ld ;`&c/\_j F&A-H*Y6cA7Vd$+8O? Oeǥ3sP+\?ӗ-><4ٸU eQ Y;m(lvKӍ((RgF%]Sܨ0̎XG[Aʳrc5:rCnڅ,*I2r|ȗ:$$ ?Ҡ`FEOsi AiϸQ2͒g穼m~~g^~cdq[vܐOkeG[@_,֡]{Ob :K Nd@Nnz RlڦBQ vZe2b S2/YL'ךDA(ۍԂu@TL^E(,ȐG1sEH*y+~AcT0ao^pϮ]?8,~-?fu r&O"݄k+$M@yCc7>st.V1KyRKX,\Zn䴃IvG6_]WO֫gFc|KϼMӲndG ۂTU9}'D3iGa?8I)ڧv1,׎0d׵xjқ)˪NIUabS= ɢH8yZ\8H>oED\/C^âP,/άw?k|dZexpMٻ~N޻YNDx{<3WlٴI3mXڢu}YCN-O%VTDI ~ѓj;j|DY.@,rS(˩cCdQڗ,zf]ɃX8[]|vKX%d>[Fy9-|Go-"#~ ɺ|93G0Y Fc.$-O= /vT."cb/p FQk!5_]>!5v(Ї+"N+oxhH~Z{{/UDX}%Ů[Q AAdGz0 juڳXUbܾʵ+ ,~=},~!yttwmurF.G5\1V@xn훃b,)Chv *M \nD4ש>wqY|tYCrM[jx͈h[dQFLcXBKYRXBJ bzwj{s*;F ?V/9_GoGt9j I`]`G9la n}%TO>?uX˺ Ru'Q 6pnl )cEFU8#"ohioj U'z!7kfY$񦠍<"ݤ kZ@y|:K$*Y3u^_ƅ@Ǩ$c *u^'(ƻTdP<-*${`r]]d Wrgꉨwm%{ɛEMKB?=uj̈l%"E$[ vr;iI{d?hàg4~~ ͎e틲%\ʸqsQ~ډeT=ؾ *GTe9Kkͯ*XKOҚ8F^c˃Vxd{DH`JI^ik 7 ط1X[b:n#ZxgU|^")"VɈhCDq#% vn+ :m1l%K,^|U~kr峧6ρ4/Gu{͂0nh&-]vC,ا#N}H#! Q x,,h|6"tb/zWצcb@ 7@z9?;%y|ɟfZ=:؏E2#-0Lfc&qezD2$T8X_f )rE]E@jEL(5r,^})>Ȣd1c, W hs"*E, dQw3DRjp:DkwYD}mvA/?$OH0R: 0vmxX (rRG7 GITzR0@rh[mlY"[o]Z6KJ]bTKMQKM[ ;.Ї0y_V.U2"ML*HQjbgF8 2h=L#X 'E +@_VkW Zx Gɢ~{Ec [M ]ϊ\&Ŭ )()ѳ>p.YfUTQdho$èYy,Ȣ/(~y>I[@,` M).xU f(PO'dEV~`wSJLÚl^] We+aayF 5j~bbzv(f۩6dW2VO=Nb8hgCLªE#u6Yj8eF,"|m"|FԼZY "D,}kLX*,ZR$[0>bo}F o(B#|#'ٜ,F{),r(˿UU73W¨|V aJ$w_WoQ.׏-)XFT0?itZQPϖ_O(Q1PDYZ{Qh$Ъ q2o@d ŕC K,G(ũ?Ԕ??F1HgbEzZ"Yh[ #zaKAb]@MNkKJWmɢOdDuI0jt.4|G3E6Yd1;%ymQb+(;PE)' S,brk x2nɑ|)[/.>yP~+ʃۦE}o]_+'nmse!a]EyШ^JX戠}m*<׀.QMKް-J/$>nm["ɺr%R=طRaj!B񩖦#J׵?Pt1;[?} *o[*I Y72[۴o"9e5FaUղ&jי4"+1",*KW m><X/G 7{^ s?n0fp&z~65KȺf>b$wAs8xDӳ|ljlY;/"'έOnos%IPHPn0g(İ|;&olp_H`=7Ϡ|il"毁Jr%9v!?l޼8|"m^OTCQ`N._쥧dsڑF[¶iSɢu쭖G&B'0\U]#D\"knh +t2ؒ,& t{Ť ,KP+` iD`*Ea]\GdQN468:4__zd1?{IȞq*nIAm V1si}rQX^W6 Wjsqʬ ?{ ieD+Y2YIl*n?_ _wA~eIsD2Y,XP}EdxaTrpj,^D2ě&:6YDi-cZ/QOp(ǚyt)F@_|퉭u҉l-ȝu|֓ kzVn.bWf lzQ7*}| ˆlN-ޖ 7Dq6n\vR=tvD)cǘ3' <}&|lz,4Yd+S#Hj`Y%~im+Oh-^JV1`);!f%;9$*bT#S3J9r<_2<BWwRT yDt<@^@Al)lJ䘋Mk,˶Dz2XXz ae~093^Ɩ@K$ K}'dSUYֽ|Mn.'ʪo[-tVo6NaR0?*yXΏ(ӶXb}.<Лޥ[&9ޠO џ|< k|tÚ֠;4,sS?,*ڀyם5}걡l`|.<[pa;LT3n ÞB^wVbO$TIrM,vV^JlՓ!Urd Yn_C,A~˘?fbb3`3׭wn ڻk='Xs/ѿ ܱl={Y*=*[<48o9#X SN6i+:( ;RLX'P,z1Xl˙$~\ϢOƉEWXU呛 3֫HۉtsFl\>Er&Nت%Ű. 7 Jv|f(߻.}ӏ=^}̽nbޝBN&S/Z|]lfijfo*{վz8ot/ޫ[&#/S?xm헗MJb+,#iY p=,< I~2Ւ/:0sq,Amk؈E||]buD"WkKGtk:bq=t[ߤ_XcogO}>]1{={t+m tY%{'$26 ϼFϿaϮ]Cj6Gp AsK"ISvSGX7@R'R")b͝08n!<73 mJQѮL Xpe] jܑػ;Eٯc;Ǯ^O3$C z_%tE*1JeFv&ZI%;qm6}}%gߧs짿Wٲ;=w-. S72=҅V|3Ob*4nPIEmExi闁#ݱ.݄tW0#>yۖJ!TyN{Wb IDATM,W'7)tY:FE/ɕ"XJERӍ"/]a~!%e j*>~n -Wcx8S'V-pɢ.>yN}ϿMW/_aVo^A{Mwow00ݹϼE+܋ ֟)Qʇ%E &EHyɥ[Ԓ4.Yb1H>ޘ-@ryXY@h"<MdQI n jT(pm!+ZCNmspn}ǵb)uvl^9^$[~N_,/:_WV,N9>Ч?vj0$=Oڨ!];,ս~TIt[\/mkVܼ#3`gaCS~߈HE6DQxP%ѳE&>BRs],ևbYn xq#!./w%yxvmA.?֢t97}zb} GNjQ d3Ͽhc]ȃ^D\NV/ѽXj,FI ky8 jxӰc:*Z0,"ehшE1 -%sI$[JmF]գqW0~\Aα mKX5nCZ_,J UTC.KCL,N)YU|WH>͕bȵKW?~[Go/~a:}`bHL6LxStwF,~BWp$BlS(Zw'łRPx uiY1E,Hw6:,{ޖSVE|n_Yם],Y<(yyl'αfôPĢp' nWolS܋iͦB4/U8T{+GONޓ,תߢo|^:w=h;e/vyaCG￟vEDWTVë82Z]A+.mB|DȈv]cQbOK.]ňJ0N'rHb Y=gQH,a a0Nk_$(8( BYR|?B[u?Fޱ;ϟy.k>E`4ևn?[+6C}9b:bqd.g EڳS%~>gNXzz߻(֫\Y{Dd "3YRU&D]җ"LsEd$/YRrs`Yb-E,@w *Qi1-=o)h5Sܜ8|?/+ɍOϽHxE:W?} Fce.)}g7Swn]w֭[Y>cNtw1:74K2Z, Ab҄"S!HUmn5*uǛQ,c!g^ERqЦ6UT|t/>0,d]5}c}_JorS+8Na4FK\_d^ej}jBHjת0!뻙MeMc Cy - d"i&ޟX&Cjcfx pt%N7AJXzʫ(b7R;4((窑@uXgH}C,7UbRMD햇h,<ǯ>Vyow_o<Ͳ2`!_>EI۷X>ϝD/|^~y #.3~Iإ/#&EjHREw3ubbж,lSKCK%!'auhb;b1'w-A%>dȼ HZů~ЁOxoWߤ~Mڶs;_>z~:<0a؝j7͔]>=@K `ӄ!bplz٨s W8B/#%AЬPMԣ=e׺M,:!F,[6IdOzbU#L'*;ڙȦ밮;J;.Z(rV"T<1ɏa}9Z:A`",[-t7Н.uZu޻.=G0h*u i$#)_g?<-E yg|tCtݴkKjl8(|5aFZ U_ NZ CC T*Y5_zX+'cX$8J qӳ],V &bݰg~,&g̐<9^Ùm;TI6xa}ER=hbQؕUKk;j/Q, *-'={Qs?~oѵK 6~ٿ){vo9D:u9o]X6壯~.^~Dܷ8} y1 9no$iF..n;=E@3ǧjM#yZF$Q,|uM, WXŦ]dEǑOL!X{$l$wjC!e^ySfDFz c<냁r+쭫ەMwVNWQ'JNt[Kߦgy\g}?R8?M޵N۹QLۗޛy _]LÃb6.w.^g EX3~Ν@rQIO!nEQӇBfܶkCoGDo7#z)bQʤNybg!ie \/7EUk+* ":ieRdC$ ~" TLUy@X P8v 8|ݿm L3ϽA~gǏ=ocw좓wo3%o_x^}3;hp9hѥLyhhLŞ` iσbV0w|)y|L\+`2JBE"cDP_D_,rЮOڸj)Mb+TKeÝ/y$4 D%Dv{}"!R9X́NЈ7M_)TON{;t7?DfF3X[Ё]t}ۭtω}ct]]λ^:3ӟ]7޾nbpd4rQ&-Bj1=8db9wn=kq>|D)%Ҍ=Q>i10oqbl~/N妋EPɸPa,17IB[ö⥥WJge(iq1I,9;ɱ<Be[nvHaޞe_~.LT@V_~ɗj+{wmy!Nn{zߪ4qT@*EWޥ+W7!"8zNukf׫,23ĝAng|wuV_zQ, ٞyvu9Ģ+^ ɬKP\[`<3\[2;ErQZW/H>L,иg)a JlW'6bQl|H"J?Jw9N_o7C1]:krRy%c7,k]Ea9}w RPrb3{/ޑ}ވUZB#e鬃漣X&yq "*sSʴ&r *Mb$\"[(ozS8}އ菿}xvF5udc];v,C|)x|1`gk,=A4C$ Cl˖YRx6}"xR<𩰂%R ZEb$:ЀV(Y e*%g+C D58FA[h2{U䩘i'Xem| jch;[HŮvscSNw~}Y03Oh  々t!DDBUhuBVU,e/H,"cbܬw@eF{3g}ٍawqsJj_xgQ cؾNBvV>`ڝviURK%Ub>M b*~3p 1Ё1ަ rY,F669],:Bז G* OqMA*1W)\Y j^^*?d%a-3 z0VCU,)dжj wbK|KʇOGǟ>=OmwTX2Sqx!q:wZᒣ¦i})(Ř{m0_fج]Oݑ%s F^JL rĢZ*?NjEN=dujLsx*&78 @*(C2 "q 'Uc2eHOM$XuGB]ZM [Kuxs>zYs+|>G}^ye0ᘾwxt;ѣ-8?_W2Kg^ƭ0kD=vo16vB3c]9\/DxWMMWȽBPI#n8-ahaTig-vA;@(jb+)H󐀂UKbj-.a4||ZO. UT0?Oo"}ϿE|1 #8f556 Uu`U& pܥ7=, LɄ6ΓtI/O<с;wcw\0Q#v~Xe5tQC*c6 $sB<<Ņ&"͸XDuel9ĢR 9NA=~*[|WQR-xxTؕ{Ŗ( \τ:%.0'V[HIQ>?=~jA;cw=3 FÈ`tQ:tv:o_TvZ#XUWscTj`qBi #uZ HĿFR_'XD5bi~amJyӽAҤZH_TY#dZUUD Qh`=2kKjG:nJeDL AX4hjIҝG֭[;"R,y .[ 'B65O&=6N(f;@BSf&LעzfmFX$OP pG)iw;W-K=G۰B'B1&8a|Ģ3vY.<`xI_n)h +oNY%HO {}&.*^Eڟ(̫8ZL0ΰ}OS="o2 &{1a% l01Gbѷv㧟&yꈶjphx`c%@R"9$h/@T84VsXC\`bbnR&OBzkήgyoJ>mbm|TCՆ@ҖCĄsz2e8X g~VSi&mwE^EVM̂Efn e7hbݫdMxٿw^|:oX zL:xK;wK8}pNTWr:1:VKg+Sk:r 6̕ tD&=aNo/ѻ6 㦪 kfƗ\Zkĥ,?a[@^ZX'yF %xwjK`uO=)b {=&O!E?%yUsGEniB.mNk/ Do]t%aDi)Eᮖ1)GD;Z*vjq $ ɀ`Y &+hb>⤶V, Ž,LŪM6]҄o+jb󻊒0ӄL &URm JbL^EVꁄ}GdE4HWqR򫍔ЄL|!CP:r>r|;K/ uL8^t%M$bzL,ڍ=&CI+Ϲ^V&ނ1Ǥϳ!\T൭XTqj(L (< rhO=).y,66xex ktuz;tvS(fF-~Ӯ;hϮ] (XfXHbPN zkb@ix|yzm&&pr(OBgd?[z uobeʓ*N@JN #UlA (}DmҋőOXoE^E], yz5&7mhv>H0WQϳn1ٿ{/ؽN>E_r.\2>w7E[Tbpglt޽3QxYVh,t=_>m=' HGYݚ&Ϭ$E12\"C'>%x eil|<OB鍊R)Z}:|goB\߹B׮]\w]k1!DzSo&o-It]\YKw!)ZT}#w_1^ޣN"9&qKcÎĢcz  O,O֧ N,ۊDdWMN'^@S\I5CQWQ ڶhHPE9\!먹Xq7j2V79'.hw╟J7 7/_?ͷf7n|@W.]fy TNNzBp?5q`>3 uYQ1>N.a\{]뵶kXX56vi p򄼠b F(.`XN^S/ܣK`) g2&g΋`_S܆gd B !Y=Z_q7EWjWQEjecdKAt$r53)GL,(,2zh^y轮 õ{ *f{f4[6e*jb]M)RZ,6ݸ!R" A^F(^ ,** YjKg2̃^*ՇOmaƊptm|tmtׄ9[ަo`ޛ/ Og6>tl~04-PJ&>0ΊecB9 _Т+  i,]~ E'o{ɳs,m8'6AR_y^o~VLk1N']ӣHKxF<[n/Q8Mcݴ<vc)ʃ$ nv}&NjVɈL6ApO!T|Cq?qZ+qIcryG~^d"ox]"oLGx?oPv9X,<& LҀ>u✺jPD؁ Mh]`/%}\R0SdԯЎ !Ƙ;ʍejZ󢺵Ztl9 Zr@z|<$.}Zv儗|ݦk܎`);.:V\!E4[Ňe/^WiҥB1*f(')z]+hdO!`\7"ypU[,GBZ*j9Ix- :|,U[Xfu @`}7bE/.O+AtjxW_$AqB:~C$k#;u#E@z4򐆉 kC3;jH!CLU7E)w-(&NCD)6zOђvc:Ox il5;ҶI(S e)U"e~& @l:=x_ 㭁riicC$vj9>7xBγt:YHLE:jcL{>dXt5,W|%ZnOtjbFv7\z9;E`p#L<ӖM/bx#aBbQ.^ΑHI1<-Ʋ0 ƣPFzԛUSmC'`,*T=#"#qmpsn>`8mH/%vյ'-jɻx 蠃ULH[ewKTFQNq^ʲbh<EqkO?+ 0%iyHNyGFn $ѕa BNbR^E:'em4.:-wKyJQy3 9^׮$?}lyvЇe(O,&xrSp7.^T\*;tCG_' y)ϼE-$A4rU@VXծHf7ˉNS,3 J'., ݓ虃+V@ų (\8+1^ 8{(06+2X[pu&}b|1 9%>"]hI##.H5C M1O=RI::8\L o6Ԇ „\,?0A3ĉ]~RNvhF#`@7B-G"B=,#H‰Mř?\T3}ǤlhbqR]ZɸMlX\kb'qv豘d-.ׯYc&Om?<##X< \k8r:N[9ԒT :=b{dzE@IK&ő(a}!cY<Ci:\l}ŷ?LIG1$DpȥQF2 mUگ7,~qf!2\y cŝ?5qrX[Fia߻I_d>npIxHVL;T퉊Ԧyv,DKZ[vRPlGcH=!&V@U OKO+L4$xf.{[zRuskٱKZV!ƾL&ez9!nX7%q(& HE,:iDhabF c;/K`#Vܸb͂S i8CpQDUtķ4 {gװ\LO[D!FBzˌ*B}Sv j1 \GwT^]9^3z!6  :ǖnftx dF9iBtb1)K|%Pf${chIp.)1 s@ 0ӕ~慸!Ng-9&WX2Ԋ=)(D)blY7x/`1O"`rK_XZ^xeC@m 4!0jjƋYơM1;/Cn7 /nU::Y-X4ax+aYe㖙 0{ JzQoIg{1 y!2ۗikqu=,,WeƋ:GzEч8X2$ńwy")MR/ǼWŃzb}kVFf&4Y e9O/oPv.`zU+^Vb?*Z*CE;րRbi:Bܸ@s_ݯ@V/L % ems>,L0NBSb.j!>zGBe-0u$LULK dd!%tX4ɫhz$T?L/sXI, ) h"tPj坺CUAߔI ԻHUC{+d1q*bUcB^ e?eQ%װwb@0!^R^纐Y <|Jjb_b' FJNXT[=E8S+ұ._^\(;{f)Bda "(H 8m)-E]C"}SMɠZ@BLʱ[o!Z\TqdZAE$ի`AsYPԕB'<%W(T rS)+*Ҙry< ]C]zaԵF⾭<F<4,#k BÕFOf !xYXSO_ۋ.A-s2G]ވh=>iST LH^1׀hufZ:WQC1"aY ]+pd-fSuz=KG8ް\T,dՈy ܟ)y..ȍ0Xp%XXWի,l;{bb%ϳy݆v`S9vB0fd23>CeiLI髜#n[)t+[1{O$K?#YA9 avzKDtVP孫`}>WԢUΙE]t^(#f/F/O ^ň lqN6Ŝ,J(.|W^wh}kOFڇ^Ef"V:uN!A!Aɒ&`bV'Y&VI< a qI;ĆpbȴzH*SxER^K]+@/M[Bs^}*/0E,)yy{ŢYXM5佩#ﴑVKXx,gk+KЯdc ydL;ת,)G&l -S"T(Lstڜ%OKpQFeEOǾ+ˌE˿t5V΂XI+W'Dz$Ek"!8 2 &NCR@kQwOePʸ4"N贕cj)GJA㧏KUT3UMM$39G-9睶;[;ji饘X*1Y,FzW'TV佸uOsk[KyOYXHͭ bDB05aߡ$AJdλb`.BGWt^(+dCCbʩE4{"U¨[/c+XXB̈́2c!JǭN,XDKkukKA'CNh+p0fgCwjD1P9ױ7` )ݚ.XI4}F|ΰBiv{i̴:v*Px0k [iQCX 1,I5lCWxPcH0SM_y8%jicY@ZB(]8W߫DZзpƳ) [P* Yx;Q^Ŋ('nMpq  PQahUPiiڀS䜷nIn{X<&CabDv&aQ(' 邇r{RI}zۃ:l 2x՘{̰lk? BkT\"}Ƕ%d[SoO 䬮P,.=Tb}O8'CpZڙ^ZնTl _( ,ρj`iQc FhL}Kn^B>8?XF䗢tBXQJ}5z,i˜"@-|3.ä.BWsm-%{Zk=S?cm02ড়ToPθ_y߱bK"T]*-Сi+NH[wU<$ZZ\G$Z8 8 ^%S~dbB3_Y\:D>@ ?ƛ$f?Q6)b#VKmlDi}xFhf'?-D(I0[ "fYGWtrm)\J4::=sMԶR AQv,=͋rz;O}\—`Nexi ^|Yx p\(22tvz@2qKUnk)g"p &] @䁶/j򠎆IS4q<\)ZL#ֹ͙:d Y}\w%+RxϧFM}G?{S|o%,'?' Y_@{y(!iX&Iwv<ǰpa1o\|K#n5F : "?xC +j: yVYC6)l.KR u1Ǟ[%{r&\(k,12#"g5"埿—`NiYB|1b_qe),w߈oe(^qK\tUɗH%Wh#J#j_FD:|XbF.Bar4S7ZZ;KwCU?[ޏ,D=b ݴN]*HW]iӼEf{ Z&+Cu$L$>v'YhFQxl.-L^^߽SY6lem `I<|^n=yR}X߃-⠉0=H@2}$`}ZI;kvuφ6<+MTSrz.ǔfV-R ],Dd*'$|d,qS㐟cKbmoH{SI=n0&Z sn„eahxȯ5齜`+}fye)4 s)mYVϖY0-DL|ǾSUxҷAY:Lz{zYntaktngg rRڈhp\Q56X[-p0T_^CX'Kׇ+$ĸU(ž ,.(Nkn\ͪZPh}H%?G<(Lv`I׼AB׃nh+ 17E@GI`:Ddy/~1nr3XhfQ1y?YRT| t!|SOADY>AҐ,* ˙gK@3x `g[ztGmE?/BMxil^ŊF71.FhM8)Eb#z>>ⴈ2io,IB$){S?^mnȂS&wڭ7\lE0^F)M?QX[WyHk6a}u.,OI|tۮHnt9rԳ.W&}Єbcٗz\|~Kc۬1 |"X8r}򣟠Oc'`ԋz=Y)F:ELE6b)Znľ`qUqa|Pl_XLofxSL(ۥAM0aX(Iv,aYPj +hH NR^~8F  ENH0VaD <Db -pDV{ ?x{4HeOZ3i镄NsymU/?1%F D oܠW^{cS~fp!B(\,Nc1b?il 9Z P$07dq{BmH瀑 BSd{p>&liBC#ݤ&@(zvY?`w Yy& ڷτ@`Â*;n{E6&Zb"?APDV)b-?$#]Ka"2 1'FiO Rr}P,Ďlň*џʩqux 譋Ysg,EũWc,bE mwUxtݼKFKPU6f".lrXsrduSXjö-3q"TOӠpV*V&3eRZ4q24L,GNH>ҨVTS(&;/KB;4&e4)]ȢXHl1IV!Oԋwϻ*Uwc[jđcB%3/Na0^HtťWƍNSQxVk8?}3 Uc,0 0 0 c3¹g]tǰi͹gcaaa-C$<caaaƂx3,GQtty1 5 0 0 X.hiR(/j.c 0 0 0ė) X=TmaaaΞ[7w]x_!" 0 0 0֋?:w̗Т E2haaعg>?VvKbaaaScۣe%bM,aafL5 =GŲL,aa.R$Ҋy]4 0 0 XF+iU6A7aaa(DD3VH,i.0 0 0 'J-= 9w" 0 0 0oHU(V Any: return getattr(get_settings(), name) # deprecated SETTINGS = _SettingsProxy() # private global object # will be populated on first call of get_settings _SETTINGS: Optional[NapariSettings] = None def get_settings(path=_NOT_SET) -> NapariSettings: """ Get settings for a given path. Parameters ---------- path : Path, optional The path to read/write the settings from. Returns ------- SettingsManager The settings manager. Notes ----- The path can only be set once per session. """ global _SETTINGS if _SETTINGS is None: if path is not _NOT_SET: path = Path(path).resolve() if path is not None else None _SETTINGS = NapariSettings(config_path=path) elif path is not _NOT_SET: import inspect curframe = inspect.currentframe() calframe = inspect.getouterframes(curframe, 2) raise RuntimeError( trans._( "The path can only be set once per session. Settings called from {calframe}", deferred=True, calframe=calframe[1][3], ) ) return _SETTINGS napari-0.5.0a1/napari/settings/_appearance.py000066400000000000000000000025301437041365600211430ustar00rootroot00000000000000from pydantic import Field from napari.settings._fields import Theme from napari.utils.events.evented_model import EventedModel from napari.utils.theme import available_themes from napari.utils.translations import trans class AppearanceSettings(EventedModel): theme: Theme = Field( "dark", title=trans._("Theme"), description=trans._("Select the user interface theme."), env="napari_theme", ) highlight_thickness: int = Field( 1, title=trans._("Highlight thickness"), description=trans._( "Select the highlight thickness when hovering over shapes/points." ), ge=1, le=10, ) layer_tooltip_visibility: bool = Field( False, title=trans._("Show layer tooltips"), description=trans._("Toggle to display a tooltip on mouse hover."), ) class NapariConfig: # Napari specific configuration preferences_exclude = ['schema_version'] def refresh_themes(self): """Updates theme data. This is not a fantastic solution but it works. Each time a new theme is added (either by a plugin or directly by the user) the enum is updated in place, ensuring that Preferences dialog can still be opened. """ self.schema()["properties"]["theme"].update(enum=available_themes()) napari-0.5.0a1/napari/settings/_application.py000066400000000000000000000154041437041365600213530ustar00rootroot00000000000000from __future__ import annotations from typing import List, Optional, Tuple from pydantic import Field, validator from napari.settings._constants import LoopMode from napari.settings._fields import Language from napari.utils._base import _DEFAULT_LOCALE from napari.utils.events.custom_types import conint from napari.utils.events.evented_model import EventedModel from napari.utils.notifications import NotificationSeverity from napari.utils.translations import trans GridStride = conint(ge=-50, le=50, ne=0) GridWidth = conint(ge=-1, ne=0) GridHeight = conint(ge=-1, ne=0) class ApplicationSettings(EventedModel): first_time: bool = Field( True, title=trans._('First time'), description=trans._( 'Indicate if napari is running for the first time. This setting is managed by the application.' ), ) ipy_interactive: bool = Field( True, title=trans._('IPython interactive'), description=trans._( r'Toggle the use of interactive `%gui qt` event loop when creating napari Viewers in IPython.' ), ) language: Language = Field( _DEFAULT_LOCALE, title=trans._("Language"), description=trans._( "Select the display language for the user interface." ), ) # Window state, geometry and position save_window_geometry: bool = Field( True, title=trans._("Save window geometry"), description=trans._( "Toggle saving the main window size and position." ), ) save_window_state: bool = Field( False, # changed from True to False in schema v0.2.1 title=trans._("Save window state"), description=trans._("Toggle saving the main window state of widgets."), ) window_position: Optional[Tuple[int, int]] = Field( None, title=trans._("Window position"), description=trans._( "Last saved x and y coordinates for the main window. This setting is managed by the application." ), ) window_size: Optional[Tuple[int, int]] = Field( None, title=trans._("Window size"), description=trans._( "Last saved width and height for the main window. This setting is managed by the application." ), ) window_maximized: bool = Field( False, title=trans._("Window maximized state"), description=trans._( "Last saved maximized state for the main window. This setting is managed by the application." ), ) window_fullscreen: bool = Field( False, title=trans._("Window fullscreen"), description=trans._( "Last saved fullscreen state for the main window. This setting is managed by the application." ), ) window_state: Optional[str] = Field( None, title=trans._("Window state"), description=trans._( "Last saved state of dockwidgets and toolbars for the main window. This setting is managed by the application." ), ) window_statusbar: bool = Field( True, title=trans._("Show status bar"), description=trans._( "Toggle diplaying the status bar for the main window." ), ) preferences_size: Optional[Tuple[int, int]] = Field( None, title=trans._("Preferences size"), description=trans._( "Last saved width and height for the preferences dialog. This setting is managed by the application." ), ) gui_notification_level: NotificationSeverity = Field( NotificationSeverity.INFO, title=trans._("GUI notification level"), description=trans._( "Select the notification level for the user interface." ), ) console_notification_level: NotificationSeverity = Field( NotificationSeverity.NONE, title=trans._("Console notification level"), description=trans._("Select the notification level for the console."), ) open_history: List[str] = Field( [], title=trans._("Opened folders history"), description=trans._( "Last saved list of opened folders. This setting is managed by the application." ), ) save_history: List[str] = Field( [], title=trans._("Saved folders history"), description=trans._( "Last saved list of saved folders. This setting is managed by the application." ), ) playback_fps: int = Field( 10, title=trans._("Playback frames per second"), description=trans._("Playback speed in frames per second."), ) playback_mode: LoopMode = Field( LoopMode.LOOP, title=trans._("Playback loop mode"), description=trans._("Loop mode for playback."), ) grid_stride: GridStride = Field( # type: ignore [valid-type] default=1, title=trans._("Grid Stride"), description=trans._("Number of layers to place in each grid square."), ) grid_width: GridWidth = Field( # type: ignore [valid-type] default=-1, title=trans._("Grid Width"), description=trans._("Number of columns in the grid."), ) grid_height: GridHeight = Field( # type: ignore [valid-type] default=-1, title=trans._("Grid Height"), description=trans._("Number of rows in the grid."), ) confirm_close_window: bool = Field( default=True, title=trans._("Confirm window or application closing"), description=trans._( "Ask for confirmation before closing a napari window or application (all napari windows).", ), ) hold_button_delay: float = Field( default=0.5, title=trans._("Delay to treat button as hold in seconds"), description=trans._( "This affects certain actions where a short press and a long press have different behaviors, such as changing the mode of a layer permanently or only during the long press." ), ) @validator('window_state') def _validate_qbtye(cls, v): if v and (not isinstance(v, str) or not v.startswith('!QBYTE_')): raise ValueError( trans._("QByte strings must start with '!QBYTE_'") ) return v class Config: use_enum_values = False # https://github.com/napari/napari/issues/3062 class NapariConfig: # Napari specific configuration preferences_exclude = [ "schema_version", "preferences_size", "first_time", "window_position", "window_size", "window_maximized", "window_fullscreen", "window_state", "window_statusbar", "open_history", "save_history", "ipy_interactive", ] napari-0.5.0a1/napari/settings/_base.py000066400000000000000000000447471437041365600177760ustar00rootroot00000000000000from __future__ import annotations import contextlib import logging import os from collections.abc import Mapping from functools import partial from pathlib import Path from typing import TYPE_CHECKING, Dict, List, Optional, Sequence, Tuple, cast from warnings import warn from pydantic import BaseModel, BaseSettings, ValidationError from pydantic.env_settings import SettingsError from pydantic.error_wrappers import display_errors from napari.settings._yaml import PydanticYamlMixin from napari.utils.events import EmitterGroup, EventedModel from napari.utils.misc import deep_update from napari.utils.translations import trans _logger = logging.getLogger(__name__) if TYPE_CHECKING: from typing import AbstractSet, Any, Union from pydantic.env_settings import EnvSettingsSource, SettingsSourceCallable from napari.utils.events import Event IntStr = Union[int, str] AbstractSetIntStr = AbstractSet[IntStr] DictStrAny = Dict[str, Any] MappingIntStrAny = Mapping[IntStr, Any] class EventedSettings(BaseSettings, EventedModel): # type: ignore[misc] """A variant of EventedModel designed for settings. Pydantic's BaseSettings model will attempt to determine the values of any fields not passed as keyword arguments by reading from the environment. """ # provide config_path=None to prevent reading from disk. def __init__(self, **values: Any) -> None: super().__init__(**values) self.events.add(changed=None) # re-emit subfield for name, field in self.__fields__.items(): attr = getattr(self, name) if isinstance(getattr(attr, 'events', None), EmitterGroup): attr.events.connect(partial(self._on_sub_event, field=name)) if field.field_info.extra.get('requires_restart'): emitter = getattr(self.events, name) @emitter.connect def _warn_restart(*_): warn( trans._( "Restart required for this change to take effect.", deferred=True, ) ) def _on_sub_event(self, event: Event, field=None): """emit the field.attr name and new value""" if field: field += "." value = getattr(event, 'value', None) self.events.changed(key=f'{field}{event._type}', value=value) _NOT_SET = object() class EventedConfigFileSettings(EventedSettings, PydanticYamlMixin): """This adds config read/write and yaml support to EventedSettings. If your settings class *only* needs to read variables from the environment, such as environment variables (but not a config file), then subclass from EventedSettings. """ _config_path: Optional[Path] = None _save_on_change: bool = True # this dict stores the data that came specifically from the config file. # it's populated in `config_file_settings_source` and # used in `_remove_env_settings` _config_file_settings: dict # provide config_path=None to prevent reading from disk. def __init__(self, config_path=_NOT_SET, **values: Any) -> None: _cfg = ( config_path if config_path is not _NOT_SET else self.__private_attributes__['_config_path'].get_default() ) # this line is here for usage in the `customise_sources` hook. It # will be overwritten in __init__ by BaseModel._init_private_attributes # so we set it again after __init__. self._config_path = _cfg super().__init__(**values) self._config_path = _cfg def _maybe_save(self): if self._save_on_change and self.config_path: self.save() def _on_sub_event(self, event, field=None): super()._on_sub_event(event, field) self._maybe_save() @property def config_path(self): """Return the path to/from which settings be saved/loaded.""" return self._config_path def dict( # type: ignore [override] self, *, include: Union[AbstractSetIntStr, MappingIntStrAny] = None, # type: ignore exclude: Union[AbstractSetIntStr, MappingIntStrAny] = None, # type: ignore by_alias: bool = False, exclude_unset: bool = False, exclude_defaults: bool = False, exclude_none: bool = False, exclude_env: bool = False, ) -> DictStrAny: """Return dict representation of the model. May optionally specify which fields to include or exclude. """ data = super().dict( include=include, exclude=exclude, by_alias=by_alias, exclude_unset=exclude_unset, exclude_defaults=exclude_defaults, exclude_none=exclude_none, ) if exclude_env: self._remove_env_settings(data) return data def _save_dict(self, **dict_kwargs: Any) -> DictStrAny: """The minimal dict representation that will be persisted to disk. By default, this will exclude settings values that match the default value, and will exclude values that were provided by environment variables. Empty dicts will also be removed. """ dict_kwargs.setdefault('exclude_defaults', True) dict_kwargs.setdefault('exclude_env', True) data = self.dict(**dict_kwargs) _remove_empty_dicts(data) return data def save(self, path: Union[str, Path, None] = None, **dict_kwargs): """Save current settings to path. By default, this will exclude settings values that match the default value, and will exclude values that were provided by environment variables. (see `_save_dict` method.) """ path = path or self.config_path if not path: raise ValueError( trans._( "No path provided in config or save argument.", deferred=True, ) ) path = Path(path).expanduser().resolve() path.parent.mkdir(exist_ok=True, parents=True) self._dump(str(path), self._save_dict(**dict_kwargs)) def _dump(self, path: str, data: Dict) -> None: """Encode and dump `data` to `path` using a path-appropriate encoder.""" if str(path).endswith(('.yaml', '.yml')): _data = self._yaml_dump(data) elif str(path).endswith(".json"): json_dumps = self.__config__.json_dumps _data = json_dumps(data, default=self.__json_encoder__) else: raise NotImplementedError( trans._( "Can only currently dump to `.json` or `.yaml`, not {path!r}", deferred=True, path=path, ) ) with open(path, 'w') as target: target.write(_data) def env_settings(self) -> Dict[str, Any]: """Get a dict of fields that were provided as environment vars.""" env_settings = getattr(self.__config__, '_env_settings', {}) if callable(env_settings): env_settings = env_settings(self) return env_settings def _remove_env_settings(self, data): """Remove key:values from `data` that match settings from env vars. This is handy when we want to persist settings to disk without including settings that were provided by environment variables (which are usually more temporary). """ env_data = self.env_settings() if env_data: _restore_config_data( data, env_data, getattr(self, '_config_file_settings', {}) ) class Config: # If True: validation errors in a config file will raise an exception # otherwise they will warn to the logger strict_config_check: bool = False sources: Sequence[str] = [] _env_settings: SettingsSourceCallable @classmethod def customise_sources( cls, init_settings: SettingsSourceCallable, env_settings: EnvSettingsSource, file_secret_settings: SettingsSourceCallable, ) -> Tuple[SettingsSourceCallable, ...]: """customise the way data is loaded. This does 2 things: 1) adds the `config_file_settings_source` to the sources, which will load data from `settings._config_path` if it exists. 2) adds support for nested env_vars, such that if a model with an env_prefix of "foo_" has a field named `bar`, then you can use `FOO_BAR_X=1` to set the x attribute in `foo.bar`. Priority is given to sources earlier in the list. You can resort the return list to change the priority of sources. """ cls._env_settings = nested_env_settings(env_settings) return ( # type: ignore [return-value] init_settings, cls._env_settings, cls._config_file_settings_source, file_secret_settings, ) @classmethod def _config_file_settings_source( cls, settings: EventedConfigFileSettings ) -> Dict[str, Any]: return config_file_settings_source(settings) # Utility functions def nested_env_settings( super_eset: EnvSettingsSource, ) -> SettingsSourceCallable: """Wraps the pydantic EnvSettingsSource to support nested env vars. currently only supports one level of nesting. Examples -------- `NAPARI_APPEARANCE_THEME=light` will parse to: {'appearance': {'theme': 'light'}} If a submodel has a field that explicitly declares an `env`... that will also be found. For example, 'ExperimentalSettings.async_' directly declares `env='napari_async'`... so NAPARI_ASYNC is accessible without nesting as well. """ def _inner(settings: BaseSettings) -> Dict[str, Any]: # first call the original implementation d = super_eset(settings) if settings.__config__.case_sensitive: env_vars: Mapping[str, Optional[str]] = os.environ else: env_vars = {k.lower(): v for k, v in os.environ.items()} # now iterate through all subfields looking for nested env vars # For example: # NapariSettings has a Config.env_prefix of 'napari_' # so every field in the NapariSettings.Application subfield will be # available at 'napari_application_fieldname' for field in settings.__fields__.values(): if not isinstance(field.type_, type(BaseModel)): continue # pragma: no cover field_type = cast(BaseModel, field.type_) for env_name in field.field_info.extra['env_names']: for subf in field_type.__fields__.values(): # first check if subfield directly declares an "env" # (for example: ExperimentalSettings.async_) for e in subf.field_info.extra.get('env_names', []): env_val = env_vars.get(e.lower()) if env_val is not None: break # otherwise, look for the standard nested env var else: env_val = env_vars.get(f'{env_name}_{subf.name}') if env_val is not None: break is_complex, all_json_fail = super_eset.field_is_complex(subf) if env_val is not None and is_complex: try: env_val = settings.__config__.json_loads(env_val) except ValueError as e: if not all_json_fail: msg = trans._( 'error parsing JSON for "{env_name}"', deferred=True, env_name=env_name, ) raise SettingsError(msg) from e if isinstance(env_val, dict): explode = super_eset.explode_env_vars(field, env_vars) env_val = deep_update(env_val, explode) # if we found an env var, store it and return it if env_val is not None: if field.alias not in d: d[field.alias] = {} d[field.alias][subf.name] = env_val return d return _inner def config_file_settings_source( settings: EventedConfigFileSettings, ) -> Dict[str, Any]: """Read config files during init of an EventedConfigFileSettings obj. The two important values are the `settings._config_path` attribute, which is the main config file (if present), and `settings.__config__.source`, which is an optional list of additional files to read. (files later in the list take precedence and `_config_path` takes precedence over all) Parameters ---------- settings : EventedConfigFileSettings The new model instance (not fully instantiated) Returns ------- dict *validated* values for the model. """ # _config_path is the primary config file on the model (the one to save to) config_path = getattr(settings, '_config_path', None) default_cfg = type(settings).__private_attributes__.get('_config_path') default_cfg = getattr(default_cfg, 'default', None) # if the config has a `sources` list, read those too and merge. sources: List[str] = list(getattr(settings.__config__, 'sources', [])) if config_path: sources.append(config_path) if not sources: return {} data: dict = {} for path in sources: if not path: continue # pragma: no cover path_ = Path(path).expanduser().resolve() # if the requested config path does not exist, move on to the next if not path_.is_file(): # if it wasn't the `_config_path` stated in the BaseModel itself, # we warn, since this would have been user provided. if path_ != default_cfg: _logger.warning( trans._( "Requested config path is not a file: {path}", path=path_, ) ) continue # get loader for yaml/json if str(path).endswith(('.yaml', '.yml')): load = __import__('yaml').safe_load elif str(path).endswith(".json"): load = __import__('json').load else: warn( trans._( "Unrecognized file extension for config_path: {path}", path=path, ) ) continue try: # try to parse the config file into a dict new_data = load(path_.read_text()) or {} except Exception as err: # noqa: BLE001 _logger.warning( trans._( "The content of the napari settings file could not be read\n\nThe default settings will be used and the content of the file will be replaced the next time settings are changed.\n\nError:\n{err}", deferred=True, err=err, ) ) continue assert isinstance(new_data, dict), path_.read_text() deep_update(data, new_data, copy=False) try: # validate the data, passing config_path=None so we dont recurse # back to this point again. type(settings)(config_path=None, **data) except ValidationError as err: if getattr(settings.__config__, 'strict_config_check', False): raise # if errors occur, we still want to boot, so we just remove bad keys errors = err.errors() msg = trans._( "Validation errors in config file(s).\nThe following fields have been reset to the default value:\n\n{errors}\n", deferred=True, errors=display_errors(errors), ) with contextlib.suppress(Exception): # we're about to nuke some settings, so just in case... try backup backup_path = path_.parent / f'{path_.stem}.BAK{path_.suffix}' backup_path.write_text(path_.read_text()) _logger.warning(msg) try: _remove_bad_keys(data, [e.get('loc', ()) for e in errors]) except KeyError: # pragma: no cover _logger.warning( trans._( 'Failed to remove validation errors from config file. Using defaults.' ) ) data = {} # store data at this state for potential later recovery settings._config_file_settings = data return data def _remove_bad_keys(data: dict, keys: List[Tuple[Union[int, str], ...]]): """Remove list of keys (as string tuples) from dict (in place). Parameters ---------- data : dict dict to modify (will be modified inplace) keys : List[Tuple[str, ...]] list of possibly nested keys Examples -------- >>> data = {'a': 1, 'b' : {'c': 2, 'd': 3}, 'e': 4} >>> keys = [('b', 'd'), ('e',)] >>> _remove_bad_keys(data, keys) >>> data {'a': 1, 'b': {'c': 2}} """ for key in keys: if not key: continue # pragma: no cover d = data while True: base, *key = key # type: ignore if not key: break # since no pydantic fields will be integers, integers usually # mean we're indexing into a typed list. So remove the base key if isinstance(key[0], int): break d = d[base] del d[base] def _restore_config_data(dct: dict, delete: dict, defaults: dict) -> dict: """delete nested dict keys, restore from defaults.""" for k, v in delete.items(): # restore from defaults if present, or just delete the key if k in dct: if k in defaults: dct[k] = defaults[k] else: del dct[k] # recurse elif isinstance(v, dict): dflt = defaults.get(k) if not isinstance(dflt, dict): dflt = {} _restore_config_data(dct[k], v, dflt) return dct def _remove_empty_dicts(dct: dict, recurse=True) -> dict: """Remove all (nested) keys with empty dict values from `dct`""" for k, v in list(dct.items()): if isinstance(v, Mapping) and recurse: _remove_empty_dicts(dct[k]) if v == {}: del dct[k] return dct napari-0.5.0a1/napari/settings/_constants.py000066400000000000000000000012021437041365600210530ustar00rootroot00000000000000from enum import auto from napari.utils.misc import StringEnum class LoopMode(StringEnum): """Looping mode for animating an axis. LoopMode.ONCE Animation will stop once movie reaches the max frame (if fps > 0) or the first frame (if fps < 0). LoopMode.LOOP Movie will return to the first frame after reaching the last frame, looping continuously until stopped. LoopMode.BACK_AND_FORTH Movie will loop continuously until stopped, reversing direction when the maximum or minimum frame has been reached. """ ONCE = auto() LOOP = auto() BACK_AND_FORTH = auto() napari-0.5.0a1/napari/settings/_experimental.py000066400000000000000000000022501437041365600215400ustar00rootroot00000000000000from typing import Union from pydantic import Field from napari.settings._base import EventedSettings from napari.utils.translations import trans # this class inherits from EventedSettings instead of EventedModel because # it uses Field(env=...) for one of its attributes class ExperimentalSettings(EventedSettings): octree: Union[bool, str] = Field( False, title=trans._("Enable Asynchronous Tiling of Images"), description=trans._( "Renders images asynchronously using tiles. \nYou must restart napari for changes of this setting to apply." ), type='boolean', # need to specify to build checkbox in preferences. requires_restart=True, ) async_: bool = Field( False, title=trans._("Render Images Asynchronously"), description=trans._( "Asynchronous loading of image data. \nThis setting partially loads data while viewing. \nYou must restart napari for changes of this setting to apply." ), env="napari_async", requires_restart=True, ) class NapariConfig: # Napari specific configuration preferences_exclude = ['schema_version'] napari-0.5.0a1/napari/settings/_fields.py000066400000000000000000000137411437041365600203200ustar00rootroot00000000000000import re from dataclasses import dataclass from functools import total_ordering from typing import Any, Dict, Optional, SupportsInt, Tuple, Union from napari.utils.theme import available_themes, is_theme_available from napari.utils.translations import _load_language, get_language_packs, trans class Theme(str): """ Custom theme type to dynamically load all installed themes. """ # https://pydantic-docs.helpmanual.io/usage/types/#custom-data-types @classmethod def __get_validators__(cls): yield cls.validate @classmethod def __modify_schema__(cls, field_schema): # TODO: Provide a way to handle keys so we can display human readable # option in the preferences dropdown field_schema.update(enum=available_themes()) @classmethod def validate(cls, v): if not isinstance(v, str): raise ValueError(trans._('must be a string', deferred=True)) value = v.lower() if not is_theme_available(value): raise ValueError( trans._( '"{value}" is not valid. It must be one of {themes}', deferred=True, value=value, themes=", ".join(available_themes()), ) ) return value class Language(str): """ Custom theme type to dynamically load all installed language packs. """ # https://pydantic-docs.helpmanual.io/usage/types/#custom-data-types @classmethod def __get_validators__(cls): yield cls.validate @classmethod def __modify_schema__(cls, field_schema): # TODO: Provide a way to handle keys so we can display human readable # option in the preferences dropdown language_packs = list(get_language_packs(_load_language()).keys()) field_schema.update(enum=language_packs) @classmethod def validate(cls, v): if not isinstance(v, str): raise ValueError(trans._('must be a string', deferred=True)) language_packs = list(get_language_packs(_load_language()).keys()) if v not in language_packs: raise ValueError( trans._( '"{value}" is not valid. It must be one of {language_packs}.', deferred=True, value=v, language_packs=", ".join(language_packs), ) ) return v @total_ordering @dataclass class Version: """A semver compatible version class. mostly vendored from python-semver (BSD-3): https://github.com/python-semver/python-semver/ """ major: SupportsInt minor: SupportsInt = 0 patch: SupportsInt = 0 prerelease: Union[bytes, str, int, None] = None build: Union[bytes, str, int, None] = None _SEMVER_PATTERN = re.compile( r""" ^ (?P0|[1-9]\d*) \. (?P0|[1-9]\d*) \. (?P0|[1-9]\d*) (?:-(?P (?:0|[1-9]\d*|\d*[a-zA-Z-][0-9a-zA-Z-]*) (?:\.(?:0|[1-9]\d*|\d*[a-zA-Z-][0-9a-zA-Z-]*))* ))? (?:\+(?P [0-9a-zA-Z-]+ (?:\.[0-9a-zA-Z-]+)* ))? $ """, re.VERBOSE, ) @classmethod def parse(cls, version: Union[bytes, str]) -> 'Version': """Convert string or bytes into Version object.""" if isinstance(version, bytes): version = version.decode("UTF-8") match = cls._SEMVER_PATTERN.match(version) if match is None: raise ValueError( trans._( '{version} is not valid SemVer string', deferred=True, version=version, ) ) matched_version_parts: Dict[str, Any] = match.groupdict() return cls(**matched_version_parts) # NOTE: we're only comparing the numeric parts for now. # ALSO: the rest of the comparators come from functools.total_ordering def __eq__(self, other) -> bool: try: return self.to_tuple()[:3] == self._from_obj(other).to_tuple()[:3] except TypeError: return NotImplemented def __lt__(self, other) -> bool: try: return self.to_tuple()[:3] < self._from_obj(other).to_tuple()[:3] except TypeError: return NotImplemented @classmethod def _from_obj(cls, other): if isinstance(other, (str, bytes)): other = Version.parse(other) elif isinstance(other, dict): other = Version(**other) elif isinstance(other, (tuple, list)): other = Version(*other) elif not isinstance(other, Version): raise TypeError( trans._( "Expected str, bytes, dict, tuple, list, or {cls} instance, but got {other_type}", deferred=True, cls=cls, other_type=type(other), ) ) return other def to_tuple(self) -> Tuple[int, int, int, Optional[str], Optional[str]]: """Return version as tuple (first three are int, last two Opt[str]).""" return ( int(self.major), int(self.minor), int(self.patch), str(self.prerelease) if self.prerelease is not None else None, str(self.build) if self.build is not None else None, ) def __iter__(self): yield from self.to_tuple() def __str__(self) -> str: v = f"{self.major}.{self.minor}.{self.patch}" if self.prerelease: # pragma: no cover v += str(self.prerelease) if self.build: # pragma: no cover v += str(self.build) return v @classmethod def __get_validators__(cls): yield cls.validate @classmethod def validate(cls, v): return cls._from_obj(v) def _json_encode(self): return str(self) napari-0.5.0a1/napari/settings/_migrations.py000066400000000000000000000074561437041365600212340ustar00rootroot00000000000000from __future__ import annotations import warnings from contextlib import contextmanager from importlib.metadata import distributions from typing import TYPE_CHECKING, Callable, List, NamedTuple from napari.settings._fields import Version if TYPE_CHECKING: from napari.settings._napari_settings import NapariSettings _MIGRATORS: List[Migrator] = [] MigratorF = Callable[['NapariSettings'], None] class Migrator(NamedTuple): """Tuple of from-version, to-version, migrator function.""" from_: Version to_: Version run: MigratorF def do_migrations(model: NapariSettings): """Migrate (update) a NapariSettings model in place.""" for migration in sorted(_MIGRATORS, key=lambda m: m.from_): if model.schema_version == migration.from_: with mutation_allowed(model): backup = model.dict() try: migration.run(model) model.schema_version = migration.to_ except Exception as e: # noqa BLE001 msg = ( f"Failed to migrate settings from v{migration.from_} " f"to v{migration.to_}. Error: {e}. " ) try: model.update(backup) msg += 'You may need to reset your settings with `napari --reset`. ' except Exception: # noqa BLE001 msg += 'Settings rollback also failed. Please run `napari --reset`.' warnings.warn(msg) return model._maybe_save() @contextmanager def mutation_allowed(obj: NapariSettings): """Temporarily allow mutations on an immutable model.""" config = obj.__config__ prev, config.allow_mutation = config.allow_mutation, True try: yield finally: config.allow_mutation = prev def migrator(from_: str, to_: str) -> Callable[[MigratorF], MigratorF]: """Decorate function as migrating settings from v `from_` to v `to_`. A migrator should mutate a `NapariSettings` model from schema version `from_` to schema version `to_` (in place). Parameters ---------- from_ : str NapariSettings.schema_version version that this migrator expects as input to_ : str NapariSettings.schema_version version after this migrator has been executed. Returns ------- Callable[ [MigratorF], MigratorF ] _description_ """ def decorator(migrate_func: MigratorF) -> MigratorF: _from, _to = Version.parse(from_), Version.parse(to_) assert _to >= _from, 'Migrator must increase the version.' _MIGRATORS.append(Migrator(_from, _to, migrate_func)) return migrate_func return decorator @migrator('0.3.0', '0.4.0') def v030_v040(model: NapariSettings): """Migrate from v0.3.0 to v0.4.0. Prior to v0.4.0, npe2 plugins were automatically added to disabled plugins. This migration removes any npe2 plugins discovered in the environment (at migration time) from the "disabled plugins" set. """ for dist in distributions(): for ep in dist.entry_points: if ep.group == "napari.manifest": model.plugins.disabled_plugins.discard(dist.metadata['Name']) @migrator('0.4.0', '0.5.0') def v040_050(model: NapariSettings): """Migrate from v0.4.0 to v0.5.0 Prior to 0.5.0 existing preferences may have reader extensions preferences saved without a leading *. fnmatch would fail on these so we coerce them to include a * e.g. '.csv' becomes '*.csv' """ from napari.settings._utils import _coerce_extensions_to_globs current_settings = model.plugins.extension2reader new_settings = _coerce_extensions_to_globs(current_settings) model.plugins.extension2reader = new_settings napari-0.5.0a1/napari/settings/_napari_settings.py000066400000000000000000000104221437041365600222350ustar00rootroot00000000000000import os from pathlib import Path from typing import Any, Optional from pydantic import Field from napari.settings._appearance import AppearanceSettings from napari.settings._application import ApplicationSettings from napari.settings._base import ( _NOT_SET, EventedConfigFileSettings, _remove_empty_dicts, ) from napari.settings._experimental import ExperimentalSettings from napari.settings._fields import Version from napari.settings._plugins import PluginsSettings from napari.settings._shortcuts import ShortcutsSettings from napari.utils._base import _DEFAULT_CONFIG_PATH from napari.utils.translations import trans _CFG_PATH = os.getenv('NAPARI_CONFIG', _DEFAULT_CONFIG_PATH) CURRENT_SCHEMA_VERSION = Version(0, 5, 0) class NapariSettings(EventedConfigFileSettings): """Schema for napari settings.""" # 1. If you want to *change* the default value of a current option, you need to # do a MINOR update in config version, e.g. from 3.0.0 to 3.1.0 # 2. If you want to *remove* options that are no longer needed in the codebase, # or if you want to *rename* options, then you need to do a MAJOR update in # version, e.g. from 3.0.0 to 4.0.0 # 3. You don't need to touch this value if you're just adding a new option schema_version: Version = Field( CURRENT_SCHEMA_VERSION, description=trans._("Napari settings schema version."), ) application: ApplicationSettings = Field( default_factory=ApplicationSettings, title=trans._("Application"), description=trans._("Main application settings."), ) appearance: AppearanceSettings = Field( default_factory=AppearanceSettings, title=trans._("Appearance"), description=trans._("User interface appearance settings."), ) plugins: PluginsSettings = Field( default_factory=PluginsSettings, title=trans._("Plugins"), description=trans._("Plugins settings."), ) shortcuts: ShortcutsSettings = Field( default_factory=ShortcutsSettings, title=trans._("Shortcuts"), description=trans._("Shortcut settings."), ) experimental: ExperimentalSettings = Field( default_factory=ExperimentalSettings, title=trans._("Experimental"), description=trans._("Experimental settings."), ) # private attributes and ClassVars will not appear in the schema _config_path: Optional[Path] = Path(_CFG_PATH) if _CFG_PATH else None class Config(EventedConfigFileSettings.Config): env_prefix = 'napari_' use_enum_values = False # all of these fields are evented models, so we don't want to break # connections by setting the top-level field itself # (you can still mutate attributes in the subfields) allow_mutation = False @classmethod def _config_file_settings_source(cls, settings) -> dict: # before '0.4.0' we didn't write the schema_version in the file # written to disk. so if it's missing, add schema_version of 0.3.0 d = super()._config_file_settings_source(settings) d.setdefault('schema_version', '0.3.0') return d def __init__(self, config_path=_NOT_SET, **values: Any) -> None: super().__init__(config_path, **values) self._maybe_migrate() def _save_dict(self, **kwargs): # we always want schema_version written to the settings.yaml # TODO: is there a better way to always include schema version? return { 'schema_version': self.schema_version, **super()._save_dict(**kwargs), } def __str__(self): out = 'NapariSettings (defaults excluded)\n' + 34 * '-' + '\n' data = self.dict(exclude_defaults=True) out += self._yaml_dump(_remove_empty_dicts(data)) return out def __repr__(self): return str(self) def _maybe_migrate(self): if self.schema_version < CURRENT_SCHEMA_VERSION: from napari.settings._migrations import do_migrations do_migrations(self) if __name__ == '__main__': import sys if len(sys.argv) > 2: dest = Path(sys.argv[2]).expanduser().absolute() else: dest = Path(__file__).parent / 'napari.schema.json' dest.write_text(NapariSettings.schema_json()) napari-0.5.0a1/napari/settings/_plugins.py000066400000000000000000000050271437041365600205310ustar00rootroot00000000000000from enum import Enum from typing import Dict, List, Set from pydantic import Field from typing_extensions import TypedDict from napari.settings._base import EventedSettings from napari.utils.misc import ( running_as_bundled_app, running_as_constructor_app, ) from napari.utils.translations import trans class PluginHookOption(TypedDict): """Custom type specifying plugin, hook implementation function name, and enabled state.""" plugin: str enabled: bool CallOrderDict = Dict[str, List[PluginHookOption]] class PluginAPI(str, Enum): napari_hub = 'napari hub' pypi = 'PyPI' class PluginsSettings(EventedSettings): use_npe2_adaptor: bool = Field( False, title=trans._("Use npe2 adaptor"), description=trans._( "Use npe2-adaptor for first generation plugins.\nWhen an npe1 plugin is found, this option will\nimport its contributions and create/cache\na 'shim' npe2 manifest that allows it to be treated\nlike an npe2 plugin (with delayed imports, etc...)", ), requires_restart=True, ) plugin_api: PluginAPI = Field( PluginAPI.pypi, title=trans._("Plugin API"), description=trans._( "Use the following API for querying plugin information.", ), ) call_order: CallOrderDict = Field( default_factory=dict, title=trans._("Plugin sort order"), description=trans._( "Sort plugins for each action in the order to be called.", ), ) disabled_plugins: Set[str] = Field( set(), title=trans._("Disabled plugins"), description=trans._( "Plugins to disable on application start.", ), ) extension2reader: Dict[str, str] = Field( default_factory=dict, title=trans._('File extension readers'), description=trans._( 'Assign file extensions to specific reader plugins' ), ) extension2writer: Dict[str, str] = Field( default_factory=dict, title=trans._('Writer plugin extension association.'), description=trans._( 'Assign file extensions to specific writer plugins' ), ) class Config: use_enum_values = False class NapariConfig: # Napari specific configuration preferences_exclude = [ 'schema_version', 'disabled_plugins', 'extension2writer', ] if running_as_bundled_app() or running_as_constructor_app(): preferences_exclude.append('plugin_api') napari-0.5.0a1/napari/settings/_shortcuts.py000066400000000000000000000020121437041365600210750ustar00rootroot00000000000000from typing import Dict, List from pydantic import Field, validator from napari.utils.events.evented_model import EventedModel from napari.utils.key_bindings import KeyBinding, coerce_keybinding from napari.utils.shortcuts import default_shortcuts from napari.utils.translations import trans class ShortcutsSettings(EventedModel): # FIXME user with modified shortcut will not see new shortcut shortcuts: Dict[str, List[KeyBinding]] = Field( default_shortcuts, title=trans._("shortcuts"), description=trans._( "Set keyboard shortcuts for actions.", ), ) class NapariConfig: # Napari specific configuration preferences_exclude = ['schema_version'] @validator('shortcuts') def shortcut_validate(cls, v): for name, value in default_shortcuts.items(): if name not in v: v[name] = value return { name: [coerce_keybinding(kb) for kb in value] for name, value in v.items() } napari-0.5.0a1/napari/settings/_tests/000077500000000000000000000000001437041365600176345ustar00rootroot00000000000000napari-0.5.0a1/napari/settings/_tests/__init__.py000066400000000000000000000000001437041365600217330ustar00rootroot00000000000000napari-0.5.0a1/napari/settings/_tests/test_migrations.py000066400000000000000000000077061437041365600234330ustar00rootroot00000000000000import os from importlib.metadata import PackageNotFoundError, distribution from unittest.mock import patch import pytest from napari.settings import NapariSettings, _migrations @pytest.fixture def _test_migrator(monkeypatch): # this fixture makes sure we're not using _migrations.MIGRATORS for tests # but rather only using migrators that get declared IN the test _TEST_MIGRATORS = [] with monkeypatch.context() as m: m.setattr(_migrations, "_MIGRATORS", _TEST_MIGRATORS) yield _migrations.migrator def test_no_migrations_available(_test_migrator): # no migrators exist... nothing should happen settings = NapariSettings(schema_version='0.1.0') assert settings.schema_version == '0.1.0' def test_backwards_migrator(_test_migrator): # we shouldn't be able to downgrade the schema version # if that is needed later, we can create a new decorator, # or change this test with pytest.raises(AssertionError): @_test_migrator('0.2.0', '0.1.0') def _(model): ... def test_migration_works(_test_migrator): # test that a basic migrator works to change the version # and mutate the model @_test_migrator('0.1.0', '0.2.0') def _(model: NapariSettings): model.appearance.theme = 'light' settings = NapariSettings(schema_version='0.1.0') assert settings.schema_version == '0.2.0' assert settings.appearance.theme == 'light' def test_migration_saves(_test_migrator): @_test_migrator('0.1.0', '0.2.0') def _(model: NapariSettings): ... with patch.object(NapariSettings, 'save') as mock: mock.assert_not_called() settings = NapariSettings(config_path='junk', schema_version='0.1.0') assert settings.schema_version == '0.2.0' mock.assert_called() def test_failed_migration_leaves_version(_test_migrator): # if an error occurs IN the migrator, the version should stay # where it was before the migration, and any changes reverted. @_test_migrator('0.1.0', '0.2.0') def _(model: NapariSettings): model.appearance.theme = 'light' assert model.appearance.theme == 'light' raise ValueError('broken migration') with pytest.warns(UserWarning) as e: settings = NapariSettings(schema_version='0.1.0') assert settings.schema_version == '0.1.0' # test migration was atomic, and reverted the theme change assert settings.appearance.theme == 'dark' # test that the user was warned assert 'Failed to migrate settings from v0.1.0 to v0.2.0' in str(e[0]) @pytest.mark.skipif( bool(os.environ.get('MIN_REQ')), reason='not relevant for MIN_REQ' ) def test_030_to_040_migration(): # Prior to v0.4.0, npe2 plugins were automatically "disabled" # 0.3.0 -> 0.4.0 should remove any installed npe2 plugins from the # set of disabled plugins (see migrator for details) try: d = distribution('napari-svg') assert 'napari.manifest' in {ep.group for ep in d.entry_points} except PackageNotFoundError: pytest.fail( 'napari-svg not present as an npe2 plugin. ' 'This test needs updating' ) settings = NapariSettings( schema_version='0.3.0', plugins={'disabled_plugins': {'napari-svg', 'napari'}}, ) assert 'napari-svg' not in settings.plugins.disabled_plugins assert 'napari' not in settings.plugins.disabled_plugins @pytest.mark.skipif( bool(os.environ.get('MIN_REQ')), reason='not relevant for MIN_REQ' ) def test_040_to_050_migration(): # Prior to 0.5.0 existing preferences may have reader extensions # preferences saved without a leading *. # fnmatch would fail on these so we coerce them to include a * # e.g. '.csv' becomes '*.csv' settings = NapariSettings( schema_version='0.4.0', plugins={'extension2reader': {'.tif': 'napari'}}, ) assert '.tif' not in settings.plugins.extension2reader assert '*.tif' in settings.plugins.extension2reader napari-0.5.0a1/napari/settings/_tests/test_settings.py000066400000000000000000000272411437041365600231130ustar00rootroot00000000000000"""Tests for the settings manager.""" import os from pathlib import Path import pydantic import pytest from yaml import safe_load from napari import settings from napari.settings import CURRENT_SCHEMA_VERSION, NapariSettings from napari.utils.theme import get_theme, register_theme @pytest.fixture def test_settings(tmp_path): """A fixture that can be used to test and save settings""" from napari.settings import NapariSettings class TestSettings(NapariSettings): class Config: env_prefix = 'testnapari_' return TestSettings( tmp_path / 'test_settings.yml', schema_version=CURRENT_SCHEMA_VERSION ) def test_settings_file(test_settings): assert not Path(test_settings.config_path).exists() test_settings.save() assert Path(test_settings.config_path).exists() def test_settings_autosave(test_settings): assert not Path(test_settings.config_path).exists() test_settings.appearance.theme = 'light' assert Path(test_settings.config_path).exists() def test_settings_file_not_created(test_settings): assert not Path(test_settings.config_path).exists() test_settings._save_on_change = False test_settings.appearance.theme = 'light' assert not Path(test_settings.config_path).exists() def test_settings_loads(tmp_path): data = "appearance:\n theme: light" fake_path = tmp_path / 'fake_path.yml' fake_path.write_text(data) assert NapariSettings(fake_path).appearance.theme == "light" def test_settings_load_invalid_content(tmp_path): # This is invalid content fake_path = tmp_path / 'fake_path.yml' fake_path.write_text(":") NapariSettings(fake_path) def test_settings_load_invalid_type(tmp_path, caplog): # The invalid data will be replaced by the default value data = "appearance:\n theme: 1" fake_path = tmp_path / 'fake_path.yml' fake_path.write_text(data) assert NapariSettings(fake_path).application.save_window_geometry is True assert 'Validation errors in config file' in str(caplog.records[0]) def test_settings_load_strict(tmp_path, monkeypatch): # use Config.strict_config_check to enforce good config files monkeypatch.setattr(NapariSettings.__config__, 'strict_config_check', True) data = "appearance:\n theme: 1" fake_path = tmp_path / 'fake_path.yml' fake_path.write_text(data) with pytest.raises(pydantic.ValidationError): NapariSettings(fake_path) def test_settings_load_invalid_key(tmp_path, monkeypatch): # The invalid key will be removed fake_path = tmp_path / 'fake_path.yml' data = """ application: non_existing_key: [1, 2] first_time: false """ fake_path.write_text(data) monkeypatch.setattr(os, 'environ', {}) s = NapariSettings(fake_path) assert getattr(s, "non_existing_key", None) is None s.save() text = fake_path.read_text() # removed bad key assert safe_load(text) == { 'application': {'first_time': False}, 'schema_version': CURRENT_SCHEMA_VERSION, } def test_settings_load_invalid_section(tmp_path): # The invalid section will be removed from the file data = "non_existing_section:\n foo: bar" fake_path = tmp_path / 'fake_path.yml' fake_path.write_text(data) settings = NapariSettings(fake_path) assert getattr(settings, "non_existing_section", None) is None def test_settings_to_dict(test_settings): data_dict = test_settings.dict() assert isinstance(data_dict, dict) and data_dict.get("application") data_dict = test_settings.dict(exclude_defaults=True) assert not data_dict.get("application") def test_settings_to_dict_no_env(monkeypatch): """Test that exclude_env works to exclude variables coming from the env.""" s = NapariSettings(None, appearance={'theme': 'light'}) assert s.dict()['appearance']['theme'] == 'light' assert s.dict(exclude_env=True)['appearance']['theme'] == 'light' monkeypatch.setenv("NAPARI_APPEARANCE_THEME", 'light') s = NapariSettings(None) assert s.dict()['appearance']['theme'] == 'light' assert 'theme' not in s.dict(exclude_env=True).get('appearance', {}) def test_settings_reset(test_settings): appearance_id = id(test_settings.appearance) test_settings.reset() assert id(test_settings.appearance) == appearance_id assert test_settings.appearance.theme == "dark" test_settings.appearance.theme = "light" assert test_settings.appearance.theme == "light" test_settings.reset() assert test_settings.appearance.theme == "dark" assert id(test_settings.appearance) == appearance_id def test_settings_model(test_settings): with pytest.raises(pydantic.error_wrappers.ValidationError): # Should be string test_settings.appearance.theme = 1 with pytest.raises(pydantic.error_wrappers.ValidationError): # Should be a valid string test_settings.appearance.theme = "vaporwave" def test_custom_theme_settings(test_settings): # See: https://github.com/napari/napari/issues/2340 custom_theme_name = "_test_blue_" # No theme registered yet, this should fail with pytest.raises(pydantic.error_wrappers.ValidationError): test_settings.appearance.theme = custom_theme_name blue_theme = get_theme('dark', True) blue_theme.update( background='rgb(28, 31, 48)', foreground='rgb(45, 52, 71)', primary='rgb(80, 88, 108)', current='rgb(184, 112, 0)', ) register_theme(custom_theme_name, blue_theme, "test") # Theme registered, should pass validation test_settings.appearance.theme = custom_theme_name def test_settings_string(test_settings): setstring = str(test_settings) assert 'NapariSettings (defaults excluded)' in setstring assert 'appearance:' not in setstring assert repr(test_settings) == setstring def test_model_fields_are_annotated(test_settings): errors = [] for field in test_settings.__fields__.values(): model = field.type_ if not hasattr(model, '__fields__'): continue difference = set(model.__fields__) - set(model.__annotations__) if difference: errors.append( f"Model '{model.__name__}' does not provide annotations " f"for the fields:\n{', '.join(repr(f) for f in difference)}" ) if errors: raise ValueError("\n\n".join(errors)) def test_settings_env_variables(monkeypatch): assert NapariSettings(None).appearance.theme == 'dark' # NOTE: this was previously tested as NAPARI_THEME monkeypatch.setenv('NAPARI_APPEARANCE_THEME', 'light') assert NapariSettings(None).appearance.theme == 'light' # can also use json assert NapariSettings(None).application.first_time is True # NOTE: this was previously tested as NAPARI_THEME monkeypatch.setenv('NAPARI_APPLICATION', '{"first_time": "false"}') assert NapariSettings(None).application.first_time is False # can also use json in nested vars assert NapariSettings(None).plugins.extension2reader == {} monkeypatch.setenv('NAPARI_PLUGINS_EXTENSION2READER', '{"*.zarr": "hi"}') assert NapariSettings(None).plugins.extension2reader == {"*.zarr": "hi"} def test_settings_env_variables_fails(monkeypatch): monkeypatch.setenv('NAPARI_APPEARANCE_THEME', 'FOOBAR') with pytest.raises(pydantic.ValidationError): NapariSettings() def test_subfield_env_field(monkeypatch): """test that setting Field(env=) works for subfields""" from napari.settings._base import EventedSettings class Sub(EventedSettings): x: int = pydantic.Field(1, env='varname') class T(NapariSettings): sub: Sub monkeypatch.setenv("VARNAME", '42') assert T(sub={}).sub.x == 42 # Failing because dark is actually the default... def test_settings_env_variables_do_not_write_to_disk(tmp_path, monkeypatch): # create a settings file with light theme data = "appearance:\n theme: light" fake_path = tmp_path / 'fake_path.yml' fake_path.write_text(data) # make sure they wrote correctly disk_settings = fake_path.read_text() assert 'theme: light' in disk_settings # make sure they load correctly assert NapariSettings(fake_path).appearance.theme == "light" # now load settings again with an Env-var override monkeypatch.setenv('NAPARI_APPEARANCE_THEME', 'dark') settings = NapariSettings(fake_path) # make sure the override worked, and save again assert settings.appearance.theme == 'dark' # data from the config file is still "known" assert settings._config_file_settings['appearance']['theme'] == 'light' # but we know what came from env vars as well: assert settings.env_settings()['appearance']['theme'] == 'dark' # when we save it shouldn't use environment variables and it shouldn't # have overriden our non-default value of `theme: light` settings.save() disk_settings = fake_path.read_text() assert 'theme: light' in disk_settings # and it's back if we reread without the env var override monkeypatch.delenv('NAPARI_APPEARANCE_THEME') assert NapariSettings(fake_path).appearance.theme == "light" def test_settings_only_saves_non_default_values(monkeypatch, tmp_path): from yaml import safe_load # prevent error during NAPARI_ASYNC tests monkeypatch.setattr(os, 'environ', {}) # manually get all default data and write to yaml file all_data = NapariSettings(None).yaml() fake_path = tmp_path / 'fake_path.yml' assert 'appearance' in all_data assert 'application' in all_data fake_path.write_text(all_data) # load that yaml file and resave NapariSettings(fake_path).save() # make sure that the only value is now the schema version assert safe_load(fake_path.read_text()) == { 'schema_version': CURRENT_SCHEMA_VERSION } def test_get_settings(tmp_path): p = f'{tmp_path}.yaml' s = settings.get_settings(p) assert str(s.config_path) == str(p) def test_get_settings_fails(monkeypatch, tmp_path): p = f'{tmp_path}.yaml' settings.get_settings(p) with pytest.raises(Exception) as e: settings.get_settings(p) assert 'The path can only be set once per session' in str(e) def test_first_time(): """This test just confirms that we don't load an existing file (locally)""" assert NapariSettings().application.first_time is True # def test_deprecated_SETTINGS(): # """Test that direct access of SETTINGS warns.""" # from napari.settings import SETTINGS # with pytest.warns(FutureWarning): # assert SETTINGS.appearance.theme == 'dark' def test_no_save_path(): """trying to save without a config path is an error""" s = NapariSettings(config_path=None) assert s.config_path is None with pytest.raises(ValueError): # the original `save()` method is patched in conftest.fresh_settings # so we "unmock" it here to assert the failure NapariSettings.__original_save__(s) # type: ignore def test_settings_events(test_settings): """Test that NapariSettings emits dotted keys.""" from unittest.mock import MagicMock mock = MagicMock() test_settings.events.changed.connect(mock) test_settings.appearance.theme = 'light' assert mock.called event = mock.call_args_list[0][0][0] assert event.key == 'appearance.theme' assert event.value == 'light' mock.reset_mock() test_settings.appearance.theme = 'light' mock.assert_not_called() @pytest.mark.parametrize('ext', ['yml', 'yaml', 'json']) def test_full_serialize(test_settings: NapariSettings, tmp_path, ext): """Make sure that every object in the settings is serializeable. Should work with both json and yaml. """ test_settings.save(tmp_path / f't.{ext}', exclude_defaults=False) napari-0.5.0a1/napari/settings/_tests/test_utils.py000066400000000000000000000022451437041365600224100ustar00rootroot00000000000000from napari.settings import get_settings from napari.settings._utils import _coerce_extensions_to_globs def test_coercion_to_glob_deletes_existing(): settings = {'.tif': 'fake-plugin', '*.csv': 'other-plugin'} settings = _coerce_extensions_to_globs(settings) assert '.tif' not in settings assert '*.tif' in settings assert settings['*.tif'] == 'fake-plugin' assert '*.csv' in settings assert settings['*.csv'] == 'other-plugin' def test_coercion_to_glob_excludes_non_extensions(): complex_pattern = '.blah*.tif' settings = {complex_pattern: 'fake-plugin', '*.csv': 'other-plugin'} settings = _coerce_extensions_to_globs(settings) assert '.blah*.tif' in settings assert settings[complex_pattern] == 'fake-plugin' def test_coercion_to_glob_doesnt_change_settings(): settings = {'*.tif': 'fake-plugin', '.csv': 'other-plugin'} get_settings().plugins.extension2reader = settings settings = _coerce_extensions_to_globs(settings) assert settings == {'*.tif': 'fake-plugin', '*.csv': 'other-plugin'} assert get_settings().plugins.extension2reader == { '*.tif': 'fake-plugin', '.csv': 'other-plugin', } napari-0.5.0a1/napari/settings/_utils.py000066400000000000000000000005511437041365600202050ustar00rootroot00000000000000def _coerce_extensions_to_globs(reader_settings): """Coerce existing reader settings for file extensions to glob patterns""" new_settings = {} for pattern, reader in reader_settings.items(): if pattern.startswith('.') and '*' not in pattern: pattern = f"*{pattern}" new_settings[pattern] = reader return new_settings napari-0.5.0a1/napari/settings/_yaml.py000066400000000000000000000056661437041365600200230ustar00rootroot00000000000000from __future__ import annotations from enum import Enum from typing import TYPE_CHECKING, Type from app_model.types import KeyBinding from pydantic import BaseModel from yaml import SafeDumper, dump_all from napari.settings._fields import Version if TYPE_CHECKING: from collections.abc import Mapping from typing import AbstractSet, Any, Dict, Optional, TypeVar, Union IntStr = Union[int, str] AbstractSetIntStr = AbstractSet[IntStr] DictStrAny = Dict[str, Any] MappingIntStrAny = Mapping[IntStr, Any] Model = TypeVar('Model', bound=BaseModel) class YamlDumper(SafeDumper): """The default YAML serializer for our pydantic models. Add support for custom types by using `YamlDumper.add_representer` or `YamlDumper.add_multi_representer` below. """ # add_representer requires a strict type match # add_multi_representer also works for all subclasses of the provided type. YamlDumper.add_multi_representer(str, YamlDumper.represent_str) YamlDumper.add_multi_representer( Enum, lambda dumper, data: dumper.represent_str(data.value) ) # the default set representer is ugly: # disabled_plugins: !!set # bioformats: null # and pydantic will make sure that incoming sets are converted to sets YamlDumper.add_representer( set, lambda dumper, data: dumper.represent_list(data) ) YamlDumper.add_representer( Version, lambda dumper, data: dumper.represent_str(str(data)) ) YamlDumper.add_representer( KeyBinding, lambda dumper, data: dumper.represent_str(str(data)) ) class PydanticYamlMixin(BaseModel): """Mixin that provides yaml dumping capability to pydantic BaseModel. To provide a custom yaml Dumper on a subclass, provide a `yaml_dumper` on the Config: class Config: yaml_dumper = MyDumper """ def yaml( self, *, include: Union[AbstractSetIntStr, MappingIntStrAny] = None, # type: ignore exclude: Union[AbstractSetIntStr, MappingIntStrAny] = None, # type: ignore by_alias: bool = False, exclude_unset: bool = False, exclude_defaults: bool = False, exclude_none: bool = False, dumper: Optional[Type[SafeDumper]] = None, **dumps_kwargs: Any, ) -> str: """Serialize model to yaml.""" data = self.dict( include=include, exclude=exclude, by_alias=by_alias, exclude_unset=exclude_unset, exclude_defaults=exclude_defaults, exclude_none=exclude_none, ) if self.__custom_root_type__: from pydantic.utils import ROOT_KEY data = data[ROOT_KEY] return self._yaml_dump(data, dumper, **dumps_kwargs) def _yaml_dump( self, data, dumper: Optional[Type[SafeDumper]] = None, **kw ) -> str: kw.setdefault('sort_keys', False) dumper = dumper or getattr(self.__config__, 'yaml_dumper', YamlDumper) return dump_all([data], Dumper=dumper, **kw) napari-0.5.0a1/napari/types.py000066400000000000000000000137121437041365600162150ustar00rootroot00000000000000from functools import partial, wraps from pathlib import Path from types import TracebackType from typing import ( TYPE_CHECKING, Any, Callable, Dict, Iterable, List, NewType, Sequence, Tuple, Type, Union, ) import numpy as np from typing_extensions import TypedDict, get_args if TYPE_CHECKING: import dask.array import zarr from magicgui.widgets import FunctionGui from qtpy.QtWidgets import QWidget try: from numpy.typing import DTypeLike # requires numpy 1.20 except ImportError: # Anything that can be coerced into numpy.dtype. # Reference: https://docs.scipy.org/doc/numpy/reference/arrays.dtypes.html from typing import Protocol, TypeVar _DType_co = TypeVar("_DType_co", covariant=True, bound=np.dtype) # A protocol for anything with the dtype attribute class _SupportsDType(Protocol[_DType_co]): @property def dtype(self) -> _DType_co: ... DTypeLike = Union[ # type: ignore np.dtype, # default data type (float64) None, type, # array-scalar types and generic types _SupportsDType[np.dtype], # anything with a dtype attribute str, # character codes, type strings, e.g. 'float64' ] # This is a WOEFULLY inadequate stub for a duck-array type. # Mostly, just a placeholder for the concept of needing an ArrayLike type. # Ultimately, this should come from https://github.com/napari/image-types # and should probably be replaced by a typing.Protocol # note, numpy.typing.ArrayLike (in v1.20) is not quite what we want either, # since it includes all valid arguments for np.array() ( int, float, str...) ArrayLike = Union[np.ndarray, 'dask.array.Array', 'zarr.Array'] # layer data may be: (data,) (data, meta), or (data, meta, layer_type) # using "Any" for the data type until ArrayLike is more mature. FullLayerData = Tuple[Any, Dict, str] LayerData = Union[Tuple[Any], Tuple[Any, Dict], FullLayerData] PathLike = Union[str, Path] PathOrPaths = Union[str, Sequence[str]] ReaderFunction = Callable[[PathOrPaths], List[LayerData]] WriterFunction = Callable[[str, List[FullLayerData]], List[str]] ExcInfo = Union[ Tuple[Type[BaseException], BaseException, TracebackType], Tuple[None, None, None], ] # Types for GUI HookSpecs WidgetCallable = Callable[..., Union['FunctionGui', 'QWidget']] AugmentedWidget = Union[WidgetCallable, Tuple[WidgetCallable, dict]] # Sample Data for napari_provide_sample_data hookspec is either a string/path # or a function that returns an iterable of LayerData tuples SampleData = Union[PathLike, Callable[..., Iterable[LayerData]]] # or... they can provide a dict as follows: class SampleDict(TypedDict): display_name: str data: SampleData # these types are mostly "intentionality" placeholders. While it's still hard # to use actual types to define what is acceptable data for a given layer, # these types let us point to a concrete namespace to indicate "this data is # intended to be (and is capable of) being turned into X layer type". # while their names should not change (without deprecation), their typing # implementations may... or may be rolled over to napari/image-types if tuple(np.__version__.split('.')) < ('1', '20'): # this hack is because NewType doesn't allow `Any` as a base type # and numpy <=1.20 didn't provide type stubs for np.ndarray # https://github.com/python/mypy/issues/6701#issuecomment-609638202 class ArrayBase(np.ndarray): def __getattr__(self, name: str) -> Any: return object.__getattribute__(self, name) else: ArrayBase = np.ndarray # type: ignore ImageData = NewType("ImageData", ArrayBase) LabelsData = NewType("LabelsData", ArrayBase) PointsData = NewType("PointsData", ArrayBase) ShapesData = NewType("ShapesData", List[ArrayBase]) SurfaceData = NewType("SurfaceData", Tuple[ArrayBase, ArrayBase, ArrayBase]) TracksData = NewType("TracksData", ArrayBase) VectorsData = NewType("VectorsData", ArrayBase) _LayerData = Union[ ImageData, LabelsData, PointsData, ShapesData, SurfaceData, TracksData, VectorsData, ] LayerDataTuple = NewType("LayerDataTuple", tuple) def image_reader_to_layerdata_reader( func: Callable[[PathOrPaths], ArrayLike] ) -> ReaderFunction: """Convert a PathLike -> ArrayLike function to a PathLike -> LayerData. Parameters ---------- func : Callable[[PathLike], ArrayLike] A function that accepts a string or list of strings, and returns an ArrayLike. Returns ------- reader_function : Callable[[PathLike], List[LayerData]] A function that accepts a string or list of strings, and returns data as a list of LayerData: List[Tuple[ArrayLike]] """ @wraps(func) def reader_function(*args, **kwargs) -> List[LayerData]: result = func(*args, **kwargs) return [(result,)] return reader_function def _register_types_with_magicgui(): """Register ``napari.types`` objects with magicgui.""" import sys from concurrent.futures import Future from magicgui import register_type from napari.utils import _magicgui as _mgui for _type in (LayerDataTuple, List[LayerDataTuple]): register_type( _type, return_callback=_mgui.add_layer_data_tuples_to_viewer, ) if sys.version_info >= (3, 9): future_type = Future[_type] # type: ignore register_type(future_type, return_callback=_mgui.add_future_data) for data_type in get_args(_LayerData): register_type( data_type, choices=_mgui.get_layers_data, return_callback=_mgui.add_layer_data_to_viewer, ) if sys.version_info >= (3, 9): register_type( Future[data_type], # type: ignore choices=_mgui.get_layers_data, return_callback=partial( _mgui.add_future_data, _from_tuple=False ), ) _register_types_with_magicgui() napari-0.5.0a1/napari/utils/000077500000000000000000000000001437041365600156335ustar00rootroot00000000000000napari-0.5.0a1/napari/utils/__init__.py000066400000000000000000000006301437041365600177430ustar00rootroot00000000000000from napari.utils._dask_utils import resize_dask_cache from napari.utils.colormaps import Colormap from napari.utils.info import citation_text, sys_info from napari.utils.notebook_display import nbscreenshot from napari.utils.progress import progrange, progress __all__ = ( "Colormap", "resize_dask_cache", "citation_text", "sys_info", "nbscreenshot", "progrange", "progress", ) napari-0.5.0a1/napari/utils/_appdirs.py000066400000000000000000000043651437041365600200160ustar00rootroot00000000000000import hashlib import os import sys from functools import partial from typing import Callable, Optional import appdirs sha_short = f"{os.path.basename(sys.prefix)}_{hashlib.sha1(sys.prefix.encode()).hexdigest()}" _appname = 'napari' _appauthor = False # all of these also take an optional "version" argument ... but if we want # to be able to update napari while using data (e.g. plugins, settings) from # an earlier version, we should leave off the version. user_data_dir: Callable[[], str] = partial( appdirs.user_data_dir, _appname, _appauthor ) user_config_dir: Callable[[], str] = partial( appdirs.user_config_dir, _appname, _appauthor, sha_short ) user_cache_dir: Callable[[], str] = partial( appdirs.user_cache_dir, _appname, _appauthor, sha_short ) user_state_dir: Callable[[], str] = partial( appdirs.user_state_dir, _appname, _appauthor ) user_log_dir: Callable[[], str] = partial( appdirs.user_log_dir, _appname, _appauthor ) def user_plugin_dir() -> str: """Prefix directory for external pip install. Suitable for use as argument with `pip install --prefix`. On mac and windows, we can install directly into the bundle. This may be used on Linux to pip install packages outside of the bundle with: ``pip install --prefix user_plugin_dir()`` """ return os.path.join(user_data_dir(), 'plugins') def user_site_packages() -> str: """Platform-specific location of site-packages folder in user library""" if os.name == 'nt': return os.path.join(user_plugin_dir(), 'Lib', 'site-packages') python_dir = f'python{sys.version_info.major}.{sys.version_info.minor}' return os.path.join(user_plugin_dir(), 'lib', python_dir, 'site-packages') def bundled_site_packages() -> Optional[str]: """Platform-specific location of site-packages folder in bundles.""" exe_dir = os.path.dirname(sys.executable) if os.name == 'nt': return os.path.join(exe_dir, "Lib", "site-packages") if sys.platform.startswith('darwin'): python_dir = f'python{sys.version_info.major}.{sys.version_info.minor}' return os.path.join( os.path.dirname(exe_dir), "lib", python_dir, "site-packages" ) # briefcase linux bundles cannot install into the AppImage return None napari-0.5.0a1/napari/utils/_base.py000066400000000000000000000005711437041365600172610ustar00rootroot00000000000000""" Default base variables for defining configuration paths. This is used by the translation loader as the settings models require using the translator before the settings manager is created. """ import os from napari.utils._appdirs import user_config_dir _FILENAME = "settings.yaml" _DEFAULT_LOCALE = "en" _DEFAULT_CONFIG_PATH = os.path.join(user_config_dir(), _FILENAME) napari-0.5.0a1/napari/utils/_dask_utils.py000066400000000000000000000120431437041365600205060ustar00rootroot00000000000000"""Dask cache utilities. """ import collections.abc import contextlib from typing import Callable, ContextManager, Optional, Tuple import dask import dask.array as da from dask.cache import Cache #: dask.cache.Cache, optional : A dask cache for opportunistic caching #: use :func:`~.resize_dask_cache` to actually register and resize. #: this is a global cache (all layers will use it), but individual layers #: can opt out using Layer(..., cache=False) _DASK_CACHE = Cache(1) _DEFAULT_MEM_FRACTION = 0.25 DaskIndexer = Callable[[], ContextManager[Optional[Tuple[dict, Cache]]]] def resize_dask_cache( nbytes: Optional[int] = None, mem_fraction: Optional[float] = None ) -> Cache: """Create or resize the dask cache used for opportunistic caching. The cache object is an instance of a :class:`Cache`, (which wraps a :class:`cachey.Cache`). See `Dask opportunistic caching `_ Parameters ---------- nbytes : int, optional The desired size of the cache, in bytes. If ``None``, the cache size will autodetermined as fraction of the total memory in the system, using ``mem_fraction``. If ``nbytes`` is 0. The cache is turned off. by default, cache size is autodetermined using ``mem_fraction``. mem_fraction : float, optional The fraction (from 0 to 1) of total memory to use for the dask cache. Returns ------- dask_cache : dask.cache.Cache An instance of a Dask Cache Examples -------- >>> from napari.utils import resize_dask_cache >>> cache = resize_dask_cache() # use 25% of total memory by default >>> # dask.Cache wraps cachey.Cache >>> assert isinstance(cache.cache, cachey.Cache) >>> # useful attributes >>> cache.cache.available_bytes # full size of cache >>> cache.cache.total_bytes # currently used bytes """ from psutil import virtual_memory if nbytes is None and mem_fraction is not None: nbytes = virtual_memory().total * mem_fraction avail = _DASK_CACHE.cache.available_bytes # if we don't have a cache already, create one. if avail == 1: # If neither nbytes nor mem_fraction was provided, use default if nbytes is None: nbytes = virtual_memory().total * _DEFAULT_MEM_FRACTION _DASK_CACHE.cache.resize(nbytes) elif nbytes is not None and nbytes != _DASK_CACHE.cache.available_bytes: # if the cache has already been registered, then calling # resize_dask_cache() without supplying either mem_fraction or nbytes # is a no-op: _DASK_CACHE.cache.resize(nbytes) return _DASK_CACHE def _is_dask_data(data) -> bool: """Return True if data is a dask array or a list/tuple of dask arrays.""" return isinstance(data, da.Array) or ( isinstance(data, collections.abc.Sequence) and any(isinstance(i, da.Array) for i in data) ) def configure_dask(data, cache=True) -> DaskIndexer: """Spin up cache and return context manager that optimizes Dask indexing. This function determines whether data is a dask array or list of dask arrays and prepares some optimizations if so. When a delayed dask array is given to napari, there are couple things that need to be done to optimize performance. 1. Opportunistic caching needs to be enabled, such that we don't recompute (or "re-read") data that has already been computed or read. 2. Dask task fusion must be turned off to prevent napari from triggering new io on data that has already been read from disk. For example, with a 4D timelapse of 3D stacks, napari may actually *re-read* the entire 3D tiff file every time the Z plane index is changed. Turning of Dask task fusion with ``optimization.fuse.active == False`` prevents this. .. note:: Turning off task fusion requires Dask version 2.15.0 or later. For background and context, see `napari/napari#718 `_, `napari/napari#1124 `_, and `dask/dask#6084 `_. For details on Dask task fusion, see the documentation on `Dask Optimization `_. Parameters ---------- data : Any data, as passed to a ``Layer.__init__`` method. Returns ------- ContextManager A context manager that can be used to optimize dask indexing Examples -------- >>> data = dask.array.ones((10,10,10)) >>> optimized_slicing = configure_dask(data) >>> with optimized_slicing(): ... data[0, 2].compute() """ if not _is_dask_data(data): return contextlib.nullcontext _cache = resize_dask_cache() if cache else contextlib.nullcontext() @contextlib.contextmanager def dask_optimized_slicing(memfrac=0.5): opts = {"optimization.fuse.active": False} with dask.config.set(opts) as cfg, _cache as c: yield cfg, c return dask_optimized_slicing napari-0.5.0a1/napari/utils/_dtype.py000066400000000000000000000055361437041365600175020ustar00rootroot00000000000000from typing import Tuple, Union import numpy as np _np_uints = { 8: np.uint8, 16: np.uint16, 32: np.uint32, 64: np.uint64, } _np_ints = { 8: np.int8, 16: np.int16, 32: np.int32, 64: np.int64, } _np_floats = { 16: np.float16, 32: np.float32, 64: np.float64, } _np_complex = { 64: np.complex64, 128: np.complex128, } _np_kinds = { 'uint': _np_uints, 'int': _np_ints, 'float': _np_floats, 'complex': _np_complex, } def _normalize_str_by_bit_depth(dtype_str, kind): if not any(str.isdigit(c) for c in dtype_str): # Python 'int' or 'float' return np.dtype(kind).type bit_dict = _np_kinds[kind] if '128' in dtype_str: return bit_dict[128] if '8' in dtype_str: return bit_dict[8] if '16' in dtype_str: return bit_dict[16] if '32' in dtype_str: return bit_dict[32] if '64' in dtype_str: return bit_dict[64] def normalize_dtype(dtype_spec): """Return a proper NumPy type given ~any duck array dtype. Parameters ---------- dtype_spec : numpy dtype, numpy type, torch dtype, tensorstore dtype, etc A type that can be interpreted as a NumPy numeric data type, e.g. 'uint32', np.uint8, torch.float32, etc. Returns ------- dtype : numpy.dtype The corresponding dtype. Notes ----- half-precision floats are not supported. """ dtype_str = str(dtype_spec) if 'uint' in dtype_str: return _normalize_str_by_bit_depth(dtype_str, 'uint') if 'int' in dtype_str: return _normalize_str_by_bit_depth(dtype_str, 'int') if 'float' in dtype_str: return _normalize_str_by_bit_depth(dtype_str, 'float') if 'complex' in dtype_str: return _normalize_str_by_bit_depth(dtype_str, 'complex') if 'bool' in dtype_str: return np.bool_ # If we don't find one of the named dtypes, return the dtype_spec # unchanged. This allows NumPy big endian types to work. See # https://github.com/napari/napari/issues/3421 else: return dtype_spec def get_dtype_limits(dtype_spec) -> Tuple[float, float]: """Return machine limits for numeric types. Parameters ---------- dtype_spec : numpy dtype, numpy type, torch dtype, tensorstore dtype, etc A type that can be interpreted as a NumPy numeric data type, e.g. 'uint32', np.uint8, torch.float32, etc. Returns ------- limits : tuple The smallest/largest numbers expressible by the type. """ dtype = normalize_dtype(dtype_spec) info: Union[np.iinfo, np.finfo] if np.issubdtype(dtype, np.integer): info = np.iinfo(dtype) elif dtype and np.issubdtype(dtype, np.floating): info = np.finfo(dtype) else: raise TypeError(f'Unrecognized or non-numeric dtype: {dtype_spec}') return info.min, info.max napari-0.5.0a1/napari/utils/_magicgui.py000066400000000000000000000310401437041365600201270ustar00rootroot00000000000000"""This module installs some napari-specific types in magicgui, if present. magicgui is a package that allows users to create GUIs from python functions https://magicgui.readthedocs.io/en/latest/ It offers a function ``register_type`` that allows developers to specify how their custom classes or types should be converted into GUIs. Then, when the end-user annotates one of their function arguments with a type hint using one of those custom classes, magicgui will know what to do with it. """ from __future__ import annotations import weakref from functools import lru_cache, partial from typing import TYPE_CHECKING, Any, List, Optional, Tuple, Type from typing_extensions import get_args from napari.utils._proxies import PublicOnlyProxy if TYPE_CHECKING: from concurrent.futures import Future from magicgui.widgets import FunctionGui from magicgui.widgets._bases import CategoricalWidget from napari._qt.qthreading import FunctionWorker from napari.layers import Layer from napari.viewer import Viewer def add_layer_data_to_viewer(gui: FunctionGui, result: Any, return_type: Type): """Show a magicgui result in the viewer. This function will be called when a magicgui-decorated function has a return annotation of one of the `napari.types.Data` ... and will add the data in ``result`` to the current viewer as the corresponding layer type. Parameters ---------- gui : MagicGui or QWidget The instantiated MagicGui widget. May or may not be docked in a dock widget. result : Any The result of the function call. For this function, this should be *just* the data part of the corresponding layer type. return_type : type The return annotation that was used in the decorated function. Examples -------- This allows the user to do this, and add the result as a viewer Image. >>> @magicgui ... def make_layer() -> napari.types.ImageData: ... return np.random.rand(256, 256) """ from napari._app_model.injection._processors import ( _add_layer_data_to_viewer, ) if result is not None and (viewer := find_viewer_ancestor(gui)): _add_layer_data_to_viewer( result, return_type=return_type, viewer=viewer, layer_name=gui.result_name, source={'widget': gui}, ) def add_layer_data_tuples_to_viewer(gui, result, return_type): """Show a magicgui result in the viewer. This function will be called when a magicgui-decorated function has a return annotation of one of the `napari.types.Data` ... and will add the data in ``result`` to the current viewer as the corresponding layer type. Parameters ---------- gui : MagicGui or QWidget The instantiated MagicGui widget. May or may not be docked in a dock widget. result : Any The result of the function call. For this function, this should be *just* the data part of the corresponding layer type. return_type : type The return annotation that was used in the decorated function. Examples -------- This allows the user to do this, and add the result to the viewer >>> @magicgui ... def make_layer() -> napari.types.LayerDataTuple: ... return (np.ones((10,10)), {'name': 'hi'}) >>> @magicgui ... def make_layer() -> List[napari.types.LayerDataTuple]: ... return [(np.ones((10,10)), {'name': 'hi'})] """ from napari._app_model.injection._processors import ( _add_layer_data_tuples_to_viewer, ) if viewer := find_viewer_ancestor(gui): _add_layer_data_tuples_to_viewer( result, viewer=viewer, source={'widget': gui} ) def add_worker_data( widget, worker: FunctionWorker, return_type, _from_tuple=True ): """Handle a thread_worker object returned from a magicgui widget. This allows someone annotate their magicgui with a return type of `FunctionWorker[...]`, create a napari thread worker (e.g. with the ``@thread_worker`` decorator), then simply return the worker. We will hook up the `returned` signal to the machinery to add the result of the long-running function to the viewer. Parameters ---------- widget : MagicGui The instantiated MagicGui widget. May or may not be docked in a dock widget. worker : WorkerBase An instance of `napari._qt.qthreading.WorkerBase`, on worker.returned, the result will be added to the viewer. return_type : type The return annotation that was used in the decorated function. _from_tuple : bool, optional (only for internal use). True if the worker returns `LayerDataTuple`, False if it returns one of the `LayerData` types. Examples -------- .. code-block:: python @magicgui def my_widget(...) -> FunctionWorker[ImageData]: @thread_worker def do_something_slowly(...) -> ImageData: ... return do_something_slowly(...) """ cb = ( add_layer_data_tuples_to_viewer if _from_tuple else add_layer_data_to_viewer ) _return_type = get_args(return_type)[0] worker.signals.returned.connect( partial(cb, widget, return_type=_return_type) ) def add_future_data(gui, future: Future, return_type, _from_tuple=True): """Process a Future object from a magicgui widget. This function will be called when a magicgui-decorated function has a return annotation of one of the `napari.types.Data` ... and will add the data in ``result`` to the current viewer as the corresponding layer type. Parameters ---------- gui : FunctionGui The instantiated magicgui widget. May or may not be docked in a dock widget. future : Future An instance of `concurrent.futures.Future` (or any third-party) object with the same interface, that provides `add_done_callback` and `result` methods. When the future is `done()`, the `result()` will be added to the viewer. return_type : type The return annotation that was used in the decorated function. _from_tuple : bool, optional (only for internal use). True if the future returns `LayerDataTuple`, False if it returns one of the `LayerData` types. """ from napari._app_model.injection._processors import _add_future_data if viewer := find_viewer_ancestor(gui): _add_future_data( future, return_type=get_args(return_type)[0], _from_tuple=_from_tuple, viewer=viewer, source={'widget': gui}, ) def find_viewer_ancestor(widget) -> Optional[Viewer]: """Return the closest parent Viewer of ``widget``. Priority is given to `Viewer` ancestors of ``widget``. `napari.current_viewer()` is called for Widgets without a Viewer ancestor. Parameters ---------- widget : QWidget A widget Returns ------- viewer : napari.Viewer or None Viewer ancestor if it exists, else `napari.current_viewer()` """ from napari._qt.widgets.qt_viewer_dock_widget import QtViewerDockWidget # magicgui v0.2.0 widgets are no longer QWidget subclasses, but the native # widget is available at widget.native if hasattr(widget, 'native') and hasattr(widget.native, 'parent'): parent = widget.native.parent() else: parent = widget.parent() from napari.viewer import current_viewer while parent: if hasattr(parent, '_qt_viewer'): # QMainWindow return parent._qt_viewer.viewer if isinstance(parent, QtViewerDockWidget): # DockWidget qt_viewer = parent._ref_qt_viewer() if qt_viewer is not None: return qt_viewer.viewer return current_viewer() parent = parent.parent() return current_viewer() def proxy_viewer_ancestor(widget) -> Optional[PublicOnlyProxy[Viewer]]: if viewer := find_viewer_ancestor(widget): return PublicOnlyProxy(viewer) return None def get_layers(gui: CategoricalWidget) -> List[Layer]: """Retrieve layers matching gui.annotation, from the Viewer the gui is in. Parameters ---------- gui : magicgui.widgets.Widget The instantiated MagicGui widget. May or may not be docked in a dock widget. Returns ------- tuple Tuple of layers of type ``gui.annotation`` Examples -------- This allows the user to do this, and get a dropdown box in their GUI that shows the available image layers. >>> @magicgui ... def get_layer_mean(layer: napari.layers.Image) -> float: ... return layer.data.mean() """ if viewer := find_viewer_ancestor(gui.native): return [x for x in viewer.layers if isinstance(x, gui.annotation)] return [] def get_layers_data(gui: CategoricalWidget) -> List[Tuple[str, Any]]: """Retrieve layers matching gui.annotation, from the Viewer the gui is in. As opposed to `get_layers`, this function returns just `layer.data` rather than the full layer object. Parameters ---------- gui : magicgui.widgets.Widget The instantiated MagicGui widget. May or may not be docked in a dock widget. Returns ------- tuple Tuple of layer.data from layers of type ``gui.annotation`` Examples -------- This allows the user to do this, and get a dropdown box in their GUI that shows the available image layers, but just get the data from the image as function input >>> @magicgui ... def get_layer_mean(data: napari.types.ImageData) -> float: ... return data.mean() """ from napari import layers if not (viewer := find_viewer_ancestor(gui.native)): return () layer_type_name = gui.annotation.__name__.replace("Data", "").title() layer_type = getattr(layers, layer_type_name) choices = [] for layer in [x for x in viewer.layers if isinstance(x, layer_type)]: choice_key = f'{layer.name} (data)' choices.append((choice_key, layer.data)) layer.events.data.connect(_make_choice_data_setter(gui, choice_key)) return choices @lru_cache(maxsize=None) def _make_choice_data_setter(gui: CategoricalWidget, choice_name: str): """Return a function that sets the ``data`` for ``choice_name`` in ``gui``. Note, using lru_cache here so that the **same** function object is returned if you call this twice for the same widget/choice_name combination. This is so that when we connect it above in `layer.events.data.connect()`, it will only get connected once (because ``.connect()`` will not add a specific callback more than once) """ gui_ref = weakref.ref(gui) def setter(event): _gui = gui_ref() if _gui is not None: _gui.set_choice(choice_name, event.value) return setter def add_layer_to_viewer(gui, result: Any, return_type: Type[Layer]) -> None: """Show a magicgui result in the viewer. Parameters ---------- gui : MagicGui or QWidget The instantiated MagicGui widget. May or may not be docked in a dock widget. result : Any The result of the function call. return_type : type The return annotation that was used in the decorated function. Examples -------- This allows the user to do this, and add the resulting layer to the viewer. >>> @magicgui ... def make_layer() -> napari.layers.Image: ... return napari.layers.Image(np.random.rand(64, 64)) """ add_layers_to_viewer(gui, [result], List[return_type]) def add_layers_to_viewer(gui, result: Any, return_type: List[Layer]) -> None: """Show a magicgui result in the viewer. Parameters ---------- gui : MagicGui or QWidget The instantiated MagicGui widget. May or may not be docked in a dock widget. result : Any The result of the function call. return_type : type The return annotation that was used in the decorated function. Examples -------- This allows the user to do this, and add the resulting layer to the viewer. >>> @magicgui ... def make_layer() -> List[napari.layers.Layer]: ... return napari.layers.Image(np.random.rand(64, 64)) """ from napari._app_model.injection._processors import _add_layer_to_viewer viewer = find_viewer_ancestor(gui) if not viewer: return for item in result: if item is not None: _add_layer_to_viewer(item, viewer=viewer, source={'widget': gui}) napari-0.5.0a1/napari/utils/_octree.py000066400000000000000000000051211437041365600176240ustar00rootroot00000000000000"""Async and Octree config file. Async/octree has its own little JSON config file. This is temporary until napari has a system-wide one. """ import json import logging from pathlib import Path from typing import Optional from napari.settings import get_settings from napari.utils.translations import trans LOGGER = logging.getLogger("napari.loader") DEFAULT_OCTREE_CONFIG = { "loader_defaults": { "log_path": None, "force_synchronous": False, "num_workers": 10, "use_processes": False, "auto_sync_ms": 30, "delay_queue_ms": 100, }, "octree": { "enabled": True, "tile_size": 256, "log_path": None, "loaders": { 0: {"num_workers": 10, "delay_queue_ms": 100}, 2: {"num_workers": 10, "delay_queue_ms": 0}, }, }, } def _get_async_config() -> Optional[dict]: """Get configuration implied by NAPARI_ASYNC. Returns ------- Optional[dict] The async config to use or None if async not specified. """ async_var = get_settings().experimental.async_ if async_var in [True, False]: async_var = str(int(async_var)) # NAPARI_ASYNC can now only be "0" or "1". if async_var not in [None, "0", "1"]: raise ValueError( trans._( 'NAPARI_ASYNC can only be "0" or "1"', deferred=True, ) ) # If NAPARI_ASYNC is "1" use defaults but with octree disabled. if async_var == "1": async_config = DEFAULT_OCTREE_CONFIG.copy() async_config['octree']['enabled'] = False return async_config # NAPARI_ASYNC is not enabled. return None def get_octree_config() -> dict: """Return the config data from the user's file or the default data. Returns ------- dict The config data we should use. """ settings = get_settings() octree_var = settings.experimental.octree if octree_var in [True, False]: octree_var = str(int(octree_var)) # If NAPARI_OCTREE is not enabled, defer to NAPARI_ASYNC if octree_var in [None, "0"]: # This will return DEFAULT_ASYNC_CONFIG or None. return _get_async_config() # If NAPARI_OCTREE is "1" then use default config. if octree_var == "1": return DEFAULT_OCTREE_CONFIG # NAPARI_OCTREE should be a config file path path = Path(octree_var).expanduser() with path.open() as infile: json_config = json.load(infile) # Need to set this for the preferences dialog to build. settings.experimental.octree = True return json_config napari-0.5.0a1/napari/utils/_proxies.py000066400000000000000000000126721437041365600200450ustar00rootroot00000000000000import os import re import sys import warnings from typing import Any, Callable, Generic, TypeVar, Union import wrapt from napari.utils import misc from napari.utils.translations import trans _T = TypeVar("_T") class ReadOnlyWrapper(wrapt.ObjectProxy): """ Disable item and attribute setting with the exception of ``__wrapped__``. """ def __setattr__(self, name, val): if name != '__wrapped__': raise TypeError( trans._( 'cannot set attribute {name}', deferred=True, name=name, ) ) super().__setattr__(name, val) def __setitem__(self, name, val): raise TypeError( trans._('cannot set item {name}', deferred=True, name=name) ) _SUNDER = re.compile('^_[^_]') class PublicOnlyProxy(wrapt.ObjectProxy, Generic[_T]): """Proxy to prevent private attribute and item access, recursively.""" __wrapped__: _T @staticmethod def _is_private_attr(name: str) -> bool: return name.startswith("_") and not ( name.startswith('__') and name.endswith('__') ) @staticmethod def _private_attr_warning(name: str, typ: str): warnings.warn( trans._( "Private attribute access ('{typ}.{name}') in this context (e.g. inside a plugin widget or dock widget) is deprecated and will be unavailable in version 0.5.0", deferred=True, name=name, typ=typ, ), category=FutureWarning, stacklevel=3, ) # This is code prepared for a moment where we want to block access to private attributes # raise AttributeError( # trans._( # "Private attribute set/access ('{typ}.{name}') not allowed in this context.", # deferred=True, # name=name, # typ=typ, # ) # ) @staticmethod def _is_called_from_napari(): """ Check if the getter or setter is called from inner napari. """ if hasattr(sys, "_getframe"): frame = sys._getframe(2) return frame.f_code.co_filename.startswith(misc.ROOT_DIR) return False def __getattr__(self, name: str): if self._is_private_attr(name): # allow napari to access private attributes and get an non-proxy if self._is_called_from_napari(): return super().__getattr__(name) typ = type(self.__wrapped__).__name__ self._private_attr_warning(name, typ) return self.create(super().__getattr__(name)) def __setattr__(self, name: str, value: Any): if ( os.environ.get("NAPARI_ENSURE_PLUGIN_MAIN_THREAD", "0") not in ("0", "False") ) and not in_main_thread(): raise RuntimeError( "Setting attributes on a napari object is only allowed from the main Qt thread." ) if self._is_private_attr(name): if self._is_called_from_napari(): return super().__setattr__(name, value) typ = type(self.__wrapped__).__name__ self._private_attr_warning(name, typ) setattr(self.__wrapped__, name, value) def __getitem__(self, key): return self.create(super().__getitem__(key)) def __repr__(self): return repr(self.__wrapped__) def __dir__(self): return [x for x in dir(self.__wrapped__) if not _SUNDER.match(x)] @classmethod def create(cls, obj: Any) -> Union['PublicOnlyProxy', Any]: # restrict the scope of this proxy to napari objects mod = getattr(type(obj), '__module__', None) or '' if not mod.startswith('napari'): return obj if isinstance(obj, PublicOnlyProxy): return obj # don't double-wrap if callable(obj): return CallablePublicOnlyProxy(obj) return PublicOnlyProxy(obj) class CallablePublicOnlyProxy(PublicOnlyProxy[Callable]): def __call__(self, *args, **kwargs): return self.__wrapped__(*args, **kwargs) def in_main_thread_py() -> bool: """ Check if caller is in main python thread. Returns ------- thread_flag : bool True if we are in the main thread, False otherwise. """ import threading return threading.current_thread() == threading.main_thread() def _in_main_thread() -> bool: """ General implementation of checking if we are in a proper thread. If Qt is available and Application is created then assign :py:func:`in_qt_main_thread` to `in_main_thread`. If Qt liba are not available then assign :py:func:`in_main_thread_py` to in_main_thread. IF Qt libs are available but there is no Application ti wil emmit warning and return result of in_main_thread_py. Returns ------- thread_flag : bool True if we are in the main thread, False otherwise. """ global in_main_thread try: from napari._qt.utils import in_qt_main_thread res = in_qt_main_thread() in_main_thread = in_qt_main_thread return res except ImportError: in_main_thread = in_main_thread_py return in_main_thread_py() except AttributeError: warnings.warn( "Qt libs are available but no QtApplication instance is created" ) return in_main_thread_py() in_main_thread = _in_main_thread napari-0.5.0a1/napari/utils/_register.py000066400000000000000000000051401437041365600201700ustar00rootroot00000000000000import sys from inspect import Parameter, getdoc, signature from napari.utils.misc import camel_to_snake from napari.utils.translations import trans template = """def {name}{signature}: kwargs = locals() kwargs.pop('self', None) layer = {cls_name}(**kwargs) self.layers.append(layer) return layer """ def create_func(cls, name=None, doc=None, filename: str = ''): cls_name = cls.__name__ if name is None: name = camel_to_snake(cls_name) if 'layer' in name: raise ValueError( trans._( "name {name} should not include 'layer'", deferred=True, name=name, ) ) name = 'add_' + name if doc is None: doc = getdoc(cls) cutoff = doc.find('\n\nParameters\n----------\n') if cutoff > 0: doc = doc[cutoff:] n = 'n' if cls_name[0].lower() in 'aeiou' else '' doc = f'Add a{n} {cls_name} layer to the layer list. ' + doc doc += '\n\nReturns\n-------\n' doc += f'layer : :class:`napari.layers.{cls_name}`' doc += f'\n\tThe newly-created {cls_name.lower()} layer.' doc = doc.expandtabs(4) sig = signature(cls) new_sig = sig.replace( parameters=[Parameter('self', Parameter.POSITIONAL_OR_KEYWORD)] + list(sig.parameters.values()), return_annotation=cls, ) src = template.format( name=name, signature=new_sig, cls_name=cls_name, ) execdict = {cls_name: cls, 'napari': sys.modules.get('napari')} code = compile(src, filename=filename, mode='exec') exec(code, execdict) func = execdict[name] func.__doc__ = doc return func def _register(cls, *, name=None, doc=None): from napari.components import ViewerModel func = create_func(cls, name=name, doc=doc) setattr(ViewerModel, func.__name__, func) return cls def add_to_viewer(cls=None, *, name=None, doc=None): """Adds a layer creation convenience method under viewers as ``add_{name}``. Parameters ---------- cls : type, optional Class to register. If None, this function is treated as a decorator. name : string, keyword-only Name in snake-case of the layer name. If None, is autogenerated from the class name. doc : string, keyword-only Docstring to use in the method. If None, is autogenerated from the existing docstring. """ if cls is not None: return _register(cls, name=name, doc=doc) def inner(cls): return _register(cls, name=name, doc=doc) return inner napari-0.5.0a1/napari/utils/_tests/000077500000000000000000000000001437041365600171345ustar00rootroot00000000000000napari-0.5.0a1/napari/utils/_tests/__init__.py000066400000000000000000000000001437041365600212330ustar00rootroot00000000000000napari-0.5.0a1/napari/utils/_tests/test_action_manager.py000066400000000000000000000030211437041365600235100ustar00rootroot00000000000000""" This module test some of the behavior of action manager. """ import pytest from napari.utils.action_manager import ActionManager @pytest.fixture def action_manager(): """ Unlike normal napari we use a different instance we have complete control over and can throw away this makes it easier. """ return ActionManager() def test_unbind_non_existing_action(action_manager): """ We test that unbinding an non existing action is ok, this can happen due to keybindings in settings while some plugins are deactivated or upgraded. We emit a warning but should not fail. """ with pytest.warns(UserWarning): assert action_manager.unbind_shortcut('napari:foo_bar') is None def test_bind_multiple_action(action_manager): """ Test we can have multiple bindings per action """ action_manager.register_action( 'napari:test_action_2', lambda: None, 'this is a test action', None ) action_manager.bind_shortcut('napari:test_action_2', 'X') action_manager.bind_shortcut('napari:test_action_2', 'Y') assert action_manager._shortcuts['napari:test_action_2'] == ['X', 'Y'] def test_bind_unbind_existing_action(action_manager): action_manager.register_action( 'napari:test_action_1', lambda: None, 'this is a test action', None ) assert action_manager.bind_shortcut('napari:test_action_1', 'X') is None assert action_manager.unbind_shortcut('napari:test_action_1') == ['X'] assert action_manager._shortcuts['napari:test_action_1'] == [] napari-0.5.0a1/napari/utils/_tests/test_dtype.py000066400000000000000000000047331437041365600217010ustar00rootroot00000000000000import itertools import numpy as np import pytest import zarr from dask import array as da from napari.utils._dtype import get_dtype_limits, normalize_dtype ts = pytest.importorskip('tensorstore') torch = pytest.importorskip('torch') bit_depths = [str(2**i) for i in range(3, 7)] uints = ['uint' + b for b in bit_depths] ints = ['int' + b for b in bit_depths] floats = ['float32', 'float64'] complex = ['complex64', 'complex128'] bools = ['bool'] pure_py = ['int', 'float'] @pytest.mark.parametrize( 'dtype_str', ['uint8'] + ints + floats + complex + bools ) def test_normalize_dtype_torch(dtype_str): """torch doesn't have uint for >8bit, so it gets its own test.""" # torch doesn't let you specify dtypes as str, # see https://github.com/pytorch/pytorch/issues/40568 torch_arr = torch.zeros(5, dtype=getattr(torch, dtype_str)) np_arr = np.zeros(5, dtype=dtype_str) assert normalize_dtype(torch_arr.dtype) is np_arr.dtype.type @pytest.mark.parametrize('dtype_str', uints + ints + floats + complex + bools) def test_normalize_dtype_tensorstore(dtype_str): np_arr = np.zeros(5, dtype=dtype_str) ts_arr = ts.array(np_arr) # inherit ts dtype from np dtype assert normalize_dtype(ts_arr.dtype) is np_arr.dtype.type @pytest.mark.parametrize( 'module, dtype_str', itertools.product((np, da, zarr), uints + ints + floats + complex + bools), ) def test_normalize_dtype_np_noop(module, dtype_str): """Check that normalize dtype works as expected for plain NumPy dtypes.""" module_arr = module.zeros(5, dtype=dtype_str) np_arr = np.zeros(5, dtype=normalize_dtype(module_arr.dtype)) assert normalize_dtype(module_arr.dtype) is normalize_dtype(np_arr.dtype) @pytest.mark.parametrize('dtype_str', ['int', 'float']) def test_pure_python_types(dtype_str): pure_arr = np.zeros(5, dtype=dtype_str) norm_arr = np.zeros(5, dtype=normalize_dtype(dtype_str)) assert pure_arr.dtype is norm_arr.dtype # note: we don't write specific tests for zarr and dask because they use numpy # dtypes directly. @pytest.mark.parametrize( "dtype", [ int, 'uint8', np.uint8, 'int8', 'uint16', 'int16', 'uint32', 'int32', float, 'float32', 'float64', '>f4', '>f8', ] + [''.join(t) for t in itertools.product('<>', 'iu', '1248')], ) def test_dtype_lims(dtype): lims = get_dtype_limits(dtype) assert isinstance(lims, tuple) and len(lims) == 2 napari-0.5.0a1/napari/utils/_tests/test_geometry.py000066400000000000000000000416201437041365600224030ustar00rootroot00000000000000import numpy as np import pytest from napari.utils.geometry import ( bounding_box_to_face_vertices, clamp_point_to_bounding_box, distance_between_point_and_line_3d, face_coordinate_from_bounding_box, find_front_back_face, find_nearest_triangle_intersection, inside_triangles, intersect_line_with_axis_aligned_bounding_box_3d, intersect_line_with_axis_aligned_plane, intersect_line_with_multiple_planes_3d, intersect_line_with_plane_3d, line_in_quadrilateral_3d, line_in_triangles_3d, point_in_quadrilateral_2d, project_points_onto_plane, rotation_matrix_from_vectors_2d, rotation_matrix_from_vectors_3d, ) single_point = np.array([10, 10, 10]) expected_point_single = np.array([[10, 0, 10]]) expected_distance_single = np.array([10]) multiple_point = np.array( [[10, 10, 10], [20, 10, 30], [20, 40, 20], [10, -5, 30]] ) expected_multiple_point = np.array( [[10, 0, 10], [20, 0, 30], [20, 0, 20], [10, 0, 30]] ) expected_distance_multiple = np.array([10, 10, 40, -5]) @pytest.mark.parametrize( "point,expected_projected_point,expected_distances", [ (single_point, expected_point_single, expected_distance_single), (multiple_point, expected_multiple_point, expected_distance_multiple), ], ) def test_project_point_to_plane( point, expected_projected_point, expected_distances ): plane_point = np.array([20, 0, 0]) plane_normal = np.array([0, 1, 0]) projected_point, distance_to_plane = project_points_onto_plane( point, plane_point, plane_normal ) np.testing.assert_allclose(projected_point, expected_projected_point) np.testing.assert_allclose(distance_to_plane, expected_distances) @pytest.mark.parametrize( "vec_1, vec_2", [ (np.array([10, 0]), np.array([0, 5])), (np.array([0, 5]), np.array([0, 5])), (np.array([0, 5]), np.array([0, -5])), ], ) def test_rotation_matrix_from_vectors_2d(vec_1, vec_2): rotation_matrix = rotation_matrix_from_vectors_2d(vec_1, vec_2) rotated_1 = rotation_matrix.dot(vec_1) unit_rotated_1 = rotated_1 / np.linalg.norm(rotated_1) unit_vec_2 = vec_2 / np.linalg.norm(vec_2) np.testing.assert_allclose(unit_rotated_1, unit_vec_2) @pytest.mark.parametrize( "vec_1, vec_2", [ (np.array([10, 0, 0]), np.array([0, 5, 0])), (np.array([0, 5, 0]), np.array([0, 5, 0])), (np.array([0, 5, 0]), np.array([0, -5, 0])), ], ) def test_rotation_matrix_from_vectors_3d(vec_1, vec_2): """Test that calculated rotation matrices align vec1 to vec2.""" rotation_matrix = rotation_matrix_from_vectors_3d(vec_1, vec_2) rotated_1 = rotation_matrix.dot(vec_1) unit_rotated_1 = rotated_1 / np.linalg.norm(rotated_1) unit_vec_2 = vec_2 / np.linalg.norm(vec_2) np.testing.assert_allclose(unit_rotated_1, unit_vec_2) @pytest.mark.parametrize( "line_position, line_direction, plane_position, plane_normal, expected", [ ([0, 0, 1], [0, 0, -1], [0, 0, 0], [0, 0, 1], [0, 0, 0]), ([1, 1, 1], [-1, -1, -1], [0, 0, 0], [0, 0, 1], [0, 0, 0]), ([2, 2, 2], [-1, -1, -1], [1, 1, 1], [0, 0, 1], [1, 1, 1]), ], ) def test_intersect_line_with_plane_3d( line_position, line_direction, plane_position, plane_normal, expected ): """Test that arbitrary line-plane intersections are correctly calculated.""" intersection = intersect_line_with_plane_3d( line_position, line_direction, plane_position, plane_normal ) np.testing.assert_allclose(expected, intersection) def test_intersect_line_with_multiple_planes_3d(): """Test intersecting a ray with multiple planes and getting the intersection with each one. """ line_position = [0, 0, 1] line_direction = [0, 0, -1] plane_positions = [[0, 0, 0], [0, 0, 1]] plane_normals = [[0, 0, 1], [0, 0, 1]] intersections = intersect_line_with_multiple_planes_3d( line_position, line_direction, plane_positions, plane_normals ) expected = np.array([[0, 0, 0], [0, 0, 1]]) np.testing.assert_allclose(intersections, expected) @pytest.mark.parametrize( "point, bounding_box, expected", [ ([5, 5, 5], np.array([[0, 10], [0, 10], [0, 10]]), [5, 5, 5]), ([10, 10, 10], np.array([[0, 10], [0, 10], [0, 10]]), [9, 9, 9]), ([5, 5, 15], np.array([[0, 10], [0, 10], [0, 10]]), [5, 5, 9]), ], ) def test_clamp_point_to_bounding_box(point, bounding_box, expected): """Test that points are correctly clamped to the limits of the data. Note: bounding boxes are calculated from layer extents, points are clamped to the range of valid indices into each dimension. e.g. for a shape (10,) array, data is clamped to the range (0, 9) """ clamped_point = clamp_point_to_bounding_box(point, bounding_box) np.testing.assert_allclose(expected, clamped_point) def test_clamp_multiple_points_to_bounding_box(): """test that an array of points can be clamped to the bbox""" points = np.array([[10, 10, 10], [0, 5, 0], [20, 0, 20]]) bbox = np.array([[0, 25], [0, 10], [3, 25]]) expected_points = np.array([[10, 9, 10], [0, 5, 3], [20, 0, 20]]) clamped_points = clamp_point_to_bounding_box(points, bbox) np.testing.assert_array_equal(clamped_points, expected_points) @pytest.mark.parametrize( 'bounding_box, face_normal, expected', [ (np.array([[5, 10], [10, 20], [20, 30]]), np.array([1, 0, 0]), 10), (np.array([[5, 10], [10, 20], [20, 30]]), np.array([-1, 0, 0]), 5), (np.array([[5, 10], [10, 20], [20, 30]]), np.array([0, 1, 0]), 20), (np.array([[5, 10], [10, 20], [20, 30]]), np.array([0, -1, 0]), 10), (np.array([[5, 10], [10, 20], [20, 30]]), np.array([0, 0, 1]), 30), (np.array([[5, 10], [10, 20], [20, 30]]), np.array([0, 0, -1]), 20), ], ) def test_face_coordinate_from_bounding_box( bounding_box, face_normal, expected ): """Test that the correct face coordinate is calculated. Face coordinate is a float which is the value where a face of a bounding box, defined by a face normal, intersects the axis the normal vector is aligned with. """ face_coordinate = face_coordinate_from_bounding_box( bounding_box, face_normal ) np.testing.assert_allclose(expected, face_coordinate) @pytest.mark.parametrize( 'plane_intercept, plane_normal, line_start, line_direction, expected', [ ( 0, np.array([0, 0, 1]), np.array([0, 0, 1]), np.array([0, 0, 1]), [0, 0, 0], ), ( 10, np.array([0, 0, 1]), np.array([0, 0, 0]), np.array([0, 0, 1]), [0, 0, 10], ), ( 10, np.array([0, 1, 0]), np.array([0, 1, 0]), np.array([0, 1, 0]), [0, 10, 0], ), ( 10, np.array([1, 0, 0]), np.array([1, 0, 0]), np.array([1, 0, 0]), [10, 0, 0], ), ], ) def test_line_with_axis_aligned_plane( plane_intercept, plane_normal, line_start, line_direction, expected ): """Test that intersections between line and axis aligned plane are calculated correctly. """ intersection = intersect_line_with_axis_aligned_plane( plane_intercept, plane_normal, line_start, line_direction ) np.testing.assert_allclose(expected, intersection) def test_bounding_box_to_face_vertices_3d(): """Test that bounding_box_to_face_vertices returns a dictionary of vertices for each face of an axis aligned 3D bounding box. """ bounding_box = np.array([[5, 10], [15, 20], [25, 30]]) face_vertices = bounding_box_to_face_vertices(bounding_box) expected = { 'x_pos': np.array( [[5, 15, 30], [5, 20, 30], [10, 20, 30], [10, 15, 30]] ), 'x_neg': np.array( [[5, 15, 25], [5, 20, 25], [10, 20, 25], [10, 15, 25]] ), 'y_pos': np.array( [[5, 20, 25], [5, 20, 30], [10, 20, 30], [10, 20, 25]] ), 'y_neg': np.array( [[5, 15, 25], [5, 15, 30], [10, 15, 30], [10, 15, 25]] ), 'z_pos': np.array( [[10, 15, 25], [10, 15, 30], [10, 20, 30], [10, 20, 25]] ), 'z_neg': np.array( [[5, 15, 25], [5, 15, 30], [5, 20, 30], [5, 20, 25]] ), } for k in face_vertices: np.testing.assert_allclose(expected[k], face_vertices[k]) def test_bounding_box_to_face_vertices_nd(): """Test that bounding_box_to_face_vertices returns a dictionary of vertices for each face of an axis aligned nD bounding box. """ bounding_box = np.array([[0, 0], [0, 0], [5, 10], [15, 20], [25, 30]]) face_vertices = bounding_box_to_face_vertices(bounding_box) expected = { 'x_pos': np.array( [[5, 15, 30], [5, 20, 30], [10, 20, 30], [10, 15, 30]] ), 'x_neg': np.array( [[5, 15, 25], [5, 20, 25], [10, 20, 25], [10, 15, 25]] ), 'y_pos': np.array( [[5, 20, 25], [5, 20, 30], [10, 20, 30], [10, 20, 25]] ), 'y_neg': np.array( [[5, 15, 25], [5, 15, 30], [10, 15, 30], [10, 15, 25]] ), 'z_pos': np.array( [[10, 15, 25], [10, 15, 30], [10, 20, 30], [10, 20, 25]] ), 'z_neg': np.array( [[5, 15, 25], [5, 15, 30], [5, 20, 30], [5, 20, 25]] ), } for k in face_vertices: np.testing.assert_allclose(expected[k], face_vertices[k]) @pytest.mark.parametrize( 'triangle, expected', [ (np.array([[[-1, -1], [-1, 1], [1, 0]]]), True), (np.array([[[1, 1], [2, 1], [1.5, 2]]]), False), ], ) def test_inside_triangles(triangle, expected): """Test that inside triangles returns an array of True for triangles which contain the origin, False otherwise. """ inside = np.all(inside_triangles(triangle)) assert inside == expected @pytest.mark.parametrize( 'point, quadrilateral, expected', [ ( np.array([0.5, 0.5]), np.array([[0, 0], [0, 1], [1, 1], [0, 1]]), True, ), (np.array([2, 2]), np.array([[0, 0], [0, 1], [1, 0], [1, 1]]), False), ], ) def test_point_in_quadrilateral_2d(point, quadrilateral, expected): """Test that point_in_quadrilateral_2d determines whether a point is inside a quadrilateral. """ inside = point_in_quadrilateral_2d(point, quadrilateral) assert inside == expected @pytest.mark.parametrize( 'click_position, quadrilateral, view_dir, expected', [ ( np.array([0, 0, 0]), np.array([[-1, -1, 0], [-1, 1, 0], [1, 1, 0], [1, -1, 0]]), np.array([0, 0, 1]), True, ), ( np.array([0, 0, 5]), np.array([[-1, -1, 0], [-1, 1, 0], [1, 1, 0], [1, -1, 0]]), np.array([0, 0, 1]), True, ), ( np.array([0, 5, 0]), np.array([[-1, -1, 0], [-1, 1, 0], [1, 1, 0], [1, -1, 0]]), np.array([0, 0, 1]), False, ), ], ) def test_click_in_quadrilateral_3d( click_position, quadrilateral, view_dir, expected ): """Test that click in quadrilateral 3d determines whether the projection of a 3D point onto a plane falls within a 3d quadrilateral projected onto the same plane """ in_quadrilateral = line_in_quadrilateral_3d( click_position, view_dir, quadrilateral ) assert in_quadrilateral == expected @pytest.mark.parametrize( 'click_position, bounding_box, view_dir, expected', [ ( np.array([5, 5, 5]), np.array([[0, 10], [0, 10], [0, 10]]), np.array([0, 0, 1]), ([0, 0, -1], [0, 0, 1]), ), ( np.array([-5, -5, -5]), np.array([[0, 10], [0, 10], [0, 10]]), np.array([0, 0, 1]), (None, None), ), ( np.array([5, 5, 5]), np.array([[0, 10], [0, 10], [0, 10]]), np.array([0, 1, 0]), ([0, -1, 0], [0, 1, 0]), ), ( np.array([5, 5, 5]), np.array([[0, 10], [0, 10], [0, 10]]), np.array([1, 0, 0]), ([-1, 0, 0], [1, 0, 0]), ), ], ) def test_find_front_back_face( click_position, bounding_box, view_dir, expected ): """Test that find front_back face finds the faces of an axis aligned bounding box that a ray intersects with. """ result = find_front_back_face(click_position, bounding_box, view_dir) for idx, item in enumerate(result): if item is not None: np.testing.assert_allclose(item, expected[idx]) else: assert item == expected[idx] @pytest.mark.parametrize( 'line_position, line_direction, bounding_box, face_normal, expected', [ ( np.array([5, 5, 5]), np.array([0, 0, 1]), np.array([[0, 10], [0, 10], [0, 10]]), np.array([0, 0, 1]), np.array([5, 5, 10]), ), ( np.array([5, 5, 5]), np.array([0, 0, 1]), np.array([[0, 10], [0, 10], [0, 10]]), np.array([0, 0, -1]), np.array([5, 5, 0]), ), ( np.array([5, 5, 5]), np.array([0, 1, 0]), np.array([[0, 10], [0, 10], [0, 10]]), np.array([0, 1, 0]), np.array([5, 10, 5]), ), ( np.array([5, 5, 5]), np.array([0, 1, 0]), np.array([[0, 10], [0, 10], [0, 10]]), np.array([0, 1, 0]), np.array([5, 10, 5]), ), ( np.array([5, 5, 5]), np.array([1, 0, 0]), np.array([[0, 10], [0, 10], [0, 10]]), np.array([1, 0, 0]), np.array([10, 5, 5]), ), ], ) def test_intersect_line_with_axis_aligned_bounding_box_3d( line_position, line_direction, bounding_box, face_normal, expected ): """Test that intersections between lines and axis aligned bounding boxes are correctly computed. """ result = intersect_line_with_axis_aligned_bounding_box_3d( line_position, line_direction, bounding_box, face_normal ) np.testing.assert_allclose(expected, result) def test_distance_between_point_and_line_3d(): """Test that distance between points and lines are correctly computed.""" line_position = np.random.random(size=3) line_direction = np.array([0, 0, 1]) # find a point a random distance away on the line point_on_line = line_position + np.random.random(1) * line_direction # find a point a fixed distance from the point in a direction perpendicular # to the line direction. expected_distance = np.random.random(1) point = point_on_line + expected_distance * np.array([0, 1, 0]) # calculate distance and check that it is correct distance = distance_between_point_and_line_3d( point, line_position, line_direction ) np.testing.assert_allclose(distance, expected_distance) def test_line_in_triangles_3d(): line_point = np.array([0, 5, 5]) line_direction = np.array([1, 0, 0]) triangles = np.array( [ [[10, 0, 0], [19, 10, 5], [5, 5, 10]], [[10, 4, 4], [10, 0, 0], [10, 4, 0]], ] ) in_triangle = line_in_triangles_3d(line_point, line_direction, triangles) np.testing.assert_array_equal(in_triangle, [True, False]) @pytest.mark.parametrize( "ray_start,ray_direction,expected_index,expected_position", [ ([0, 1, 1], [1, 0, 0], 0, [3, 1, 1]), ([6, 1, 1], [-1, 0, 0], 1, [5, 1, 1]), ], ) def test_find_nearest_triangle_intersection( ray_start, ray_direction, expected_index, expected_position ): triangles = np.array( [ [[3, 0, 0], [3, 0, 10], [3, 10, 0]], [[5, 0, 0], [5, 0, 10], [5, 10, 0]], [ [2, 50, 50], [2, 50, 100], [2, 100, 50], ], ] ) index, intersection = find_nearest_triangle_intersection( ray_position=ray_start, ray_direction=ray_direction, triangles=triangles, ) assert index == expected_index np.testing.assert_allclose(intersection, expected_position) def test_find_nearest_triangle_intersection_no_intersection(): """Test find_nearest_triangle_intersection() when there is not intersection""" triangles = np.array( [ [[3, 0, 0], [3, 0, 10], [3, 10, 0]], [[5, 0, 0], [5, 0, 10], [5, 10, 0]], [ [2, 50, 50], [2, 50, 100], [2, 100, 50], ], ] ) ray_start = np.array([0, -10, -10]) ray_direction = np.array([-1, 0, 0]) index, intersection = find_nearest_triangle_intersection( ray_position=ray_start, ray_direction=ray_direction, triangles=triangles, ) assert index is None assert intersection is None napari-0.5.0a1/napari/utils/_tests/test_history.py000066400000000000000000000014431437041365600222500ustar00rootroot00000000000000from pathlib import Path from napari.utils.history import ( get_open_history, get_save_history, update_open_history, update_save_history, ) def test_open_history(): open_history = get_open_history() assert len(open_history) == 1 assert str(Path.home()) in open_history def test_update_open_history(tmpdir): new_folder = Path(tmpdir) / "some-file.svg" update_open_history(new_folder) assert str(new_folder.parent) in get_open_history() def test_save_history(): save_history = get_save_history() assert len(save_history) == 1 assert str(Path.home()) in save_history def test_update_save_history(tmpdir): new_folder = Path(tmpdir) / "some-file.svg" update_save_history(new_folder) assert str(new_folder.parent) in get_save_history() napari-0.5.0a1/napari/utils/_tests/test_info.py000066400000000000000000000035241437041365600215040ustar00rootroot00000000000000import subprocess from typing import NamedTuple from napari.utils import info def test_citation_text(): assert isinstance(info.citation_text, str) assert 'doi' in info.citation_text def test_linux_os_name_file(monkeypatch, tmp_path): with open(tmp_path / "os-release", "w") as f_p: f_p.write('PRETTY_NAME="Test text"\n') monkeypatch.setattr(info, "OS_RELEASE_PATH", str(tmp_path / "os-release")) assert info._linux_sys_name() == "Test text" with open(tmp_path / "os-release", "w") as f_p: f_p.write('NAME="Test2"\nVERSION="text"') assert info._linux_sys_name() == "Test2 text" with open(tmp_path / "os-release", "w") as f_p: f_p.write('NAME="Test2"\nVERSION_ID="text2"') assert info._linux_sys_name() == "Test2 text2" with open(tmp_path / "os-release", "w") as f_p: f_p.write('NAME="Test2"\nVERSION="text"\nVERSION_ID="text2"') assert info._linux_sys_name() == "Test2 text" with open(tmp_path / "os-release", "w") as f_p: f_p.write( 'PRETTY_NAME="Test text"\nNAME="Test2"\nVERSION="text"\nVERSION_ID="text2"' ) assert info._linux_sys_name() == "Test text" class _CompletedProcessMock(NamedTuple): stdout: bytes def _lsb_mock(*_args, **_kwargs): return _CompletedProcessMock( stdout=b'Description: Ubuntu Test 20.04\nRelease: 20.04' ) def _lsb_mock2(*_args, **_kwargs): return _CompletedProcessMock( stdout=b'Description: Ubuntu Test\nRelease: 20.05' ) def test_linux_os_name_lsb(monkeypatch, tmp_path): monkeypatch.setattr(info, "OS_RELEASE_PATH", str(tmp_path / "os-release")) monkeypatch.setattr(subprocess, "run", _lsb_mock) assert info._linux_sys_name() == "Ubuntu Test 20.04" monkeypatch.setattr(subprocess, "run", _lsb_mock2) assert info._linux_sys_name() == "Ubuntu Test 20.05" napari-0.5.0a1/napari/utils/_tests/test_interactions.py000066400000000000000000000012731437041365600232520ustar00rootroot00000000000000import pytest from napari.utils.interactions import Shortcut @pytest.mark.parametrize( 'shortcut,reason', [ ('Atl-A', 'Alt misspelled'), ('Ctrl-AA', 'AA makes no sense'), ('BB', 'BB makes no sense'), ], ) def test_shortcut_invalid(shortcut, reason): with pytest.warns(UserWarning): Shortcut(shortcut) # Should be Control-A def test_minus_shortcut(): """ Misc tests minus is properly handled as it is the delimiter """ assert str(Shortcut('-')) == '-' assert str(Shortcut('Control--')).endswith('-') assert str(Shortcut('Shift--')).endswith('-') def test_shortcut_qt(): assert Shortcut('Control-A').qt == 'Ctrl+A' napari-0.5.0a1/napari/utils/_tests/test_key_bindings.py000066400000000000000000000211401437041365600232100ustar00rootroot00000000000000import inspect import time import types from unittest.mock import patch import pytest from app_model.types import KeyCode, KeyMod from napari.utils import key_bindings from napari.utils.key_bindings import ( KeymapHandler, KeymapProvider, _bind_keymap, _bind_user_key, _get_user_keymap, bind_key, ) def test_bind_key(): kb = {} # bind def forty_two(): return 42 bind_key(kb, 'A', forty_two) assert kb == dict(A=forty_two) # overwrite def spam(): return 'SPAM' with pytest.raises(ValueError): bind_key(kb, 'A', spam) bind_key(kb, 'A', spam, overwrite=True) assert kb == dict(A=spam) # unbind bind_key(kb, 'A', None) assert kb == {} # check signature # blocker bind_key(kb, 'A', ...) assert kb == {'A': ...} # catch-all bind_key(kb, ..., ...) assert kb == {'A': ..., ...: ...} # typecheck with pytest.raises(TypeError): bind_key(kb, 'B', 'not a callable') # app-model representation kb = {} bind_key(kb, KeyMod.Shift | KeyCode.KeyA, ...) (key,) = kb.keys() assert key == 'Shift-A' def test_bind_key_decorator(): kb = {} @bind_key(kb, 'A') def foo(): ... assert kb == dict(A=foo) def test_keymap_provider(): class Foo(KeymapProvider): ... assert Foo.class_keymap == {} foo = Foo() assert foo.keymap == {} class Bar(Foo): ... assert Bar.class_keymap == {} assert Bar.class_keymap is not Foo.class_keymap class Baz(KeymapProvider): class_keymap = {'A': ...} assert Baz.class_keymap == {'A': ...} def test_bind_keymap(): class Foo: ... def bar(foo): return foo def baz(foo): return foo keymap = {'A': bar, 'B': baz, 'C': ...} foo = Foo() assert _bind_keymap(keymap, foo) == { 'A': types.MethodType(bar, foo), 'B': types.MethodType(baz, foo), 'C': ..., } class Foo(KeymapProvider): class_keymap = { 'A': lambda x: setattr(x, 'A', ...), 'B': lambda x: setattr(x, 'B', ...), 'C': lambda x: setattr(x, 'C', ...), 'D': ..., } def __init__(self) -> None: self.keymap = { 'B': lambda x: setattr(x, 'B', None), # overwrite 'E': lambda x: setattr(x, 'E', None), # new entry 'C': ..., # blocker } class Bar(KeymapProvider): class_keymap = {'E': lambda x: setattr(x, 'E', 42)} class Baz(Bar): class_keymap = {'F': lambda x: setattr(x, 'F', 16)} def test_handle_single_keymap_provider(): foo = Foo() handler = KeymapHandler() handler.keymap_providers = [foo] assert handler.keymap_chain.maps == [ _get_user_keymap(), _bind_keymap(foo.keymap, foo), _bind_keymap(foo.class_keymap, foo), ] assert handler.active_keymap == { 'A': types.MethodType(foo.class_keymap['A'], foo), 'B': types.MethodType(foo.keymap['B'], foo), 'E': types.MethodType(foo.keymap['E'], foo), } # non-overwritten class keybinding # 'A' in Foo and not foo assert not hasattr(foo, 'A') handler.press_key('A') assert foo.A is ... # keybinding blocker on class # 'D' in Foo and not foo but has no func handler.press_key('D') assert not hasattr(foo, 'D') # non-overwriting instance keybinding # 'E' not in Foo and in foo assert not hasattr(foo, 'E') handler.press_key('E') assert foo.E is None # overwriting instance keybinding # 'B' in Foo and in foo; foo has priority assert not hasattr(foo, 'B') handler.press_key('B') assert foo.B is None # keybinding blocker on instance # 'C' in Foo and in Foo; foo has priority but no func handler.press_key('C') assert not hasattr(foo, 'C') @patch('napari.utils.key_bindings.USER_KEYMAP', new_callable=dict) def test_bind_user_key(keymap_mock): foo = Foo() bar = Bar() handler = KeymapHandler() handler.keymap_providers = [bar, foo] x = 0 @_bind_user_key('D') def abc(): nonlocal x x = 42 assert handler.active_keymap == { 'A': types.MethodType(foo.class_keymap['A'], foo), 'B': types.MethodType(foo.keymap['B'], foo), 'D': abc, 'E': types.MethodType(bar.class_keymap['E'], bar), } handler.press_key('D') assert x == 42 def test_handle_multiple_keymap_providers(): foo = Foo() bar = Bar() handler = KeymapHandler() handler.keymap_providers = [bar, foo] assert handler.keymap_chain.maps == [ _get_user_keymap(), _bind_keymap(bar.keymap, bar), _bind_keymap(bar.class_keymap, bar), _bind_keymap(foo.keymap, foo), _bind_keymap(foo.class_keymap, foo), ] assert handler.active_keymap == { 'A': types.MethodType(foo.class_keymap['A'], foo), 'B': types.MethodType(foo.keymap['B'], foo), 'E': types.MethodType(bar.class_keymap['E'], bar), } # check 'bar' callback # 'E' in bar and foo; bar takes priority assert not hasattr(bar, 'E') handler.press_key('E') assert bar.E == 42 # check 'foo' callback # 'B' not in bar and in foo handler.press_key('B') assert not hasattr(bar, 'B') # catch-all key combo # if key not found in 'bar' keymap; default to this binding def catch_all(x): x.catch_all = True bar.class_keymap[...] = catch_all assert handler.active_keymap == { ...: types.MethodType(catch_all, bar), 'E': types.MethodType(bar.class_keymap['E'], bar), } assert not hasattr(bar, 'catch_all') handler.press_key('Z') assert bar.catch_all is True # empty bar.class_keymap[...] = ... assert handler.active_keymap == { 'E': types.MethodType(bar.class_keymap['E'], bar) } del foo.B handler.press_key('B') assert not hasattr(foo, 'B') def test_inherited_keymap(): baz = Baz() handler = KeymapHandler() handler.keymap_providers = [baz] assert handler.keymap_chain.maps == [ _get_user_keymap(), _bind_keymap(baz.keymap, baz), _bind_keymap(baz.class_keymap, baz), _bind_keymap(Bar.class_keymap, baz), ] assert handler.active_keymap == { 'F': types.MethodType(baz.class_keymap['F'], baz), 'E': types.MethodType(Bar.class_keymap['E'], baz), } def test_handle_on_release_bindings(): def make_42(x): # on press x.SPAM = 42 if False: yield # on release # do nothing, but this will make it a generator function def add_then_subtract(x): # on press x.aliiiens += 3 yield # on release x.aliiiens -= 3 class Baz(KeymapProvider): aliiiens = 0 class_keymap = { KeyCode.Shift: make_42, 'Control-Shift-B': add_then_subtract, } baz = Baz() handler = KeymapHandler() handler.keymap_providers = [baz] # one-statement generator function assert not hasattr(baz, 'SPAM') handler.press_key('Shift') assert baz.SPAM == 42 # two-statement generator function assert baz.aliiiens == 0 handler.press_key('Control-Shift-B') assert baz.aliiiens == 3 handler.release_key('Control-Shift-B') assert baz.aliiiens == 0 # order of modifiers should not matter handler.press_key('Shift-Control-B') assert baz.aliiiens == 3 handler.release_key('B') assert baz.aliiiens == 0 def test_bind_key_method(): class Foo2(KeymapProvider): ... foo = Foo2() # instance binding foo.bind_key('A', lambda: 42) assert foo.keymap['A']() == 42 # class binding @Foo2.bind_key('B') def bar(): return 'SPAM' assert Foo2.class_keymap['B'] is bar def test_bind_key_doc(): doc = inspect.getdoc(bind_key) doc = doc.split('Notes\n-----\n')[-1] assert doc == inspect.getdoc(key_bindings) def test_key_release_callback(monkeypatch): called = False called2 = False monkeypatch.setattr(time, "time", lambda: 1) class Foo(KeymapProvider): ... foo = Foo() handler = KeymapHandler() handler.keymap_providers = [foo] def _call(): nonlocal called2 called2 = True @Foo.bind_key("K") def callback(x): nonlocal called called = True return _call handler.press_key("K") assert called assert not called2 handler.release_key("K") assert not called2 handler.press_key("K") assert called assert not called2 monkeypatch.setattr(time, "time", lambda: 2) handler.release_key("K") assert called2 napari-0.5.0a1/napari/utils/_tests/test_migrations.py000066400000000000000000000011751437041365600227250ustar00rootroot00000000000000import pytest from napari.utils.migrations import rename_argument def test_simple(): @rename_argument("a", "b", "1") def sample_fun(b): return b assert sample_fun(1) == 1 assert sample_fun(b=1) == 1 with pytest.deprecated_call(): assert sample_fun(a=1) == 1 with pytest.raises(ValueError): sample_fun(b=1, a=1) def test_constructor(): class Sample: @rename_argument("a", "b", "1") def __init__(self, b) -> None: self.b = b assert Sample(1).b == 1 assert Sample(b=1).b == 1 with pytest.deprecated_call(): assert Sample(a=1).b == 1 napari-0.5.0a1/napari/utils/_tests/test_misc.py000066400000000000000000000164101437041365600215020ustar00rootroot00000000000000from enum import auto from os.path import abspath, expanduser, sep from pathlib import Path import pytest from napari.utils.misc import ( StringEnum, _is_array_type, _quiet_array_equal, abspath_or_url, ensure_iterable, ensure_list_of_layer_data_tuple, ensure_sequence_of_iterables, pick_equality_operator, ) ITERABLE = (0, 1, 2) NESTED_ITERABLE = [ITERABLE, ITERABLE, ITERABLE] DICT = {'a': 1, 'b': 3, 'c': 5} LIST_OF_DICTS = [DICT, DICT, DICT] PARTLY_NESTED_ITERABLE = [ITERABLE, None, None] REPEATED_PARTLY_NESTED_ITERABLE = [PARTLY_NESTED_ITERABLE] * 3 @pytest.mark.parametrize( 'input, expected', [ [ITERABLE, NESTED_ITERABLE], [NESTED_ITERABLE, NESTED_ITERABLE], [(ITERABLE, (2,), (3, 1, 6)), (ITERABLE, (2,), (3, 1, 6))], [DICT, LIST_OF_DICTS], [LIST_OF_DICTS, LIST_OF_DICTS], [(ITERABLE, (2,), (3, 1, 6)), (ITERABLE, (2,), (3, 1, 6))], [None, (None, None, None)], [PARTLY_NESTED_ITERABLE, REPEATED_PARTLY_NESTED_ITERABLE], [[], ([], [], [])], ], ) def test_sequence_of_iterables(input, expected): """Test ensure_sequence_of_iterables returns a sequence of iterables.""" zipped = zip( range(3), ensure_sequence_of_iterables(input, repeat_empty=True), expected, ) for _i, result, expectation in zipped: assert result == expectation def test_sequence_of_iterables_allow_none(): input = [(1, 2), None] assert ensure_sequence_of_iterables(input, allow_none=True) == input def test_sequence_of_iterables_no_repeat_empty(): assert ensure_sequence_of_iterables([], repeat_empty=False) == [] with pytest.raises(ValueError): ensure_sequence_of_iterables([], repeat_empty=False, length=3) def test_sequence_of_iterables_raises(): with pytest.raises(ValueError): # the length argument asserts a specific length ensure_sequence_of_iterables(((0, 1),), length=4) # BEWARE: only the first element of a nested sequence is checked. with pytest.raises(AssertionError): iterable = (None, (0, 1), (0, 2)) result = iter(ensure_sequence_of_iterables(iterable)) assert next(result) is None @pytest.mark.parametrize( 'input, expected', [ [ITERABLE, ITERABLE], [DICT, DICT], [1, [1, 1, 1]], ['foo', ['foo', 'foo', 'foo']], [None, [None, None, None]], ], ) def test_ensure_iterable(input, expected): """Test test_ensure_iterable returns an iterable.""" zipped = zip(range(3), ensure_iterable(input), expected) for _i, result, expectation in zipped: assert result == expectation def test_string_enum(): # Make a test StringEnum class TestEnum(StringEnum): THING = auto() OTHERTHING = auto() # test setting by value, correct case assert TestEnum('thing') == TestEnum.THING # test setting by value mixed case assert TestEnum('thInG') == TestEnum.THING # test setting by instance of self assert TestEnum(TestEnum.THING) == TestEnum.THING # test setting by name correct case assert TestEnum['THING'] == TestEnum.THING # test setting by name mixed case assert TestEnum['tHiNg'] == TestEnum.THING # test setting by value with incorrect value with pytest.raises(ValueError): TestEnum('NotAThing') # test setting by name with incorrect name with pytest.raises(KeyError): TestEnum['NotAThing'] # test creating a StringEnum with the functional API animals = StringEnum('Animal', 'AARDVARK BUFFALO CAT DOG') assert str(animals.AARDVARK) == 'aardvark' assert animals('BUffALO') == animals.BUFFALO assert animals['BUffALO'] == animals.BUFFALO # test setting by instance of self class OtherEnum(StringEnum): SOMETHING = auto() # test setting by instance of a different StringEnum is an error with pytest.raises(ValueError): TestEnum(OtherEnum.SOMETHING) # test string conversion assert str(TestEnum.THING) == 'thing' # test direct comparison with a string assert TestEnum.THING == 'thing' assert 'thing' == TestEnum.THING assert TestEnum.THING != 'notathing' assert 'notathing' != TestEnum.THING # test comparison with another enum with same value names class AnotherTestEnum(StringEnum): THING = auto() ANOTHERTHING = auto() assert TestEnum.THING != AnotherTestEnum.THING # test lookup in a set assert TestEnum.THING in {TestEnum.THING, TestEnum.OTHERTHING} assert TestEnum.THING not in {TestEnum.OTHERTHING} assert TestEnum.THING in {'thing', TestEnum.OTHERTHING} assert TestEnum.THING not in { AnotherTestEnum.THING, AnotherTestEnum.ANOTHERTHING, } def test_abspath_or_url(): relpath = "~" + sep + "something" assert abspath_or_url(relpath) == expanduser(relpath) assert abspath_or_url('something') == abspath('something') assert abspath_or_url(sep + 'something') == abspath(sep + 'something') assert abspath_or_url('https://something') == 'https://something' assert abspath_or_url('http://something') == 'http://something' assert abspath_or_url('ftp://something') == 'ftp://something' assert abspath_or_url('s3://something') == 's3://something' assert abspath_or_url('file://something') == 'file://something' with pytest.raises(TypeError): abspath_or_url({'a', '~'}) def test_type_stable(): assert isinstance(abspath_or_url('~'), str) assert isinstance(abspath_or_url(Path('~')), Path) def test_equality_operator(): import operator import dask.array as da import numpy as np import xarray as xr import zarr class MyNPArray(np.ndarray): pass assert pick_equality_operator(np.ones((1, 1))) == _quiet_array_equal assert pick_equality_operator(MyNPArray([1, 1])) == _quiet_array_equal assert pick_equality_operator(da.ones((1, 1))) == operator.is_ assert pick_equality_operator(zarr.ones((1, 1))) == operator.is_ assert ( pick_equality_operator(xr.DataArray(np.ones((1, 1)))) == _quiet_array_equal ) eq = pick_equality_operator(np.asarray([])) # make sure this doesn't warn assert not eq(np.asarray([]), np.asarray([], '>> def test_adding_shapes(make_napari_viewer): ... viewer = make_napari_viewer() ... viewer.add_shapes() ... assert len(viewer.layers) == 1 >>> def test_something_with_plugins(make_napari_viewer): ... viewer = make_napari_viewer(block_plugin_discovery=False) >>> def test_something_with_strict_qt_tests(make_napari_viewer): ... viewer = make_napari_viewer(strict_qt=True) """ from qtpy.QtWidgets import QApplication from napari import Viewer from napari._qt.qt_viewer import QtViewer from napari.settings import get_settings global GCPASS GCPASS += 1 if GCPASS % 50 == 0: gc.collect() else: gc.collect(1) _do_not_inline_below = len(QtViewer._instances) # # do not inline to avoid pytest trying to compute repr of expression. # # it fails if C++ object gone but not Python object. if request.config.getoption(_SAVE_GRAPH_OPNAME): fail_obj_graph(QtViewer) QtViewer._instances.clear() assert _do_not_inline_below == 0, ( "Some instance of QtViewer is not properly cleaned in one of previous test. For easier debug one may " f"use {_SAVE_GRAPH_OPNAME} flag for pytest to get graph of leaked objects. If you use qtbot (from pytest-qt)" " to clean Qt objects after test you may need to switch to manual clean using " "`deleteLater()` and `qtbot.wait(50)` later." ) settings = get_settings() settings.reset() viewers: WeakSet[Viewer] = WeakSet() # may be overridden by using `make_napari_viewer(strict=True)` _strict = False initial = QApplication.topLevelWidgets() prior_exception = getattr(sys, 'last_value', None) is_internal_test = request.module.__name__.startswith("napari.") # disable throttling cursor event in tests monkeypatch.setattr( "napari._qt.qt_main_window._QtMainWindow._throttle_cursor_to_status_connection", _empty, ) def actual_factory( *model_args, ViewerClass=Viewer, strict_qt=None, block_plugin_discovery=True, **model_kwargs, ): if strict_qt is None: strict_qt = is_internal_test or os.getenv("NAPARI_STRICT_QT") nonlocal _strict _strict = strict_qt if not block_plugin_discovery: napari_plugin_manager.discovery_blocker.stop() should_show = request.config.getoption("--show-napari-viewer") model_kwargs['show'] = model_kwargs.pop('show', should_show) viewer = ViewerClass(*model_args, **model_kwargs) viewers.add(viewer) return viewer yield actual_factory # Some tests might have the viewer closed, so this call will not be able # to access the window. with suppress(AttributeError): get_settings().reset() # close viewers, but don't saving window settings while closing for viewer in viewers: if hasattr(viewer.window, '_qt_window'): with patch.object( viewer.window._qt_window, '_save_current_window_settings' ): viewer.close() else: viewer.close() if GCPASS % 50 == 0 or len(QtViewer._instances): gc.collect() else: gc.collect(1) if request.config.getoption(_SAVE_GRAPH_OPNAME): fail_obj_graph(QtViewer) if request.node.rep_call.failed: # IF test failed do not check for leaks QtViewer._instances.clear() _do_not_inline_below = len(QtViewer._instances) QtViewer._instances.clear() # clear to prevent fail of next test # do not inline to avoid pytest trying to compute repr of expression. # it fails if C++ object gone but not Python object. assert _do_not_inline_below == 0 # only check for leaked widgets if an exception was raised during the test, # or "strict" mode was used. if _strict and getattr(sys, 'last_value', None) is prior_exception: QApplication.processEvents() leak = set(QApplication.topLevelWidgets()).difference(initial) # still not sure how to clean up some of the remaining vispy # vispy.app.backends._qt.CanvasBackendDesktop widgets... if any(n.__class__.__name__ != 'CanvasBackendDesktop' for n in leak): # just a warning... but this can be converted to test errors # in pytest with `-W error` msg = f"""The following Widgets leaked!: {leak}. Note: If other tests are failing it is likely that widgets will leak as they will be (indirectly) attached to the tracebacks of previous failures. Please only consider this an error if all other tests are passing. """ # Explanation notes on the above: While we are indeed looking at the # difference in sets of widgets between before and after, new object can # still not be garbage collected because of it. # in particular with VisPyCanvas, it looks like if a traceback keeps # contains the type, then instances are still attached to the type. # I'm not too sure why this is the case though. if _strict: raise AssertionError(msg) else: warnings.warn(msg) @pytest.fixture def make_napari_viewer_proxy(make_napari_viewer, monkeypatch): """Fixture that returns a function for creating a napari viewer wrapped in proxy. Use in the same way like `make_napari_viewer` fixture. Parameters ---------- make_napari_viewer : fixture The make_napari_viewer fixture. Returns ------- function A function that creates a napari viewer. """ from napari.utils._proxies import PublicOnlyProxy proxies = [] def actual_factory(*model_args, ensure_main_thread=True, **model_kwargs): monkeypatch.setenv( "NAPARI_ENSURE_PLUGIN_MAIN_THREAD", str(ensure_main_thread) ) viewer = make_napari_viewer(*model_args, **model_kwargs) proxies.append(PublicOnlyProxy(viewer)) return proxies[-1] proxies.clear() yield actual_factory @pytest.fixture def MouseEvent(): """Create a subclass for simulating vispy mouse events. Returns ------- Event : Type A new tuple subclass named Event that can be used to create a NamedTuple object with fields "type" and "is_dragging". """ return collections.namedtuple( 'Event', field_names=[ 'type', 'is_dragging', 'position', 'view_direction', 'dims_displayed', 'dims_point', ], ) napari-0.5.0a1/napari/utils/_tracebacks.py000066400000000000000000000162161437041365600204540ustar00rootroot00000000000000import re from typing import Callable, Dict, Generator import numpy as np from napari.types import ExcInfo def get_tb_formatter() -> Callable[[ExcInfo, bool, str], str]: """Return a formatter callable that uses IPython VerboseTB if available. Imports IPython lazily if available to take advantage of ultratb.VerboseTB. If unavailable, cgitb is used instead, but this function overrides a lot of the hardcoded citgb styles and adds error chaining (for exceptions that result from other exceptions). Returns ------- callable A function that accepts a 3-tuple and a boolean ``(exc_info, as_html)`` and returns a formatted traceback string. The ``exc_info`` tuple is of the ``(type, value, traceback)`` format returned by sys.exc_info(). The ``as_html`` determines whether the traceback is formatted in html or plain text. """ try: import IPython.core.ultratb def format_exc_info( info: ExcInfo, as_html: bool, color='Neutral' ) -> str: # avoids printing the array data # some discussion related to obtaining the current string function # can be found here, https://github.com/numpy/numpy/issues/11266 np.set_string_function( lambda arr: f'{type(arr)} {arr.shape} {arr.dtype}' ) vbtb = IPython.core.ultratb.VerboseTB(color_scheme=color) if as_html: ansi_string = vbtb.text(*info).replace(" ", " ") html = "".join(ansi2html(ansi_string)) html = html.replace("\n", "
") html = ( "" + html + "" ) tb_text = html else: tb_text = vbtb.text(*info) # resets to default behavior np.set_string_function(None) return tb_text except ModuleNotFoundError: import cgitb import traceback # cgitb does not support error chaining... # see https://peps.python.org/pep-3134/#enhanced-reporting # this is a workaround def cgitb_chain(exc: Exception) -> Generator[str, None, None]: """Recurse through exception stack and chain cgitb_html calls.""" if exc.__cause__: yield from cgitb_chain(exc.__cause__) yield ( '

The above exception was ' 'the direct cause of the following exception:
' ) elif exc.__context__: yield from cgitb_chain(exc.__context__) yield ( '

During handling of the ' 'above exception, another exception occurred:
' ) yield cgitb_html(exc) def cgitb_html(exc: Exception) -> str: """Format exception with cgitb.html.""" info = (type(exc), exc, exc.__traceback__) return cgitb.html(info) def format_exc_info(info: ExcInfo, as_html: bool, color=None) -> str: # avoids printing the array data np.set_string_function( lambda arr: f'{type(arr)} {arr.shape} {arr.dtype}' ) if as_html: html = "\n".join(cgitb_chain(info[1])) # cgitb has a lot of hardcoded colors that don't work for us # remove bgcolor, and let theme handle it html = re.sub('bgcolor="#.*"', '', html) # remove superfluous whitespace html = html.replace('
\n', '\n') # but retain it around the bits html = re.sub(r'()', '
\\1
', html) # weird 2-part syntax is a workaround for hard-to-grep text. html = html.replace( "

A problem occurred in a Python script. " "Here is the sequence of", "", ) html = html.replace( "function calls leading up to the error, " "in the order they occurred.

", "
", ) # remove hardcoded fonts html = html.replace('face="helvetica, arial"', "") html = ( "" + html + "" ) tb_text = html else: # if we don't need HTML, just use traceback tb_text = ''.join(traceback.format_exception(*info)) # resets to default behavior np.set_string_function(None) return tb_text return format_exc_info ANSI_STYLES = { 1: {"font_weight": "bold"}, 2: {"font_weight": "lighter"}, 3: {"font_weight": "italic"}, 4: {"text_decoration": "underline"}, 5: {"text_decoration": "blink"}, 6: {"text_decoration": "blink"}, 8: {"visibility": "hidden"}, 9: {"text_decoration": "line-through"}, 30: {"color": "black"}, 31: {"color": "red"}, 32: {"color": "green"}, 33: {"color": "yellow"}, 34: {"color": "blue"}, 35: {"color": "magenta"}, 36: {"color": "cyan"}, 37: {"color": "white"}, } def ansi2html( ansi_string: str, styles: Dict[int, Dict[str, str]] = ANSI_STYLES ) -> Generator[str, None, None]: """Convert ansi string to colored HTML Parameters ---------- ansi_string : str text with ANSI color codes. styles : dict, optional A mapping from ANSI codes to a dict of css kwargs:values, by default ANSI_STYLES Yields ------ str HTML strings that can be joined to form the final html """ previous_end = 0 in_span = False ansi_codes = [] ansi_finder = re.compile("\033\\[" "([\\d;]*)" "([a-zA-z])") for match in ansi_finder.finditer(ansi_string): yield ansi_string[previous_end : match.start()] previous_end = match.end() params, command = match.groups() if command not in "mM": continue try: params = [int(p) for p in params.split(";")] except ValueError: params = [0] for i, v in enumerate(params): if v == 0: params = params[i + 1 :] if in_span: in_span = False yield "" ansi_codes = [] if not params: continue ansi_codes.extend(params) if in_span: yield "" in_span = False if not ansi_codes: continue style = [ "; ".join([f"{k}: {v}" for k, v in styles[k].items()]).strip() for k in ansi_codes if k in styles ] yield '' % "; ".join(style) in_span = True yield ansi_string[previous_end:] if in_span: yield "" in_span = False napari-0.5.0a1/napari/utils/_units.py000066400000000000000000000014161437041365600175100ustar00rootroot00000000000000"""Units utilities.""" from functools import lru_cache # define preferred scale bar values PREFERRED_VALUES = [ 1, 2, 5, 10, 15, 20, 25, 50, 75, 100, 125, 150, 200, 500, 750, ] @lru_cache(maxsize=1) def get_unit_registry(): """Get pint's UnitRegistry. Pint greedily imports many libraries, (including dask, xarray, pandas, and babel) to check for compatibility. Some of those libraries may be slow to import. This accessor function should be used (and only when units are actually necessary) to avoid incurring a large import time penalty. See comment for details: https://github.com/napari/napari/pull/2617#issuecomment-827747792 """ import pint return pint.UnitRegistry() napari-0.5.0a1/napari/utils/action_manager.py000066400000000000000000000352561437041365600211670ustar00rootroot00000000000000from __future__ import annotations import warnings from collections import defaultdict from dataclasses import dataclass from functools import cached_property from inspect import isgeneratorfunction from typing import TYPE_CHECKING, Any, Callable, Dict, List, Optional from napari.utils.events import EmitterGroup from napari.utils.interactions import Shortcut from napari.utils.translations import trans if TYPE_CHECKING: from concurrent.futures import Future from typing import Protocol from napari.utils.key_bindings import KeymapProvider class SignalInstance(Protocol): def connect(self, callback: Callable) -> None: ... class Button(Protocol): clicked: SignalInstance def setToolTip(self, text: str) -> None: ... class ShortcutEvent: name: str shortcut: str tooltip: str @dataclass class Action: command: Callable description: str keymapprovider: KeymapProvider # subclassclass or instance of a subclass repeatable: bool = False @cached_property def injected(self) -> Callable[..., Future]: """command with napari objects injected. This will inject things like the current viewer, or currently selected layer into the commands. See :func:`inject_napari_dependencies` for details. """ from napari._app_model import get_app return get_app().injection_store.inject(self.command) class ActionManager: """ Manage the bindings between buttons; shortcuts, callbacks gui elements... The action manager is aware of the various buttons, keybindings and other elements that may trigger an action and is able to synchronise all of those. Thus when a shortcut is bound; this should be capable of updating the buttons tooltip menus etc to show the shortcuts, descriptions... In most cases this should also allow to bind non existing shortcuts, actions, buttons, in which case they will be bound only once the actions are registered. >>> def callback(qtv, number): ... qtv.dims[number] +=1 >>> action_manager.register_action('bump one', callback, ... 'Add one to dims', ... None) The callback signature is going to be inspected and required globals passed in. """ _actions: Dict[str, Action] def __init__(self) -> None: # map associating a name/id with a Comm self._actions: Dict[str, Action] = {} self._shortcuts: Dict[str, List[str]] = defaultdict(list) self._stack: List[str] = [] self._tooltip_include_action_name = False self.events = EmitterGroup(source=self, shorcut_changed=None) def _debug(self, val): self._tooltip_include_action_name = val def _validate_action_name(self, name): if len(name.split(':')) != 2: raise ValueError( trans._( 'Action names need to be in the form `package:name`, got {name!r}', name=name, deferred=True, ) ) def register_action( self, name: str, command: Callable, description: str, keymapprovider: KeymapProvider, repeatable: bool = False, ): """ Register an action for future usage An action is generally a callback associated with - a name (unique), usually `packagename:name` - a description - A keymap provider (easier for focus and backward compatibility). - a boolean repeatability flag indicating whether it can be auto- repeated (i.e. when a key is held down); defaults to False Actions can then be later bound/unbound from button elements, and shortcuts; and the action manager will take care of modifying the keymap of instances to handle shortcuts; and UI elements to have tooltips with descriptions and shortcuts; Parameters ---------- name : str unique name/id of the command that can be used to refer to this command command : callable take 0, or 1 parameter; if `keymapprovider` is not None, will be called with `keymapprovider` as first parameter. description : str Long string to describe what the command does, will be used in tooltips. keymapprovider : KeymapProvider KeymapProvider class or instance to use to bind the shortcut(s) when registered. This make sure the shortcut is active only when an instance of this is in focus. repeatable : bool a boolean flag indicating whether the action can be autorepeated. Defaults to False. Notes ----- Registering an action, binding buttons and shortcuts can happen in any order and should have the same effect. In particular registering an action can happen later (plugin loading), while user preference (keyboard shortcut), has already been happen. When this is the case, the button and shortcut binding is delayed until an action with the corresponding name is registered. See Also -------- bind_button, bind_shortcut """ self._validate_action_name(name) self._actions[name] = Action( command, description, keymapprovider, repeatable ) self._update_shortcut_bindings(name) def _update_shortcut_bindings(self, name: str): """ Update the key mappable for given action name to trigger the action within the given context and """ if name not in self._actions: return if name not in self._shortcuts: return action = self._actions[name] km_provider: KeymapProvider = action.keymapprovider if hasattr(km_provider, 'bind_key'): for shortcut in self._shortcuts[name]: # NOTE: it would be better if we could bind `self.trigger` here # as it allow the action manager to be a convenient choke point # to monitor all commands (useful for undo/redo, etc...), but # the generator pattern in the keybindings caller makes that # difficult at the moment, since `self.trigger(name)` is not a # generator function (but action.injected is) km_provider.bind_key(shortcut, action.injected, overwrite=True) def bind_button( self, name: str, button: Button, extra_tooltip_text='' ) -> None: """ Bind `button` to trigger Action `name` on click. Parameters ---------- name : str name of the corresponding action in the form ``packagename:name`` button : Button A object providing Button interface (like QPushButton) that, when clicked, should trigger the action. The tooltip will be set to the action description and the corresponding shortcut if available. extra_tooltip_text : str Extra text to add at the end of the tooltip. This is useful to convey more information about this action as the action manager may update the tooltip based on the action name. Notes ----- calling `bind_button` can be done before an action with the corresponding name is registered, in which case the effect will be delayed until the corresponding action is registered. Note: this method cannot be used with generator functions, see https://github.com/napari/napari/issues/4164 for details. """ self._validate_action_name(name) if action := self._actions.get(name): if isgeneratorfunction(action): raise ValueError( trans._( '`bind_button` cannot be used with generator functions', deferred=True, ) ) button.clicked.connect(lambda: self.trigger(name)) if name in self._actions: button.setToolTip( f'{self._build_tooltip(name)} {extra_tooltip_text}' ) def _update_tt(event: ShortcutEvent): if event.name == name: button.setToolTip(f'{event.tooltip} {extra_tooltip_text}') # if it's a QPushbutton, we'll remove it when it gets destroyed until = getattr(button, 'destroyed', None) self.events.shorcut_changed.connect(_update_tt, until=until) def bind_shortcut(self, name: str, shortcut: str) -> None: """ bind shortcut `shortcut` to trigger action `name` Parameters ---------- name : str name of the corresponding action in the form ``packagename:name`` shortcut : str Shortcut to assign to this action use dash as separator. See `Shortcut` for known modifiers. Notes ----- calling `bind_button` can be done before an action with the corresponding name is registered, in which case the effect will be delayed until the corresponding action is registered. """ self._validate_action_name(name) if shortcut in self._shortcuts[name]: return self._shortcuts[name].append(shortcut) self._update_shortcut_bindings(name) self._emit_shortcut_change(name, shortcut) def unbind_shortcut(self, name: str) -> Optional[List[str]]: """ Unbind all shortcuts for a given action name. Parameters ---------- name : str name of the action in the form `packagename:name` to unbind. Returns ------- shortcuts: set of str | None Previously bound shortcuts or None if not such shortcuts was bound, or no such action exists. Warns ----- UserWarning: When trying to unbind an action unknown form the action manager, this warning will be emitted. """ action = self._actions.get(name, None) if action is None: warnings.warn( trans._( "Attempting to unbind an action which does not exists ({name}), this may have no effects. This can happen if your settings are out of date, if you upgraded napari, upgraded or deactivated a plugin, or made a typo in in your custom keybinding.", name=name, ), UserWarning, stacklevel=2, ) shortcuts = self._shortcuts.get(name) if shortcuts: if action and hasattr(action.keymapprovider, 'bind_key'): for shortcut in shortcuts: action.keymapprovider.bind_key(shortcut)(None) del self._shortcuts[name] self._emit_shortcut_change(name) return shortcuts def _emit_shortcut_change(self, name: str, shortcut=''): tt = self._build_tooltip(name) if name in self._actions else '' self.events.shorcut_changed(name=name, shortcut=shortcut, tooltip=tt) def _build_tooltip(self, name: str) -> str: """Build tooltip for action `name`.""" ttip = self._actions[name].description if name in self._shortcuts: jstr = ' ' + trans._p(' or ', 'or') + ' ' shorts = jstr.join(f"{Shortcut(s)}" for s in self._shortcuts[name]) ttip += f' ({shorts})' ttip += f'[{name}]' if self._tooltip_include_action_name else '' return ttip def _get_layer_shortcuts(self, layers) -> dict: """ Get shortcuts filtered by the given layers. Parameters ---------- layers : list of layers Layers to use for shortcuts filtering. Returns ------- dict Dictionary of layers with dictionaries of shortcuts to descriptions. """ layer_shortcuts = {} for layer in layers: layer_shortcuts[layer] = {} for name, shortcuts in self._shortcuts.items(): action = self._actions.get(name, None) if action and layer == action.keymapprovider: for shortcut in shortcuts: layer_shortcuts[layer][ str(shortcut) ] = action.description return layer_shortcuts def _get_provider_actions(self, provider) -> dict: """ Get actions filtered by the given provider. Parameters ---------- provider : KeymapProvider Provider to use for actions filtering. Returns ------- provider_actions: dict Dictionary of names of actions with action values for a provider. """ return { name: action for name, action in self._actions.items() if action and provider == action.keymapprovider } def _get_active_shortcuts(self, active_keymap): """ Get active shortcuts for the given active keymap. Parameters ---------- active_keymap : KeymapProvider The active keymap provider. Returns ------- dict Dictionary of shortcuts to descriptions. """ active_func_names = [i[1].__name__ for i in active_keymap.items()] active_shortcuts = {} for name, shortcuts in self._shortcuts.items(): action = self._actions.get(name, None) if action and action.command.__name__ in active_func_names: for shortcut in shortcuts: active_shortcuts[str(shortcut)] = action.description return active_shortcuts def _get_repeatable_shortcuts(self, active_keymap) -> list: """ Get active, repeatable shortcuts for the given active keymap. Parameters ---------- active_keymap : KeymapProvider The active keymap provider. Returns ------- list List of shortcuts that are repeatable. """ active_func_names = {i[1].__name__ for i in active_keymap.items()} active_repeatable_shortcuts = [] for name, shortcuts in self._shortcuts.items(): action = self._actions.get(name, None) if ( action and action.command.__name__ in active_func_names and action.repeatable ): active_repeatable_shortcuts.extend(shortcuts) return active_repeatable_shortcuts def trigger(self, name: str) -> Any: """Trigger the action `name`.""" return self._actions[name].injected() action_manager = ActionManager() napari-0.5.0a1/napari/utils/color.py000066400000000000000000000100551437041365600173240ustar00rootroot00000000000000"""Contains napari color constants and utilities.""" from typing import Union import numpy as np from napari.utils.colormaps.standardize_color import transform_color class ColorValue(np.ndarray): """A custom pydantic field type for storing one color value. Using this as a field type in a pydantic model means that validation of that field (e.g. on initialization or setting) will automatically use the ``validate`` method to coerce a value to a single color. """ @classmethod def __get_validators__(cls): yield cls.validate @classmethod def validate( cls, value: Union[np.ndarray, list, tuple, str, None] ) -> np.ndarray: """Validates and coerces the given value into an array storing one color. Parameters ---------- value : Union[np.ndarray, list, tuple, str, None] A supported single color value, which must be one of the following. - A supported RGB(A) sequence of floating point values in [0, 1]. - A CSS3 color name: https://www.w3.org/TR/css-color-3/#svg-color - A single character matplotlib color name: https://matplotlib.org/stable/tutorials/colors/colors.html#specifying-colors - An RGB(A) hex code string. Returns ------- np.ndarray An RGBA color vector of floating point values in [0, 1]. Raises ------ ValueError, AttributeError, KeyError If the value is not recognized as a color. Examples -------- Coerce an RGBA array-like. >>> ColorValue.validate([1, 0, 0, 1]) array([1., 0., 0., 1.], dtype=float32) Coerce a CSS3 color name. >>> ColorValue.validate('red') array([1., 0., 0., 1.], dtype=float32) Coerce a matplotlib single character color name. >>> ColorValue.validate('r') array([1., 0., 0., 1.], dtype=float32) Coerce an RGB hex-code. >>> ColorValue.validate('#ff0000') array([1., 0., 0., 1.], dtype=float32) """ return transform_color(value)[0] class ColorArray(np.ndarray): """A custom pydantic field type for storing an array of color values. Using this as a field type in a pydantic model means that validation of that field (e.g. on initialization or setting) will automatically use the ``validate`` method to coerce a value to an array of colors. """ @classmethod def __get_validators__(cls): yield cls.validate @classmethod def validate( cls, value: Union[np.ndarray, list, tuple, None] ) -> np.ndarray: """Validates and coerces the given value into an array storing many colors. Parameters ---------- value : Union[np.ndarray, list, tuple, None] A supported sequence of single color values. See ``ColorValue.validate`` for valid single color values. In general each value should be of the same type, so avoid passing values like ``['red', [0, 0, 1]]``. Returns ------- np.ndarray An array of N colors where each row is an RGBA color vector with floating point values in [0, 1]. Raises ------ ValueError, AttributeError, KeyError If the value is not recognized as an array of colors. Examples -------- Coerce a list of CSS3 color names. >>> ColorArray.validate(['red', 'blue']) array([[1., 0., 0., 1.], [0., 0., 1., 1.]], dtype=float32) Coerce a tuple of matplotlib single character color names. >>> ColorArray.validate(('r', 'b')) array([[1., 0., 0., 1.], [0., 0., 1., 1.]], dtype=float32) """ # Special case an empty supported sequence because transform_color # warns and returns an array containing a default color in that case. if isinstance(value, (np.ndarray, list, tuple)) and len(value) == 0: return np.empty((0, 4), np.float32) return transform_color(value) napari-0.5.0a1/napari/utils/colormaps/000077500000000000000000000000001437041365600176325ustar00rootroot00000000000000napari-0.5.0a1/napari/utils/colormaps/__init__.py000066400000000000000000000015141437041365600217440ustar00rootroot00000000000000from napari.utils.colormaps.colorbars import make_colorbar from napari.utils.colormaps.colormap import Colormap from napari.utils.colormaps.colormap_utils import ( ALL_COLORMAPS, AVAILABLE_COLORMAPS, CYMRGB, INVERSE_COLORMAPS, MAGENTA_GREEN, RGB, SIMPLE_COLORMAPS, ValidColormapArg, color_dict_to_colormap, display_name_to_name, ensure_colormap, label_colormap, low_discrepancy_image, matplotlib_colormaps, ) __all__ = [ "make_colorbar", "Colormap", "ALL_COLORMAPS", "AVAILABLE_COLORMAPS", "CYMRGB", "INVERSE_COLORMAPS", "MAGENTA_GREEN", "RGB", "SIMPLE_COLORMAPS", "ValidColormapArg", "color_dict_to_colormap", "display_name_to_name", "ensure_colormap", "label_colormap", "low_discrepancy_image", "matplotlib_colormaps", ] napari-0.5.0a1/napari/utils/colormaps/_tests/000077500000000000000000000000001437041365600211335ustar00rootroot00000000000000napari-0.5.0a1/napari/utils/colormaps/_tests/__init__.py000066400000000000000000000000001437041365600232320ustar00rootroot00000000000000napari-0.5.0a1/napari/utils/colormaps/_tests/colors_data.py000066400000000000000000000063631437041365600240070ustar00rootroot00000000000000""" This file contains most (all?) permutations of single and dual colors which a user can try to use as an argument to face_color and edge_color in the relevant layers. The idea is to parameterize the tests over these options. Vispy has a few bugs/limitations that we're trying to overcome. First, it doesn't parse lists like [Color('red'), Color('red')]. Second, the color of 'g' and 'green' is different. We're consistent with vispy's behavior ATM, but it might change in a future release. """ import numpy as np from vispy.color import Color, ColorArray # Apparently there are two types of greens - 'g' is represented by a # (0, 1, 0) tuple, while 'green' has an approximate value of # (0, 0.5, 0). This is why these two colors are treated differently # below. REDA = (1.0, 0.0, 0.0, 1.0) RED = (1.0, 0.0, 0.0) REDF = '#ff0000' GREENV = Color('green').rgb[1] GREENA = (0.0, GREENV, 0.0, 1.0) GREEN = (0.0, GREENV, 0.0) GREENF = Color('green').hex REDARR = np.array([[1.0, 0.0, 0.0, 1.0]], dtype=np.float32) GREENARR = np.array([[0.0, GREENV, 0.0, 1.0]], dtype=np.float32) single_color_options = [ RED, GREENA, 'transparent', 'red', 'g', GREENF, '#ffccaa44', REDA, REDARR[0, :3], Color(RED).rgb, Color(GREENF).rgba, ColorArray('red').rgb, ColorArray(GREENA).rgba, ColorArray(GREEN).rgb, ColorArray([GREENA]).rgba, GREENARR, REDF, np.array([GREEN]), np.array([GREENF]), None, ] single_colors_as_array = [ ColorArray(RED).rgba, ColorArray(GREEN).rgba, ColorArray((0.0, 0.0, 0.0, 0.0)).rgba, ColorArray(RED).rgba, ColorArray('#00ff00').rgba, ColorArray(GREEN).rgba, ColorArray('#ffccaa44').rgba, ColorArray(RED).rgba, ColorArray(RED).rgba, ColorArray(RED).rgba, ColorArray(GREEN).rgba, ColorArray(RED).rgba, ColorArray(GREEN).rgba, ColorArray(GREEN).rgba, ColorArray(GREEN).rgba, ColorArray(GREEN).rgba, ColorArray(RED).rgba, ColorArray(GREEN).rgba, ColorArray(GREEN).rgba, np.zeros((1, 4), dtype=np.float32), ] two_color_options = [ ['red', 'red'], ('green', 'red'), ['green', '#ff0000'], ['green', 'g'], ('r' for r in range(2)), ['r', 'r'], np.array(['r', 'r']), np.array([[1, 1, 1, 1], [0, GREENV, 0, 1]]), (None, 'green'), [GREENARR[0, :3], REDARR[0, :3]], ] # Some of the options below are commented out. When the bugs with # vispy described above are resolved, we can uncomment the lines # below as well. two_colors_simple = [ ['red', 'red'], ['green', 'red'], ['green', 'red'], ['green', 'g'], ['red', 'red'], ['red', 'red'], ['red', 'red'], ['white', 'green'], (None, 'green'), ['green', 'red'], ] two_colors_as_array = [ColorArray(color).rgba for color in two_colors_simple] invalid_colors = [ 'rr', 'gf', '#gf9gfg', '#ff00000', '#ff0000ii', (-1, 0.0, 0.0, 0.0), ('a', 1, 1, 1), 4, (3,), (34, 342, 2334, 4343, 32, 0.1, -1), np.array([[1, 1, 1, 1, 1]]), np.array([[[0, 1, 1, 1]]]), ColorArray(['r', 'r']), Color('red'), (REDARR, GREENARR), ] warning_colors = [ np.array([]), np.array(['g', 'g'], dtype=object), [], [[1, 2], [3, 4], [5, 6]], np.array([[10], [10], [10], [10]]), ] napari-0.5.0a1/napari/utils/colormaps/_tests/test_categorical_colormap.py000066400000000000000000000115761437041365600267270ustar00rootroot00000000000000import json from itertools import cycle import numpy as np import pytest from napari.utils.colormaps.categorical_colormap import CategoricalColormap def test_default_categorical_colormap(): cmap = CategoricalColormap() assert cmap.colormap == {} color_cycle = cmap.fallback_color np.testing.assert_almost_equal(color_cycle.values, [[1, 1, 1, 1]]) np.testing.assert_almost_equal(next(color_cycle.cycle), [1, 1, 1, 1]) def test_categorical_colormap_direct(): """Test a categorical colormap with a provided mapping""" colormap = {'hi': np.array([1, 1, 1, 1]), 'hello': np.array([0, 0, 0, 0])} cmap = CategoricalColormap(colormap=colormap) color = cmap.map(['hi']) np.testing.assert_allclose(color, [[1, 1, 1, 1]]) color = cmap.map(['hello']) np.testing.assert_allclose(color, [[0, 0, 0, 0]]) # test that the default fallback color (white) is applied new_color_0 = cmap.map(['not a key']) np.testing.assert_almost_equal(new_color_0, [[1, 1, 1, 1]]) new_cmap = cmap.colormap np.testing.assert_almost_equal(new_cmap['not a key'], [1, 1, 1, 1]) # set a cycle of fallback colors new_fallback_colors = [[1, 0, 0, 1], [0, 1, 0, 1]] cmap.fallback_color = new_fallback_colors new_color_1 = cmap.map(['new_prop 1']) np.testing.assert_almost_equal( np.squeeze(new_color_1), new_fallback_colors[0] ) new_color_2 = cmap.map(['new_prop 2']) np.testing.assert_almost_equal( np.squeeze(new_color_2), new_fallback_colors[1] ) def test_categorical_colormap_cycle(): color_cycle = [[1, 1, 1, 1], [1, 0, 0, 1]] cmap = CategoricalColormap(fallback_color=color_cycle) # verify that no mapping between prop value and color has been set assert cmap.colormap == {} # the values used to create the color cycle can be accessed via fallback color np.testing.assert_almost_equal(cmap.fallback_color.values, color_cycle) # map 2 colors, verify their colors are returned in order colors = cmap.map(['hi', 'hello']) np.testing.assert_almost_equal(colors, color_cycle) # map a third color and verify the colors wrap around third_color = cmap.map(['bonjour']) np.testing.assert_almost_equal(np.squeeze(third_color), color_cycle[0]) def test_categorical_colormap_cycle_as_dict(): color_values = np.array([[1, 1, 1, 1], [1, 0, 0, 1]]) color_cycle = cycle(color_values) fallback_color = {'values': color_values, 'cycle': color_cycle} cmap = CategoricalColormap(fallback_color=fallback_color) # verify that no mapping between prop value and color has been set assert cmap.colormap == {} # the values used to create the color cycle can be accessed via fallback color np.testing.assert_almost_equal(cmap.fallback_color.values, color_values) np.testing.assert_almost_equal( next(cmap.fallback_color.cycle), color_values[0] ) fallback_colors = np.array([[1, 0, 0, 1], [0, 1, 0, 1]]) def test_categorical_colormap_from_array(): cmap = CategoricalColormap.from_array(fallback_colors) np.testing.assert_almost_equal(cmap.fallback_color.values, fallback_colors) color_mapping = { 'typeA': np.array([1, 1, 1, 1]), 'typeB': np.array([1, 0, 0, 1]), } default_fallback_color = np.array([[1, 1, 1, 1]]) @pytest.mark.parametrize( 'params,expected', [ ({'colormap': color_mapping}, (color_mapping, default_fallback_color)), ( {'colormap': color_mapping, 'fallback_color': fallback_colors}, (color_mapping, fallback_colors), ), ({'fallback_color': fallback_colors}, ({}, fallback_colors)), (color_mapping, (color_mapping, default_fallback_color)), ], ) def test_categorical_colormap_from_dict(params, expected): cmap = CategoricalColormap.from_dict(params) np.testing.assert_equal(cmap.colormap, expected[0]) np.testing.assert_almost_equal(cmap.fallback_color.values, expected[1]) def test_categorical_colormap_equality(): color_cycle = [[1, 1, 1, 1], [1, 0, 0, 1]] cmap_1 = CategoricalColormap(fallback_color=color_cycle) cmap_2 = CategoricalColormap(fallback_color=color_cycle) cmap_3 = CategoricalColormap(fallback_color=[[1, 1, 1, 1], [1, 1, 0, 1]]) cmap_4 = CategoricalColormap( colormap={0: np.array([0, 0, 0, 1])}, fallback_color=color_cycle ) assert cmap_1 == cmap_2 assert cmap_1 != cmap_3 assert cmap_1 != cmap_4 # test equality against a different type assert cmap_1 != color_cycle @pytest.mark.parametrize( 'params', [ {'colormap': color_mapping}, {'colormap': color_mapping, 'fallback_color': fallback_colors}, {'fallback_color': fallback_colors}, ], ) def test_categorical_colormap_serialization(params): cmap_1 = CategoricalColormap(**params) cmap_json = cmap_1.json() json_dict = json.loads(cmap_json) cmap_2 = CategoricalColormap(**json_dict) assert cmap_1 == cmap_2 napari-0.5.0a1/napari/utils/colormaps/_tests/test_categorical_colormap_utils.py000066400000000000000000000037231437041365600301420ustar00rootroot00000000000000from itertools import cycle import numpy as np from napari.utils.colormaps.categorical_colormap_utils import ( ColorCycle, compare_colormap_dicts, ) def test_color_cycle(): color_values = np.array([[1, 0, 0, 1], [0, 0, 1, 1]]) color_cycle = cycle(color_values) cc_1 = ColorCycle(values=color_values, cycle=color_cycle) cc_2 = ColorCycle(values=color_values, cycle=color_cycle) np.testing.assert_allclose(cc_1.values, color_values) assert isinstance(cc_1.cycle, cycle) assert cc_1 == cc_2 other_color_values = np.array([[1, 0, 0, 1], [1, 1, 1, 1]]) other_color_cycle = cycle(other_color_values) cc_3 = ColorCycle(values=other_color_values, cycle=other_color_cycle) assert cc_1 != cc_3 # verify that checking equality against another type works assert cc_1 != color_values def test_compare_colormap_dicts(): cmap_dict_1 = { 0: np.array([0, 0, 0, 1]), 1: np.array([1, 1, 1, 1]), 2: np.array([1, 0, 0, 1]), } cmap_dict_2 = { 0: np.array([0, 0, 0, 1]), 1: np.array([1, 1, 1, 1]), 2: np.array([1, 0, 0, 1]), } assert compare_colormap_dicts(cmap_dict_1, cmap_dict_2) # same keys different values cmap_dict_3 = { 0: np.array([1, 1, 1, 1]), 1: np.array([1, 1, 1, 1]), 2: np.array([1, 0, 0, 1]), } assert not compare_colormap_dicts(cmap_dict_1, cmap_dict_3) # different number of keys cmap_dict_4 = { 0: np.array([1, 1, 1, 1]), 1: np.array([1, 1, 1, 1]), } assert not compare_colormap_dicts(cmap_dict_1, cmap_dict_4) assert not compare_colormap_dicts(cmap_dict_3, cmap_dict_4) # same number of keys, but different keys cmap_dict_5 = { 'hi': np.array([1, 1, 1, 1]), 'hello': np.array([1, 1, 1, 1]), 'hallo': np.array([1, 0, 0, 1]), } assert not compare_colormap_dicts(cmap_dict_1, cmap_dict_5) assert not compare_colormap_dicts(cmap_dict_3, cmap_dict_5) napari-0.5.0a1/napari/utils/colormaps/_tests/test_color_to_array.py000066400000000000000000000034251437041365600255660ustar00rootroot00000000000000import numpy as np import pytest from napari.utils.colormaps._tests.colors_data import ( invalid_colors, single_color_options, single_colors_as_array, two_color_options, two_colors_as_array, warning_colors, ) from napari.utils.colormaps.standardize_color import transform_color @pytest.mark.parametrize( "colors, true_colors", zip(single_color_options, single_colors_as_array) ) def test_oned_points(colors, true_colors): np.testing.assert_array_equal(true_colors, transform_color(colors)) def test_warns_but_parses(): """Test collection of colors that raise a warning but do not return a default white color array. """ colors = ['', (43, 3, 3, 3), np.array([[3, 3, 3, 3], [0, 0, 0, 1]])] true_colors = [ np.zeros((1, 4), dtype=np.float32), np.array([[1, 3 / 43, 3 / 43, 3 / 43]], dtype=np.float32), np.array( [[1.0, 1.0, 1.0, 1.0], [0.0, 0.0, 0.0, 1.0]], dtype=np.float32 ), ] with pytest.warns(UserWarning): for true, color in zip(true_colors, colors): np.testing.assert_array_equal(true, transform_color(color)) @pytest.mark.parametrize( "colors, true_colors", zip(two_color_options, two_colors_as_array) ) def test_twod_points(colors, true_colors): np.testing.assert_array_equal(true_colors, transform_color(colors)) @pytest.mark.parametrize("color", invalid_colors) def test_invalid_colors(color): with pytest.raises((ValueError, AttributeError, KeyError)): transform_color(color) @pytest.mark.parametrize("colors", warning_colors) def test_warning_colors(colors): with pytest.warns(UserWarning): np.testing.assert_array_equal( np.ones((max(len(colors), 1), 4), dtype=np.float32), transform_color(colors), ) napari-0.5.0a1/napari/utils/colormaps/_tests/test_colormap.py000066400000000000000000000067571437041365600243770ustar00rootroot00000000000000import numpy as np import pytest from napari.utils.colormaps import Colormap def test_linear_colormap(): """Test a linear colormap.""" colors = np.array([[0, 0, 0, 1], [0, 1, 0, 1], [0, 0, 1, 1]]) cmap = Colormap(colors, name='testing') assert cmap.name == 'testing' assert cmap.interpolation == 'linear' assert len(cmap.controls) == len(colors) np.testing.assert_almost_equal(cmap.colors, colors) np.testing.assert_almost_equal(cmap.map([0.75]), [[0, 0.5, 0.5, 1]]) def test_linear_colormap_with_control_points(): """Test a linear colormap with control points.""" colors = np.array([[0, 0, 0, 1], [0, 1, 0, 1], [0, 0, 1, 1]]) cmap = Colormap(colors, name='testing', controls=[0, 0.75, 1]) assert cmap.name == 'testing' assert cmap.interpolation == 'linear' assert len(cmap.controls) == len(colors) np.testing.assert_almost_equal(cmap.colors, colors) np.testing.assert_almost_equal(cmap.map([0.75]), [[0, 1, 0, 1]]) def test_non_ascending_control_points(): """Test non ascending control points raises an error.""" colors = np.array( [[0, 0, 0, 1], [0, 0.5, 0, 1], [0, 1, 0, 1], [0, 0, 1, 1]] ) with pytest.raises(ValueError): Colormap(colors, name='testing', controls=[0, 0.75, 0.25, 1]) def test_wrong_number_control_points(): """Test wrong number of control points raises an error.""" colors = np.array( [[0, 0, 0, 1], [0, 0.5, 0, 1], [0, 1, 0, 1], [0, 0, 1, 1]] ) with pytest.raises(ValueError): Colormap(colors, name='testing', controls=[0, 0.75, 1]) def test_wrong_start_control_point(): """Test wrong start of control points raises an error.""" colors = np.array([[0, 0, 0, 1], [0, 1, 0, 1], [0, 0, 1, 1]]) with pytest.raises(ValueError): Colormap(colors, name='testing', controls=[0.1, 0.75, 1]) def test_wrong_end_control_point(): """Test wrong end of control points raises an error.""" colors = np.array([[0, 0, 0, 1], [0, 1, 0, 1], [0, 0, 1, 1]]) with pytest.raises(ValueError): Colormap(colors, name='testing', controls=[0, 0.75, 0.9]) def test_binned_colormap(): """Test a binned colormap.""" colors = np.array([[0, 0, 0, 1], [0, 1, 0, 1], [0, 0, 1, 1]]) cmap = Colormap(colors, name='testing', interpolation='zero') assert cmap.name == 'testing' assert cmap.interpolation == 'zero' assert len(cmap.controls) == len(colors) + 1 np.testing.assert_almost_equal(cmap.colors, colors) np.testing.assert_almost_equal(cmap.map([0.4]), [[0, 1, 0, 1]]) def test_binned_colormap_with_control_points(): """Test a binned with control points.""" colors = np.array([[0, 0, 0, 1], [0, 1, 0, 1], [0, 0, 1, 1]]) cmap = Colormap( colors, name='testing', interpolation='zero', controls=[0, 0.2, 0.3, 1], ) assert cmap.name == 'testing' assert cmap.interpolation == 'zero' assert len(cmap.controls) == len(colors) + 1 np.testing.assert_almost_equal(cmap.colors, colors) np.testing.assert_almost_equal(cmap.map([0.4]), [[0, 0, 1, 1]]) def test_colormap_equality(): colors = np.array([[0, 0, 0, 1], [0, 1, 0, 1], [0, 0, 1, 1]]) cmap_1 = Colormap(colors, name='testing', controls=[0, 0.75, 1]) cmap_2 = Colormap(colors, name='testing', controls=[0, 0.75, 1]) cmap_3 = Colormap(colors, name='testing', controls=[0, 0.25, 1]) assert cmap_1 == cmap_2 assert cmap_1 != cmap_3 def test_colormap_recreate(): c_map = Colormap("black") Colormap(**c_map.dict()) napari-0.5.0a1/napari/utils/colormaps/_tests/test_colormaps.py000066400000000000000000000166101437041365600245470ustar00rootroot00000000000000import numpy as np import pytest from vispy.color import Colormap as VispyColormap from napari.utils.colormaps import Colormap from napari.utils.colormaps.colormap_utils import ( _MATPLOTLIB_COLORMAP_NAMES, _VISPY_COLORMAPS_ORIGINAL, _VISPY_COLORMAPS_TRANSLATIONS, AVAILABLE_COLORMAPS, _increment_unnamed_colormap, ensure_colormap, vispy_or_mpl_colormap, ) from napari.utils.colormaps.standardize_color import transform_color from napari.utils.colormaps.vendored import cm @pytest.mark.parametrize("name", list(AVAILABLE_COLORMAPS.keys())) def test_colormap(name): np.random.seed(0) cmap = AVAILABLE_COLORMAPS[name] # Test can map random 0-1 values values = np.random.rand(50) colors = cmap.map(values) assert colors.shape == (len(values), 4) # Create vispy colormap and check current colormaps match vispy # colormap vispy_cmap = VispyColormap(*cmap) vispy_colors = vispy_cmap.map(values) np.testing.assert_almost_equal(colors, vispy_colors, decimal=6) def test_increment_unnamed_colormap(): # test that unnamed colormaps are incremented names = [ '[unnamed colormap 0]', 'existing_colormap', 'perceptually_uniform', '[unnamed colormap 1]', ] assert _increment_unnamed_colormap(names)[0] == '[unnamed colormap 2]' # test that named colormaps are not incremented named_colormap = 'perfect_colormap' assert ( _increment_unnamed_colormap(names, named_colormap)[0] == named_colormap ) def test_can_accept_vispy_colormaps(): """Test that we can accept vispy colormaps.""" colors = np.array([[0, 0, 0, 1], [0, 1, 0, 1], [0, 0, 1, 1]]) vispy_cmap = VispyColormap(colors) cmap = ensure_colormap(vispy_cmap) assert isinstance(cmap, Colormap) np.testing.assert_almost_equal(cmap.colors, colors) def test_can_accept_napari_colormaps(): """Test that we can accept napari colormaps.""" colors = np.array([[0, 0, 0, 1], [0, 1, 0, 1], [0, 0, 1, 1]]) napari_cmap = Colormap(colors) cmap = ensure_colormap(napari_cmap) assert isinstance(cmap, Colormap) np.testing.assert_almost_equal(cmap.colors, colors) def test_can_accept_vispy_colormap_name_tuple(): """Test that we can accept vispy colormap named type.""" colors = np.array([[0, 0, 0, 1], [0, 1, 0, 1], [0, 0, 1, 1]]) vispy_cmap = VispyColormap(colors) cmap = ensure_colormap(('special_name', vispy_cmap)) assert isinstance(cmap, Colormap) np.testing.assert_almost_equal(cmap.colors, colors) assert cmap.name == 'special_name' def test_can_accept_napari_colormap_name_tuple(): """Test that we can accept napari colormap named type.""" colors = np.array([[0, 0, 0, 1], [0, 1, 0, 1], [0, 0, 1, 1]]) napari_cmap = Colormap(colors) cmap = ensure_colormap(('special_name', napari_cmap)) assert isinstance(cmap, Colormap) np.testing.assert_almost_equal(cmap.colors, colors) assert cmap.name == 'special_name' def test_can_accept_named_vispy_colormaps(): """Test that we can accept named vispy colormap.""" cmap = ensure_colormap('red') assert isinstance(cmap, Colormap) assert cmap.name == 'red' def test_can_accept_named_mpl_colormap(): """Test we can accept named mpl colormap""" cmap_name = 'RdYlGn' cmap = ensure_colormap(cmap_name) assert isinstance(cmap, Colormap) assert cmap.name == cmap_name @pytest.mark.filterwarnings("ignore::UserWarning") def test_can_accept_vispy_colormaps_in_dict(): """Test that we can accept vispy colormaps in a dictionary.""" colors_a = np.array([[0, 0, 0, 1], [0, 1, 0, 1], [0, 0, 1, 1]]) colors_b = np.array([[0, 0, 0, 1], [1, 0, 0, 1], [0, 0, 1, 1]]) vispy_cmap_a = VispyColormap(colors_a) vispy_cmap_b = VispyColormap(colors_b) cmap = ensure_colormap({'a': vispy_cmap_a, 'b': vispy_cmap_b}) assert isinstance(cmap, Colormap) np.testing.assert_almost_equal(cmap.colors, colors_a) assert cmap.name == 'a' @pytest.mark.filterwarnings("ignore::UserWarning") def test_can_accept_napari_colormaps_in_dict(): """Test that we can accept vispy colormaps in a dictionary""" colors_a = np.array([[0, 0, 0, 1], [0, 1, 0, 1], [0, 0, 1, 1]]) colors_b = np.array([[0, 0, 0, 1], [1, 0, 0, 1], [0, 0, 1, 1]]) napari_cmap_a = Colormap(colors_a) napari_cmap_b = Colormap(colors_b) cmap = ensure_colormap({'a': napari_cmap_a, 'b': napari_cmap_b}) assert isinstance(cmap, Colormap) np.testing.assert_almost_equal(cmap.colors, colors_a) assert cmap.name == 'a' def test_can_accept_colormap_dict(): """Test that we can accept vispy colormaps in a dictionary""" colors = np.array([[0, 0, 0, 1], [0, 1, 0, 1], [0, 0, 1, 1]]) cmap = ensure_colormap({'colors': colors, 'name': 'special_name'}) assert isinstance(cmap, Colormap) np.testing.assert_almost_equal(cmap.colors, colors) assert cmap.name == 'special_name' def test_can_degrade_gracefully(): """Test that we can degrade gracefully if given something not recognized.""" with pytest.warns(UserWarning): cmap = ensure_colormap(object) assert isinstance(cmap, Colormap) assert cmap.name == 'gray' def test_vispy_colormap_amount(): """ Test that the amount of localized vispy colormap names matches available colormaps. """ for name in _VISPY_COLORMAPS_ORIGINAL: assert name in _VISPY_COLORMAPS_TRANSLATIONS def test_mpl_colormap_exists(): """Test that all localized mpl colormap names exist.""" for name in _MATPLOTLIB_COLORMAP_NAMES: assert getattr(cm, name, None) is not None def test_colormap_error_suggestion(): """ Test that vispy/mpl errors, when using `display_name`, suggest `name`. """ name = '"twilight_shifted"' display_name = 'twilight shifted' with pytest.raises(KeyError) as excinfo: vispy_or_mpl_colormap(display_name) assert name in str(excinfo.value) wrong_name = 'foobar' with pytest.raises(KeyError) as excinfo: vispy_or_mpl_colormap(wrong_name) assert name in str(excinfo.value) np.random.seed(0) _SINGLE_RGBA_COLOR = np.random.rand(4) _SINGLE_RGB_COLOR = _SINGLE_RGBA_COLOR[:3] _SINGLE_COLOR_VARIANTS = ( _SINGLE_RGB_COLOR, _SINGLE_RGBA_COLOR, tuple(_SINGLE_RGB_COLOR), tuple(_SINGLE_RGBA_COLOR), list(_SINGLE_RGB_COLOR), list(_SINGLE_RGBA_COLOR), ) @pytest.mark.parametrize('color', _SINGLE_COLOR_VARIANTS) def test_ensure_colormap_with_single_color(color): """See https://github.com/napari/napari/issues/3141""" colormap = ensure_colormap(color) np.testing.assert_array_equal(colormap.colors[0], [0, 0, 0, 1]) expected_color = transform_color(color)[0] np.testing.assert_array_equal(colormap.colors[-1], expected_color) np.random.seed(0) _MULTI_RGBA_COLORS = np.random.rand(5, 4) _MULTI_RGB_COLORS = _MULTI_RGBA_COLORS[:, :3] _MULTI_COLORS_VARIANTS = ( _MULTI_RGB_COLORS, _MULTI_RGBA_COLORS, tuple(tuple(color) for color in _MULTI_RGB_COLORS), tuple(tuple(color) for color in _MULTI_RGBA_COLORS), list(list(color) for color in _MULTI_RGB_COLORS), list(list(color) for color in _MULTI_RGBA_COLORS), ) @pytest.mark.parametrize('colors', _MULTI_COLORS_VARIANTS) def test_ensure_colormap_with_multi_colors(colors): """See https://github.com/napari/napari/issues/3141""" colormap = ensure_colormap(colors) expected_colors = transform_color(colors) np.testing.assert_array_equal(colormap.colors, expected_colors) napari-0.5.0a1/napari/utils/colormaps/bop_colors.py000066400000000000000000001453021437041365600223520ustar00rootroot00000000000000"""This module contains the colormap dictionaries for BOP lookup tables taken from https://github.com/cleterrier/ChrisLUTs. To make it compatible with napari's colormap classes, all the values in the colormap are normalized (divide by 255). """ from napari.utils.translations import trans bop_blue = [ [0.0, 0.0, 0.0], [0.0, 0.0, 0.0], [0.0, 0.00392156862745098, 0.00392156862745098], [0.0, 0.00784313725490196, 0.00784313725490196], [0.0, 0.00784313725490196, 0.011764705882352941], [0.0, 0.011764705882352941, 0.01568627450980392], [0.0, 0.01568627450980392, 0.0196078431372549], [0.0, 0.01568627450980392, 0.023529411764705882], [0.00392156862745098, 0.0196078431372549, 0.027450980392156862], [0.00392156862745098, 0.023529411764705882, 0.03137254901960784], [0.00392156862745098, 0.023529411764705882, 0.03529411764705882], [0.00392156862745098, 0.027450980392156862, 0.0392156862745098], [0.00392156862745098, 0.03137254901960784, 0.043137254901960784], [0.00392156862745098, 0.03137254901960784, 0.047058823529411764], [0.00392156862745098, 0.03529411764705882, 0.050980392156862744], [0.00392156862745098, 0.0392156862745098, 0.054901960784313725], [0.00784313725490196, 0.0392156862745098, 0.058823529411764705], [0.00784313725490196, 0.043137254901960784, 0.06274509803921569], [0.00784313725490196, 0.047058823529411764, 0.06666666666666667], [0.00784313725490196, 0.047058823529411764, 0.07058823529411765], [0.00784313725490196, 0.050980392156862744, 0.07450980392156863], [0.00784313725490196, 0.054901960784313725, 0.0784313725490196], [0.00784313725490196, 0.054901960784313725, 0.08235294117647059], [0.00784313725490196, 0.058823529411764705, 0.08627450980392157], [0.011764705882352941, 0.06274509803921569, 0.09019607843137255], [0.011764705882352941, 0.06274509803921569, 0.09411764705882353], [0.011764705882352941, 0.06666666666666667, 0.09803921568627451], [0.011764705882352941, 0.07058823529411765, 0.10196078431372549], [0.011764705882352941, 0.07058823529411765, 0.10588235294117647], [0.011764705882352941, 0.07450980392156863, 0.10980392156862745], [0.011764705882352941, 0.0784313725490196, 0.11372549019607843], [0.011764705882352941, 0.0784313725490196, 0.11764705882352941], [0.01568627450980392, 0.08235294117647059, 0.12156862745098039], [0.01568627450980392, 0.08627450980392157, 0.12156862745098039], [0.01568627450980392, 0.08627450980392157, 0.12549019607843137], [0.01568627450980392, 0.09019607843137255, 0.12941176470588237], [0.01568627450980392, 0.09411764705882353, 0.13333333333333333], [0.01568627450980392, 0.09803921568627451, 0.13725490196078433], [0.01568627450980392, 0.09803921568627451, 0.1411764705882353], [0.01568627450980392, 0.10196078431372549, 0.1450980392156863], [0.0196078431372549, 0.10588235294117647, 0.14901960784313725], [0.0196078431372549, 0.10588235294117647, 0.15294117647058825], [0.0196078431372549, 0.10980392156862745, 0.1568627450980392], [0.0196078431372549, 0.11372549019607843, 0.1607843137254902], [0.0196078431372549, 0.11372549019607843, 0.16470588235294117], [0.0196078431372549, 0.11764705882352941, 0.16862745098039217], [0.0196078431372549, 0.12156862745098039, 0.17254901960784313], [0.0196078431372549, 0.12156862745098039, 0.17647058823529413], [0.023529411764705882, 0.12549019607843137, 0.1803921568627451], [0.023529411764705882, 0.12941176470588237, 0.1843137254901961], [0.023529411764705882, 0.12941176470588237, 0.18823529411764706], [0.023529411764705882, 0.13333333333333333, 0.19215686274509805], [0.023529411764705882, 0.13725490196078433, 0.19607843137254902], [0.023529411764705882, 0.13725490196078433, 0.2], [0.023529411764705882, 0.1411764705882353, 0.20392156862745098], [0.023529411764705882, 0.1450980392156863, 0.20784313725490197], [0.027450980392156862, 0.1450980392156863, 0.21176470588235294], [0.027450980392156862, 0.14901960784313725, 0.21568627450980393], [0.027450980392156862, 0.15294117647058825, 0.2196078431372549], [0.027450980392156862, 0.15294117647058825, 0.2235294117647059], [0.027450980392156862, 0.1568627450980392, 0.22745098039215686], [0.027450980392156862, 0.1607843137254902, 0.23137254901960785], [0.027450980392156862, 0.1607843137254902, 0.23529411764705882], [0.027450980392156862, 0.16470588235294117, 0.23921568627450981], [0.03137254901960784, 0.16862745098039217, 0.24313725490196078], [0.03137254901960784, 0.16862745098039217, 0.24313725490196078], [0.03137254901960784, 0.17254901960784313, 0.24705882352941178], [0.03137254901960784, 0.17647058823529413, 0.25098039215686274], [0.03137254901960784, 0.17647058823529413, 0.2549019607843137], [0.03137254901960784, 0.1803921568627451, 0.25882352941176473], [0.03137254901960784, 0.1843137254901961, 0.2627450980392157], [0.03137254901960784, 0.1843137254901961, 0.26666666666666666], [0.03529411764705882, 0.18823529411764706, 0.27058823529411763], [0.03529411764705882, 0.19215686274509805, 0.27450980392156865], [0.03529411764705882, 0.19607843137254902, 0.2784313725490196], [0.03529411764705882, 0.19607843137254902, 0.2823529411764706], [0.03529411764705882, 0.2, 0.28627450980392155], [0.03529411764705882, 0.20392156862745098, 0.2901960784313726], [0.03529411764705882, 0.20392156862745098, 0.29411764705882354], [0.03529411764705882, 0.20784313725490197, 0.2980392156862745], [0.0392156862745098, 0.21176470588235294, 0.30196078431372547], [0.0392156862745098, 0.21176470588235294, 0.3058823529411765], [0.0392156862745098, 0.21568627450980393, 0.30980392156862746], [0.0392156862745098, 0.2196078431372549, 0.3137254901960784], [0.0392156862745098, 0.2196078431372549, 0.3176470588235294], [0.0392156862745098, 0.2235294117647059, 0.3215686274509804], [0.0392156862745098, 0.22745098039215686, 0.3254901960784314], [0.0392156862745098, 0.22745098039215686, 0.32941176470588235], [0.043137254901960784, 0.23137254901960785, 0.3333333333333333], [0.043137254901960784, 0.23529411764705882, 0.33725490196078434], [0.043137254901960784, 0.23529411764705882, 0.3411764705882353], [0.043137254901960784, 0.23921568627450981, 0.34509803921568627], [0.043137254901960784, 0.24313725490196078, 0.34901960784313724], [0.043137254901960784, 0.24313725490196078, 0.35294117647058826], [0.043137254901960784, 0.24705882352941178, 0.3568627450980392], [0.043137254901960784, 0.25098039215686274, 0.3607843137254902], [0.047058823529411764, 0.25098039215686274, 0.36470588235294116], [0.047058823529411764, 0.2549019607843137, 0.36470588235294116], [0.047058823529411764, 0.25882352941176473, 0.3686274509803922], [0.047058823529411764, 0.25882352941176473, 0.37254901960784315], [0.047058823529411764, 0.2627450980392157, 0.3764705882352941], [0.047058823529411764, 0.26666666666666666, 0.3803921568627451], [0.047058823529411764, 0.26666666666666666, 0.3843137254901961], [0.047058823529411764, 0.27058823529411763, 0.38823529411764707], [0.050980392156862744, 0.27450980392156865, 0.39215686274509803], [0.050980392156862744, 0.27450980392156865, 0.396078431372549], [0.050980392156862744, 0.2784313725490196, 0.4], [0.050980392156862744, 0.2823529411764706, 0.403921568627451], [0.050980392156862744, 0.2823529411764706, 0.40784313725490196], [0.050980392156862744, 0.28627450980392155, 0.4117647058823529], [0.050980392156862744, 0.2901960784313726, 0.41568627450980394], [0.050980392156862744, 0.29411764705882354, 0.4196078431372549], [0.054901960784313725, 0.29411764705882354, 0.4235294117647059], [0.054901960784313725, 0.2980392156862745, 0.42745098039215684], [0.054901960784313725, 0.30196078431372547, 0.43137254901960786], [0.054901960784313725, 0.30196078431372547, 0.43529411764705883], [0.054901960784313725, 0.3058823529411765, 0.4392156862745098], [0.054901960784313725, 0.30980392156862746, 0.44313725490196076], [0.054901960784313725, 0.30980392156862746, 0.4470588235294118], [0.054901960784313725, 0.3137254901960784, 0.45098039215686275], [0.058823529411764705, 0.3176470588235294, 0.4549019607843137], [0.058823529411764705, 0.3176470588235294, 0.4588235294117647], [0.058823529411764705, 0.3215686274509804, 0.4627450980392157], [0.058823529411764705, 0.3254901960784314, 0.4666666666666667], [0.058823529411764705, 0.3254901960784314, 0.47058823529411764], [0.058823529411764705, 0.32941176470588235, 0.4745098039215686], [0.058823529411764705, 0.3333333333333333, 0.47843137254901963], [0.058823529411764705, 0.3333333333333333, 0.4823529411764706], [0.06274509803921569, 0.33725490196078434, 0.48627450980392156], [0.06274509803921569, 0.3411764705882353, 0.48627450980392156], [0.06274509803921569, 0.3411764705882353, 0.49019607843137253], [0.06274509803921569, 0.34509803921568627, 0.49411764705882355], [0.06274509803921569, 0.34901960784313724, 0.4980392156862745], [0.06274509803921569, 0.34901960784313724, 0.5019607843137255], [0.06274509803921569, 0.35294117647058826, 0.5058823529411764], [0.06274509803921569, 0.3568627450980392, 0.5098039215686274], [0.06666666666666667, 0.3568627450980392, 0.5137254901960784], [0.06666666666666667, 0.3607843137254902, 0.5176470588235295], [0.06666666666666667, 0.36470588235294116, 0.5215686274509804], [0.06666666666666667, 0.36470588235294116, 0.5254901960784314], [0.06666666666666667, 0.3686274509803922, 0.5294117647058824], [0.06666666666666667, 0.37254901960784315, 0.5333333333333333], [0.06666666666666667, 0.37254901960784315, 0.5372549019607843], [0.06666666666666667, 0.3764705882352941, 0.5411764705882353], [0.07058823529411765, 0.3803921568627451, 0.5450980392156862], [0.07058823529411765, 0.3803921568627451, 0.5490196078431373], [0.07058823529411765, 0.3843137254901961, 0.5529411764705883], [0.07058823529411765, 0.38823529411764707, 0.5568627450980392], [0.07058823529411765, 0.39215686274509803, 0.5607843137254902], [0.07058823529411765, 0.39215686274509803, 0.5647058823529412], [0.07058823529411765, 0.396078431372549, 0.5686274509803921], [0.07058823529411765, 0.4, 0.5725490196078431], [0.07450980392156863, 0.4, 0.5764705882352941], [0.07450980392156863, 0.403921568627451, 0.5803921568627451], [0.07450980392156863, 0.40784313725490196, 0.5843137254901961], [0.07450980392156863, 0.40784313725490196, 0.5882352941176471], [0.07450980392156863, 0.4117647058823529, 0.592156862745098], [0.07450980392156863, 0.41568627450980394, 0.596078431372549], [0.07450980392156863, 0.41568627450980394, 0.6], [0.07450980392156863, 0.4196078431372549, 0.6039215686274509], [0.0784313725490196, 0.4235294117647059, 0.6078431372549019], [0.0784313725490196, 0.4235294117647059, 0.6078431372549019], [0.0784313725490196, 0.42745098039215684, 0.611764705882353], [0.0784313725490196, 0.43137254901960786, 0.615686274509804], [0.0784313725490196, 0.43137254901960786, 0.6196078431372549], [0.0784313725490196, 0.43529411764705883, 0.6235294117647059], [0.0784313725490196, 0.4392156862745098, 0.6274509803921569], [0.0784313725490196, 0.4392156862745098, 0.6313725490196078], [0.08235294117647059, 0.44313725490196076, 0.6352941176470588], [0.08235294117647059, 0.4470588235294118, 0.6392156862745098], [0.08235294117647059, 0.4470588235294118, 0.6431372549019608], [0.08235294117647059, 0.45098039215686275, 0.6470588235294118], [0.08235294117647059, 0.4549019607843137, 0.6509803921568628], [0.08235294117647059, 0.4549019607843137, 0.6549019607843137], [0.08235294117647059, 0.4588235294117647, 0.6588235294117647], [0.08235294117647059, 0.4627450980392157, 0.6627450980392157], [0.08627450980392157, 0.4627450980392157, 0.6666666666666666], [0.08627450980392157, 0.4666666666666667, 0.6705882352941176], [0.08627450980392157, 0.47058823529411764, 0.6745098039215687], [0.08627450980392157, 0.47058823529411764, 0.6784313725490196], [0.08627450980392157, 0.4745098039215686, 0.6823529411764706], [0.08627450980392157, 0.47843137254901963, 0.6862745098039216], [0.08627450980392157, 0.47843137254901963, 0.6901960784313725], [0.08627450980392157, 0.4823529411764706, 0.6941176470588235], [0.09019607843137255, 0.48627450980392156, 0.6980392156862745], [0.09019607843137255, 0.49019607843137253, 0.7019607843137254], [0.09019607843137255, 0.49019607843137253, 0.7058823529411765], [0.09019607843137255, 0.49411764705882355, 0.7098039215686275], [0.09019607843137255, 0.4980392156862745, 0.7137254901960784], [0.09019607843137255, 0.4980392156862745, 0.7176470588235294], [0.09019607843137255, 0.5019607843137255, 0.7215686274509804], [0.09019607843137255, 0.5058823529411764, 0.7254901960784313], [0.09411764705882353, 0.5058823529411764, 0.7294117647058823], [0.09411764705882353, 0.5098039215686274, 0.7294117647058823], [0.09411764705882353, 0.5137254901960784, 0.7333333333333333], [0.09411764705882353, 0.5137254901960784, 0.7372549019607844], [0.09411764705882353, 0.5176470588235295, 0.7411764705882353], [0.09411764705882353, 0.5215686274509804, 0.7450980392156863], [0.09411764705882353, 0.5215686274509804, 0.7490196078431373], [0.09411764705882353, 0.5254901960784314, 0.7529411764705882], [0.09803921568627451, 0.5294117647058824, 0.7568627450980392], [0.09803921568627451, 0.5294117647058824, 0.7607843137254902], [0.09803921568627451, 0.5333333333333333, 0.7647058823529411], [0.09803921568627451, 0.5372549019607843, 0.7686274509803922], [0.09803921568627451, 0.5372549019607843, 0.7725490196078432], [0.09803921568627451, 0.5411764705882353, 0.7764705882352941], [0.09803921568627451, 0.5450980392156862, 0.7803921568627451], [0.09803921568627451, 0.5450980392156862, 0.7843137254901961], [0.10196078431372549, 0.5490196078431373, 0.788235294117647], [0.10196078431372549, 0.5529411764705883, 0.792156862745098], [0.10196078431372549, 0.5529411764705883, 0.796078431372549], [0.10196078431372549, 0.5568627450980392, 0.8], [0.10196078431372549, 0.5607843137254902, 0.803921568627451], [0.10196078431372549, 0.5607843137254902, 0.807843137254902], [0.10196078431372549, 0.5647058823529412, 0.8117647058823529], [0.10196078431372549, 0.5686274509803921, 0.8156862745098039], [0.10588235294117647, 0.5686274509803921, 0.8196078431372549], [0.10588235294117647, 0.5725490196078431, 0.8235294117647058], [0.10588235294117647, 0.5764705882352941, 0.8274509803921568], [0.10588235294117647, 0.5764705882352941, 0.8313725490196079], [0.10588235294117647, 0.5803921568627451, 0.8352941176470589], [0.10588235294117647, 0.5843137254901961, 0.8392156862745098], [0.10588235294117647, 0.5882352941176471, 0.8431372549019608], [0.10588235294117647, 0.5882352941176471, 0.8470588235294118], [0.10980392156862745, 0.592156862745098, 0.8509803921568627], [0.10980392156862745, 0.596078431372549, 0.8509803921568627], [0.10980392156862745, 0.596078431372549, 0.8549019607843137], [0.10980392156862745, 0.6, 0.8588235294117647], [0.10980392156862745, 0.6039215686274509, 0.8627450980392157], [0.10980392156862745, 0.6039215686274509, 0.8666666666666667], [0.10980392156862745, 0.6078431372549019, 0.8705882352941177], [0.10980392156862745, 0.611764705882353, 0.8745098039215686], [0.11372549019607843, 0.611764705882353, 0.8784313725490196], [0.11372549019607843, 0.615686274509804, 0.8823529411764706], [0.11372549019607843, 0.6196078431372549, 0.8862745098039215], [0.11372549019607843, 0.6196078431372549, 0.8901960784313725], [0.11372549019607843, 0.6235294117647059, 0.8941176470588236], [0.11372549019607843, 0.6274509803921569, 0.8980392156862745], [0.11372549019607843, 0.6274509803921569, 0.9019607843137255], [0.11372549019607843, 0.6313725490196078, 0.9058823529411765], [0.11764705882352941, 0.6352941176470588, 0.9098039215686274], [0.11764705882352941, 0.6352941176470588, 0.9137254901960784], [0.11764705882352941, 0.6392156862745098, 0.9176470588235294], [0.11764705882352941, 0.6431372549019608, 0.9215686274509803], [0.11764705882352941, 0.6431372549019608, 0.9254901960784314], [0.11764705882352941, 0.6470588235294118, 0.9294117647058824], [0.11764705882352941, 0.6509803921568628, 0.9333333333333333], [0.11764705882352941, 0.6509803921568628, 0.9372549019607843], [0.12156862745098039, 0.6549019607843137, 0.9411764705882353], [0.12156862745098039, 0.6588235294117647, 0.9450980392156862], [0.12156862745098039, 0.6588235294117647, 0.9490196078431372], [0.12156862745098039, 0.6627450980392157, 0.9529411764705882], [0.12156862745098039, 0.6666666666666666, 0.9568627450980393], [0.12156862745098039, 0.6666666666666666, 0.9607843137254902], [0.12156862745098039, 0.6705882352941176, 0.9647058823529412], [0.12549019607843137, 0.6784313725490196, 0.9725490196078431], ] bop_orange = [ [0.0, 0.0, 0.0], [0.0, 0.0, 0.0], [0.00392156862745098, 0.00392156862745098, 0.0], [0.00784313725490196, 0.00784313725490196, 0.0], [0.011764705882352941, 0.00784313725490196, 0.0], [0.01568627450980392, 0.011764705882352941, 0.0], [0.0196078431372549, 0.01568627450980392, 0.0], [0.023529411764705882, 0.01568627450980392, 0.0], [0.027450980392156862, 0.0196078431372549, 0.00392156862745098], [0.03137254901960784, 0.023529411764705882, 0.00392156862745098], [0.03529411764705882, 0.023529411764705882, 0.00392156862745098], [0.0392156862745098, 0.027450980392156862, 0.00392156862745098], [0.043137254901960784, 0.03137254901960784, 0.00392156862745098], [0.047058823529411764, 0.03137254901960784, 0.00392156862745098], [0.050980392156862744, 0.03529411764705882, 0.00392156862745098], [0.054901960784313725, 0.0392156862745098, 0.00392156862745098], [0.058823529411764705, 0.0392156862745098, 0.00784313725490196], [0.06274509803921569, 0.043137254901960784, 0.00784313725490196], [0.06666666666666667, 0.047058823529411764, 0.00784313725490196], [0.07058823529411765, 0.047058823529411764, 0.00784313725490196], [0.07450980392156863, 0.050980392156862744, 0.00784313725490196], [0.0784313725490196, 0.054901960784313725, 0.00784313725490196], [0.08235294117647059, 0.054901960784313725, 0.00784313725490196], [0.08627450980392157, 0.058823529411764705, 0.00784313725490196], [0.09019607843137255, 0.06274509803921569, 0.011764705882352941], [0.09411764705882353, 0.06274509803921569, 0.011764705882352941], [0.09803921568627451, 0.06666666666666667, 0.011764705882352941], [0.10196078431372549, 0.07058823529411765, 0.011764705882352941], [0.10588235294117647, 0.07058823529411765, 0.011764705882352941], [0.10980392156862745, 0.07450980392156863, 0.011764705882352941], [0.11372549019607843, 0.0784313725490196, 0.011764705882352941], [0.11764705882352941, 0.0784313725490196, 0.011764705882352941], [0.12156862745098039, 0.08235294117647059, 0.01568627450980392], [0.12156862745098039, 0.08627450980392157, 0.01568627450980392], [0.12549019607843137, 0.08627450980392157, 0.01568627450980392], [0.12941176470588237, 0.09019607843137255, 0.01568627450980392], [0.13333333333333333, 0.09411764705882353, 0.01568627450980392], [0.13725490196078433, 0.09803921568627451, 0.01568627450980392], [0.1411764705882353, 0.09803921568627451, 0.01568627450980392], [0.1450980392156863, 0.10196078431372549, 0.01568627450980392], [0.14901960784313725, 0.10588235294117647, 0.0196078431372549], [0.15294117647058825, 0.10588235294117647, 0.0196078431372549], [0.1568627450980392, 0.10980392156862745, 0.0196078431372549], [0.1607843137254902, 0.11372549019607843, 0.0196078431372549], [0.16470588235294117, 0.11372549019607843, 0.0196078431372549], [0.16862745098039217, 0.11764705882352941, 0.0196078431372549], [0.17254901960784313, 0.12156862745098039, 0.0196078431372549], [0.17647058823529413, 0.12156862745098039, 0.0196078431372549], [0.1803921568627451, 0.12549019607843137, 0.023529411764705882], [0.1843137254901961, 0.12941176470588237, 0.023529411764705882], [0.18823529411764706, 0.12941176470588237, 0.023529411764705882], [0.19215686274509805, 0.13333333333333333, 0.023529411764705882], [0.19607843137254902, 0.13725490196078433, 0.023529411764705882], [0.2, 0.13725490196078433, 0.023529411764705882], [0.20392156862745098, 0.1411764705882353, 0.023529411764705882], [0.20784313725490197, 0.1450980392156863, 0.023529411764705882], [0.21176470588235294, 0.1450980392156863, 0.027450980392156862], [0.21568627450980393, 0.14901960784313725, 0.027450980392156862], [0.2196078431372549, 0.15294117647058825, 0.027450980392156862], [0.2235294117647059, 0.15294117647058825, 0.027450980392156862], [0.22745098039215686, 0.1568627450980392, 0.027450980392156862], [0.23137254901960785, 0.1607843137254902, 0.027450980392156862], [0.23529411764705882, 0.1607843137254902, 0.027450980392156862], [0.23921568627450981, 0.16470588235294117, 0.027450980392156862], [0.24313725490196078, 0.16862745098039217, 0.03137254901960784], [0.24313725490196078, 0.16862745098039217, 0.03137254901960784], [0.24705882352941178, 0.17254901960784313, 0.03137254901960784], [0.25098039215686274, 0.17647058823529413, 0.03137254901960784], [0.2549019607843137, 0.17647058823529413, 0.03137254901960784], [0.25882352941176473, 0.1803921568627451, 0.03137254901960784], [0.2627450980392157, 0.1843137254901961, 0.03137254901960784], [0.26666666666666666, 0.1843137254901961, 0.03137254901960784], [0.27058823529411763, 0.18823529411764706, 0.03529411764705882], [0.27450980392156865, 0.19215686274509805, 0.03529411764705882], [0.2784313725490196, 0.19607843137254902, 0.03529411764705882], [0.2823529411764706, 0.19607843137254902, 0.03529411764705882], [0.28627450980392155, 0.2, 0.03529411764705882], [0.2901960784313726, 0.20392156862745098, 0.03529411764705882], [0.29411764705882354, 0.20392156862745098, 0.03529411764705882], [0.2980392156862745, 0.20784313725490197, 0.03529411764705882], [0.30196078431372547, 0.21176470588235294, 0.0392156862745098], [0.3058823529411765, 0.21176470588235294, 0.0392156862745098], [0.30980392156862746, 0.21568627450980393, 0.0392156862745098], [0.3137254901960784, 0.2196078431372549, 0.0392156862745098], [0.3176470588235294, 0.2196078431372549, 0.0392156862745098], [0.3215686274509804, 0.2235294117647059, 0.0392156862745098], [0.3254901960784314, 0.22745098039215686, 0.0392156862745098], [0.32941176470588235, 0.22745098039215686, 0.0392156862745098], [0.3333333333333333, 0.23137254901960785, 0.043137254901960784], [0.33725490196078434, 0.23529411764705882, 0.043137254901960784], [0.3411764705882353, 0.23529411764705882, 0.043137254901960784], [0.34509803921568627, 0.23921568627450981, 0.043137254901960784], [0.34901960784313724, 0.24313725490196078, 0.043137254901960784], [0.35294117647058826, 0.24313725490196078, 0.043137254901960784], [0.3568627450980392, 0.24705882352941178, 0.043137254901960784], [0.3607843137254902, 0.25098039215686274, 0.043137254901960784], [0.36470588235294116, 0.25098039215686274, 0.047058823529411764], [0.36470588235294116, 0.2549019607843137, 0.047058823529411764], [0.3686274509803922, 0.25882352941176473, 0.047058823529411764], [0.37254901960784315, 0.25882352941176473, 0.047058823529411764], [0.3764705882352941, 0.2627450980392157, 0.047058823529411764], [0.3803921568627451, 0.26666666666666666, 0.047058823529411764], [0.3843137254901961, 0.26666666666666666, 0.047058823529411764], [0.38823529411764707, 0.27058823529411763, 0.047058823529411764], [0.39215686274509803, 0.27450980392156865, 0.050980392156862744], [0.396078431372549, 0.27450980392156865, 0.050980392156862744], [0.4, 0.2784313725490196, 0.050980392156862744], [0.403921568627451, 0.2823529411764706, 0.050980392156862744], [0.40784313725490196, 0.2823529411764706, 0.050980392156862744], [0.4117647058823529, 0.28627450980392155, 0.050980392156862744], [0.41568627450980394, 0.2901960784313726, 0.050980392156862744], [0.4196078431372549, 0.29411764705882354, 0.050980392156862744], [0.4235294117647059, 0.29411764705882354, 0.054901960784313725], [0.42745098039215684, 0.2980392156862745, 0.054901960784313725], [0.43137254901960786, 0.30196078431372547, 0.054901960784313725], [0.43529411764705883, 0.30196078431372547, 0.054901960784313725], [0.4392156862745098, 0.3058823529411765, 0.054901960784313725], [0.44313725490196076, 0.30980392156862746, 0.054901960784313725], [0.4470588235294118, 0.30980392156862746, 0.054901960784313725], [0.45098039215686275, 0.3137254901960784, 0.054901960784313725], [0.4549019607843137, 0.3176470588235294, 0.058823529411764705], [0.4588235294117647, 0.3176470588235294, 0.058823529411764705], [0.4627450980392157, 0.3215686274509804, 0.058823529411764705], [0.4666666666666667, 0.3254901960784314, 0.058823529411764705], [0.47058823529411764, 0.3254901960784314, 0.058823529411764705], [0.4745098039215686, 0.32941176470588235, 0.058823529411764705], [0.47843137254901963, 0.3333333333333333, 0.058823529411764705], [0.4823529411764706, 0.3333333333333333, 0.058823529411764705], [0.48627450980392156, 0.33725490196078434, 0.06274509803921569], [0.48627450980392156, 0.3411764705882353, 0.06274509803921569], [0.49019607843137253, 0.3411764705882353, 0.06274509803921569], [0.49411764705882355, 0.34509803921568627, 0.06274509803921569], [0.4980392156862745, 0.34901960784313724, 0.06274509803921569], [0.5019607843137255, 0.34901960784313724, 0.06274509803921569], [0.5058823529411764, 0.35294117647058826, 0.06274509803921569], [0.5098039215686274, 0.3568627450980392, 0.06274509803921569], [0.5137254901960784, 0.3568627450980392, 0.06666666666666667], [0.5176470588235295, 0.3607843137254902, 0.06666666666666667], [0.5215686274509804, 0.36470588235294116, 0.06666666666666667], [0.5254901960784314, 0.36470588235294116, 0.06666666666666667], [0.5294117647058824, 0.3686274509803922, 0.06666666666666667], [0.5333333333333333, 0.37254901960784315, 0.06666666666666667], [0.5372549019607843, 0.37254901960784315, 0.06666666666666667], [0.5411764705882353, 0.3764705882352941, 0.06666666666666667], [0.5450980392156862, 0.3803921568627451, 0.07058823529411765], [0.5490196078431373, 0.3803921568627451, 0.07058823529411765], [0.5529411764705883, 0.3843137254901961, 0.07058823529411765], [0.5568627450980392, 0.38823529411764707, 0.07058823529411765], [0.5607843137254902, 0.39215686274509803, 0.07058823529411765], [0.5647058823529412, 0.39215686274509803, 0.07058823529411765], [0.5686274509803921, 0.396078431372549, 0.07058823529411765], [0.5725490196078431, 0.4, 0.07058823529411765], [0.5764705882352941, 0.4, 0.07450980392156863], [0.5803921568627451, 0.403921568627451, 0.07450980392156863], [0.5843137254901961, 0.40784313725490196, 0.07450980392156863], [0.5882352941176471, 0.40784313725490196, 0.07450980392156863], [0.592156862745098, 0.4117647058823529, 0.07450980392156863], [0.596078431372549, 0.41568627450980394, 0.07450980392156863], [0.6, 0.41568627450980394, 0.07450980392156863], [0.6039215686274509, 0.4196078431372549, 0.07450980392156863], [0.6078431372549019, 0.4235294117647059, 0.0784313725490196], [0.6078431372549019, 0.4235294117647059, 0.0784313725490196], [0.611764705882353, 0.42745098039215684, 0.0784313725490196], [0.615686274509804, 0.43137254901960786, 0.0784313725490196], [0.6196078431372549, 0.43137254901960786, 0.0784313725490196], [0.6235294117647059, 0.43529411764705883, 0.0784313725490196], [0.6274509803921569, 0.4392156862745098, 0.0784313725490196], [0.6313725490196078, 0.4392156862745098, 0.0784313725490196], [0.6352941176470588, 0.44313725490196076, 0.08235294117647059], [0.6392156862745098, 0.4470588235294118, 0.08235294117647059], [0.6431372549019608, 0.4470588235294118, 0.08235294117647059], [0.6470588235294118, 0.45098039215686275, 0.08235294117647059], [0.6509803921568628, 0.4549019607843137, 0.08235294117647059], [0.6549019607843137, 0.4549019607843137, 0.08235294117647059], [0.6588235294117647, 0.4588235294117647, 0.08235294117647059], [0.6627450980392157, 0.4627450980392157, 0.08235294117647059], [0.6666666666666666, 0.4627450980392157, 0.08627450980392157], [0.6705882352941176, 0.4666666666666667, 0.08627450980392157], [0.6745098039215687, 0.47058823529411764, 0.08627450980392157], [0.6784313725490196, 0.47058823529411764, 0.08627450980392157], [0.6823529411764706, 0.4745098039215686, 0.08627450980392157], [0.6862745098039216, 0.47843137254901963, 0.08627450980392157], [0.6901960784313725, 0.47843137254901963, 0.08627450980392157], [0.6941176470588235, 0.4823529411764706, 0.08627450980392157], [0.6980392156862745, 0.48627450980392156, 0.09019607843137255], [0.7019607843137254, 0.49019607843137253, 0.09019607843137255], [0.7058823529411765, 0.49019607843137253, 0.09019607843137255], [0.7098039215686275, 0.49411764705882355, 0.09019607843137255], [0.7137254901960784, 0.4980392156862745, 0.09019607843137255], [0.7176470588235294, 0.4980392156862745, 0.09019607843137255], [0.7215686274509804, 0.5019607843137255, 0.09019607843137255], [0.7254901960784313, 0.5058823529411764, 0.09019607843137255], [0.7294117647058823, 0.5058823529411764, 0.09411764705882353], [0.7294117647058823, 0.5098039215686274, 0.09411764705882353], [0.7333333333333333, 0.5137254901960784, 0.09411764705882353], [0.7372549019607844, 0.5137254901960784, 0.09411764705882353], [0.7411764705882353, 0.5176470588235295, 0.09411764705882353], [0.7450980392156863, 0.5215686274509804, 0.09411764705882353], [0.7490196078431373, 0.5215686274509804, 0.09411764705882353], [0.7529411764705882, 0.5254901960784314, 0.09411764705882353], [0.7568627450980392, 0.5294117647058824, 0.09803921568627451], [0.7607843137254902, 0.5294117647058824, 0.09803921568627451], [0.7647058823529411, 0.5333333333333333, 0.09803921568627451], [0.7686274509803922, 0.5372549019607843, 0.09803921568627451], [0.7725490196078432, 0.5372549019607843, 0.09803921568627451], [0.7764705882352941, 0.5411764705882353, 0.09803921568627451], [0.7803921568627451, 0.5450980392156862, 0.09803921568627451], [0.7843137254901961, 0.5450980392156862, 0.09803921568627451], [0.788235294117647, 0.5490196078431373, 0.10196078431372549], [0.792156862745098, 0.5529411764705883, 0.10196078431372549], [0.796078431372549, 0.5529411764705883, 0.10196078431372549], [0.8, 0.5568627450980392, 0.10196078431372549], [0.803921568627451, 0.5607843137254902, 0.10196078431372549], [0.807843137254902, 0.5607843137254902, 0.10196078431372549], [0.8117647058823529, 0.5647058823529412, 0.10196078431372549], [0.8156862745098039, 0.5686274509803921, 0.10196078431372549], [0.8196078431372549, 0.5686274509803921, 0.10588235294117647], [0.8235294117647058, 0.5725490196078431, 0.10588235294117647], [0.8274509803921568, 0.5764705882352941, 0.10588235294117647], [0.8313725490196079, 0.5764705882352941, 0.10588235294117647], [0.8352941176470589, 0.5803921568627451, 0.10588235294117647], [0.8392156862745098, 0.5843137254901961, 0.10588235294117647], [0.8431372549019608, 0.5882352941176471, 0.10588235294117647], [0.8470588235294118, 0.5882352941176471, 0.10588235294117647], [0.8509803921568627, 0.592156862745098, 0.10980392156862745], [0.8509803921568627, 0.596078431372549, 0.10980392156862745], [0.8549019607843137, 0.596078431372549, 0.10980392156862745], [0.8588235294117647, 0.6, 0.10980392156862745], [0.8627450980392157, 0.6039215686274509, 0.10980392156862745], [0.8666666666666667, 0.6039215686274509, 0.10980392156862745], [0.8705882352941177, 0.6078431372549019, 0.10980392156862745], [0.8745098039215686, 0.611764705882353, 0.10980392156862745], [0.8784313725490196, 0.611764705882353, 0.11372549019607843], [0.8823529411764706, 0.615686274509804, 0.11372549019607843], [0.8862745098039215, 0.6196078431372549, 0.11372549019607843], [0.8901960784313725, 0.6196078431372549, 0.11372549019607843], [0.8941176470588236, 0.6235294117647059, 0.11372549019607843], [0.8980392156862745, 0.6274509803921569, 0.11372549019607843], [0.9019607843137255, 0.6274509803921569, 0.11372549019607843], [0.9058823529411765, 0.6313725490196078, 0.11372549019607843], [0.9098039215686274, 0.6352941176470588, 0.11764705882352941], [0.9137254901960784, 0.6352941176470588, 0.11764705882352941], [0.9176470588235294, 0.6392156862745098, 0.11764705882352941], [0.9215686274509803, 0.6431372549019608, 0.11764705882352941], [0.9254901960784314, 0.6431372549019608, 0.11764705882352941], [0.9294117647058824, 0.6470588235294118, 0.11764705882352941], [0.9333333333333333, 0.6509803921568628, 0.11764705882352941], [0.9372549019607843, 0.6509803921568628, 0.11764705882352941], [0.9411764705882353, 0.6549019607843137, 0.12156862745098039], [0.9450980392156862, 0.6588235294117647, 0.12156862745098039], [0.9490196078431372, 0.6588235294117647, 0.12156862745098039], [0.9529411764705882, 0.6627450980392157, 0.12156862745098039], [0.9568627450980393, 0.6666666666666666, 0.12156862745098039], [0.9607843137254902, 0.6666666666666666, 0.12156862745098039], [0.9647058823529412, 0.6705882352941176, 0.12156862745098039], [0.9725490196078431, 0.6784313725490196, 0.12549019607843137], ] bop_purple = [ [0.0, 0.0, 0.0], [0.0, 0.0, 0.0], [0.00392156862745098, 0.0, 0.00392156862745098], [0.00392156862745098, 0.0, 0.00392156862745098], [0.00784313725490196, 0.0, 0.00784313725490196], [0.00784313725490196, 0.0, 0.00784313725490196], [0.011764705882352941, 0.0, 0.011764705882352941], [0.01568627450980392, 0.0, 0.01568627450980392], [0.01568627450980392, 0.00392156862745098, 0.01568627450980392], [0.0196078431372549, 0.00392156862745098, 0.0196078431372549], [0.0196078431372549, 0.00392156862745098, 0.0196078431372549], [0.023529411764705882, 0.00392156862745098, 0.023529411764705882], [0.023529411764705882, 0.00392156862745098, 0.023529411764705882], [0.027450980392156862, 0.00392156862745098, 0.027450980392156862], [0.03137254901960784, 0.00392156862745098, 0.03137254901960784], [0.03137254901960784, 0.00392156862745098, 0.03137254901960784], [0.03529411764705882, 0.00784313725490196, 0.03529411764705882], [0.03529411764705882, 0.00784313725490196, 0.03529411764705882], [0.0392156862745098, 0.00784313725490196, 0.0392156862745098], [0.0392156862745098, 0.00784313725490196, 0.0392156862745098], [0.043137254901960784, 0.00784313725490196, 0.043137254901960784], [0.047058823529411764, 0.00784313725490196, 0.047058823529411764], [0.047058823529411764, 0.00784313725490196, 0.047058823529411764], [0.050980392156862744, 0.00784313725490196, 0.050980392156862744], [0.050980392156862744, 0.011764705882352941, 0.050980392156862744], [0.054901960784313725, 0.011764705882352941, 0.054901960784313725], [0.058823529411764705, 0.011764705882352941, 0.058823529411764705], [0.058823529411764705, 0.011764705882352941, 0.058823529411764705], [0.06274509803921569, 0.011764705882352941, 0.06274509803921569], [0.06274509803921569, 0.011764705882352941, 0.06274509803921569], [0.06666666666666667, 0.011764705882352941, 0.06666666666666667], [0.06666666666666667, 0.011764705882352941, 0.06666666666666667], [0.07058823529411765, 0.01568627450980392, 0.07058823529411765], [0.07450980392156863, 0.01568627450980392, 0.07450980392156863], [0.07450980392156863, 0.01568627450980392, 0.07450980392156863], [0.0784313725490196, 0.01568627450980392, 0.0784313725490196], [0.0784313725490196, 0.01568627450980392, 0.0784313725490196], [0.08235294117647059, 0.01568627450980392, 0.08235294117647059], [0.08235294117647059, 0.01568627450980392, 0.08235294117647059], [0.08627450980392157, 0.01568627450980392, 0.08627450980392157], [0.09019607843137255, 0.0196078431372549, 0.09019607843137255], [0.09019607843137255, 0.0196078431372549, 0.09019607843137255], [0.09411764705882353, 0.0196078431372549, 0.09411764705882353], [0.09411764705882353, 0.0196078431372549, 0.09411764705882353], [0.09803921568627451, 0.0196078431372549, 0.09803921568627451], [0.10196078431372549, 0.0196078431372549, 0.10196078431372549], [0.10196078431372549, 0.0196078431372549, 0.10196078431372549], [0.10588235294117647, 0.0196078431372549, 0.10588235294117647], [0.10588235294117647, 0.023529411764705882, 0.10588235294117647], [0.10980392156862745, 0.023529411764705882, 0.10980392156862745], [0.10980392156862745, 0.023529411764705882, 0.10980392156862745], [0.11372549019607843, 0.023529411764705882, 0.11372549019607843], [0.11764705882352941, 0.023529411764705882, 0.11764705882352941], [0.11764705882352941, 0.023529411764705882, 0.11764705882352941], [0.12156862745098039, 0.023529411764705882, 0.12156862745098039], [0.12156862745098039, 0.023529411764705882, 0.12156862745098039], [0.12549019607843137, 0.027450980392156862, 0.12549019607843137], [0.12549019607843137, 0.027450980392156862, 0.12549019607843137], [0.12941176470588237, 0.027450980392156862, 0.12941176470588237], [0.13333333333333333, 0.027450980392156862, 0.13333333333333333], [0.13333333333333333, 0.027450980392156862, 0.13333333333333333], [0.13725490196078433, 0.027450980392156862, 0.13725490196078433], [0.13725490196078433, 0.027450980392156862, 0.13725490196078433], [0.1411764705882353, 0.027450980392156862, 0.1411764705882353], [0.1450980392156863, 0.03137254901960784, 0.1450980392156863], [0.1450980392156863, 0.03137254901960784, 0.1450980392156863], [0.14901960784313725, 0.03137254901960784, 0.14901960784313725], [0.14901960784313725, 0.03137254901960784, 0.14901960784313725], [0.15294117647058825, 0.03137254901960784, 0.15294117647058825], [0.15294117647058825, 0.03137254901960784, 0.15294117647058825], [0.1568627450980392, 0.03137254901960784, 0.1568627450980392], [0.1607843137254902, 0.03137254901960784, 0.1607843137254902], [0.1607843137254902, 0.03529411764705882, 0.1607843137254902], [0.16470588235294117, 0.03529411764705882, 0.16470588235294117], [0.16470588235294117, 0.03529411764705882, 0.16470588235294117], [0.16862745098039217, 0.03529411764705882, 0.16862745098039217], [0.16862745098039217, 0.03529411764705882, 0.16862745098039217], [0.17254901960784313, 0.03529411764705882, 0.17254901960784313], [0.17647058823529413, 0.03529411764705882, 0.17647058823529413], [0.17647058823529413, 0.03529411764705882, 0.17647058823529413], [0.1803921568627451, 0.0392156862745098, 0.1803921568627451], [0.1803921568627451, 0.0392156862745098, 0.1803921568627451], [0.1843137254901961, 0.0392156862745098, 0.1843137254901961], [0.1843137254901961, 0.0392156862745098, 0.1843137254901961], [0.18823529411764706, 0.0392156862745098, 0.18823529411764706], [0.19215686274509805, 0.0392156862745098, 0.19215686274509805], [0.19215686274509805, 0.0392156862745098, 0.19215686274509805], [0.19607843137254902, 0.0392156862745098, 0.19607843137254902], [0.19607843137254902, 0.043137254901960784, 0.19607843137254902], [0.2, 0.043137254901960784, 0.2], [0.20392156862745098, 0.043137254901960784, 0.20392156862745098], [0.20392156862745098, 0.043137254901960784, 0.20392156862745098], [0.20784313725490197, 0.043137254901960784, 0.20784313725490197], [0.20784313725490197, 0.043137254901960784, 0.20784313725490197], [0.21176470588235294, 0.043137254901960784, 0.21176470588235294], [0.21176470588235294, 0.043137254901960784, 0.21176470588235294], [0.21568627450980393, 0.047058823529411764, 0.21568627450980393], [0.2196078431372549, 0.047058823529411764, 0.2196078431372549], [0.2196078431372549, 0.047058823529411764, 0.2196078431372549], [0.2235294117647059, 0.047058823529411764, 0.2235294117647059], [0.2235294117647059, 0.047058823529411764, 0.2235294117647059], [0.22745098039215686, 0.047058823529411764, 0.22745098039215686], [0.22745098039215686, 0.047058823529411764, 0.22745098039215686], [0.23137254901960785, 0.047058823529411764, 0.23137254901960785], [0.23529411764705882, 0.050980392156862744, 0.23529411764705882], [0.23529411764705882, 0.050980392156862744, 0.23529411764705882], [0.23921568627450981, 0.050980392156862744, 0.23921568627450981], [0.23921568627450981, 0.050980392156862744, 0.23921568627450981], [0.24313725490196078, 0.050980392156862744, 0.24313725490196078], [0.24705882352941178, 0.050980392156862744, 0.24705882352941178], [0.24705882352941178, 0.050980392156862744, 0.24705882352941178], [0.25098039215686274, 0.050980392156862744, 0.25098039215686274], [0.25098039215686274, 0.054901960784313725, 0.25098039215686274], [0.2549019607843137, 0.054901960784313725, 0.2549019607843137], [0.2549019607843137, 0.054901960784313725, 0.2549019607843137], [0.25882352941176473, 0.054901960784313725, 0.25882352941176473], [0.2627450980392157, 0.054901960784313725, 0.2627450980392157], [0.2627450980392157, 0.054901960784313725, 0.2627450980392157], [0.26666666666666666, 0.054901960784313725, 0.26666666666666666], [0.26666666666666666, 0.054901960784313725, 0.26666666666666666], [0.27058823529411763, 0.058823529411764705, 0.27058823529411763], [0.27058823529411763, 0.058823529411764705, 0.27058823529411763], [0.27450980392156865, 0.058823529411764705, 0.27450980392156865], [0.2784313725490196, 0.058823529411764705, 0.2784313725490196], [0.2784313725490196, 0.058823529411764705, 0.2784313725490196], [0.2823529411764706, 0.058823529411764705, 0.2823529411764706], [0.2823529411764706, 0.058823529411764705, 0.2823529411764706], [0.28627450980392155, 0.058823529411764705, 0.28627450980392155], [0.2901960784313726, 0.06274509803921569, 0.2901960784313726], [0.2901960784313726, 0.06274509803921569, 0.2901960784313726], [0.29411764705882354, 0.06274509803921569, 0.29411764705882354], [0.29411764705882354, 0.06274509803921569, 0.29411764705882354], [0.2980392156862745, 0.06274509803921569, 0.2980392156862745], [0.2980392156862745, 0.06274509803921569, 0.2980392156862745], [0.30196078431372547, 0.06274509803921569, 0.30196078431372547], [0.3058823529411765, 0.06274509803921569, 0.3058823529411765], [0.3058823529411765, 0.06666666666666667, 0.3058823529411765], [0.30980392156862746, 0.06666666666666667, 0.30980392156862746], [0.30980392156862746, 0.06666666666666667, 0.30980392156862746], [0.3137254901960784, 0.06666666666666667, 0.3137254901960784], [0.3137254901960784, 0.06666666666666667, 0.3137254901960784], [0.3176470588235294, 0.06666666666666667, 0.3176470588235294], [0.3215686274509804, 0.06666666666666667, 0.3215686274509804], [0.3215686274509804, 0.06666666666666667, 0.3215686274509804], [0.3254901960784314, 0.07058823529411765, 0.3254901960784314], [0.3254901960784314, 0.07058823529411765, 0.3254901960784314], [0.32941176470588235, 0.07058823529411765, 0.32941176470588235], [0.32941176470588235, 0.07058823529411765, 0.32941176470588235], [0.3333333333333333, 0.07058823529411765, 0.3333333333333333], [0.33725490196078434, 0.07058823529411765, 0.33725490196078434], [0.33725490196078434, 0.07058823529411765, 0.33725490196078434], [0.3411764705882353, 0.07058823529411765, 0.3411764705882353], [0.3411764705882353, 0.07450980392156863, 0.3411764705882353], [0.34509803921568627, 0.07450980392156863, 0.34509803921568627], [0.34901960784313724, 0.07450980392156863, 0.34901960784313724], [0.34901960784313724, 0.07450980392156863, 0.34901960784313724], [0.35294117647058826, 0.07450980392156863, 0.35294117647058826], [0.35294117647058826, 0.07450980392156863, 0.35294117647058826], [0.3568627450980392, 0.07450980392156863, 0.3568627450980392], [0.3568627450980392, 0.07450980392156863, 0.3568627450980392], [0.3607843137254902, 0.0784313725490196, 0.3607843137254902], [0.36470588235294116, 0.0784313725490196, 0.36470588235294116], [0.36470588235294116, 0.0784313725490196, 0.36470588235294116], [0.3686274509803922, 0.0784313725490196, 0.3686274509803922], [0.3686274509803922, 0.0784313725490196, 0.3686274509803922], [0.37254901960784315, 0.0784313725490196, 0.37254901960784315], [0.37254901960784315, 0.0784313725490196, 0.37254901960784315], [0.3764705882352941, 0.0784313725490196, 0.3764705882352941], [0.3803921568627451, 0.08235294117647059, 0.3803921568627451], [0.3803921568627451, 0.08235294117647059, 0.3803921568627451], [0.3843137254901961, 0.08235294117647059, 0.3843137254901961], [0.3843137254901961, 0.08235294117647059, 0.3843137254901961], [0.38823529411764707, 0.08235294117647059, 0.38823529411764707], [0.39215686274509803, 0.08235294117647059, 0.39215686274509803], [0.39215686274509803, 0.08235294117647059, 0.39215686274509803], [0.396078431372549, 0.08235294117647059, 0.396078431372549], [0.396078431372549, 0.08627450980392157, 0.396078431372549], [0.4, 0.08627450980392157, 0.4], [0.4, 0.08627450980392157, 0.4], [0.403921568627451, 0.08627450980392157, 0.403921568627451], [0.40784313725490196, 0.08627450980392157, 0.40784313725490196], [0.40784313725490196, 0.08627450980392157, 0.40784313725490196], [0.4117647058823529, 0.08627450980392157, 0.4117647058823529], [0.4117647058823529, 0.08627450980392157, 0.4117647058823529], [0.41568627450980394, 0.09019607843137255, 0.41568627450980394], [0.41568627450980394, 0.09019607843137255, 0.41568627450980394], [0.4196078431372549, 0.09019607843137255, 0.4196078431372549], [0.4235294117647059, 0.09019607843137255, 0.4235294117647059], [0.4235294117647059, 0.09019607843137255, 0.4235294117647059], [0.42745098039215684, 0.09019607843137255, 0.42745098039215684], [0.42745098039215684, 0.09019607843137255, 0.42745098039215684], [0.43137254901960786, 0.09019607843137255, 0.43137254901960786], [0.43529411764705883, 0.09411764705882353, 0.43529411764705883], [0.43529411764705883, 0.09411764705882353, 0.43529411764705883], [0.4392156862745098, 0.09411764705882353, 0.4392156862745098], [0.4392156862745098, 0.09411764705882353, 0.4392156862745098], [0.44313725490196076, 0.09411764705882353, 0.44313725490196076], [0.44313725490196076, 0.09411764705882353, 0.44313725490196076], [0.4470588235294118, 0.09411764705882353, 0.4470588235294118], [0.45098039215686275, 0.09411764705882353, 0.45098039215686275], [0.45098039215686275, 0.09803921568627451, 0.45098039215686275], [0.4549019607843137, 0.09803921568627451, 0.4549019607843137], [0.4549019607843137, 0.09803921568627451, 0.4549019607843137], [0.4588235294117647, 0.09803921568627451, 0.4588235294117647], [0.4588235294117647, 0.09803921568627451, 0.4588235294117647], [0.4627450980392157, 0.09803921568627451, 0.4627450980392157], [0.4666666666666667, 0.09803921568627451, 0.4666666666666667], [0.4666666666666667, 0.09803921568627451, 0.4666666666666667], [0.47058823529411764, 0.10196078431372549, 0.47058823529411764], [0.47058823529411764, 0.10196078431372549, 0.47058823529411764], [0.4745098039215686, 0.10196078431372549, 0.4745098039215686], [0.4745098039215686, 0.10196078431372549, 0.4745098039215686], [0.47843137254901963, 0.10196078431372549, 0.47843137254901963], [0.4823529411764706, 0.10196078431372549, 0.4823529411764706], [0.4823529411764706, 0.10196078431372549, 0.4823529411764706], [0.48627450980392156, 0.10196078431372549, 0.48627450980392156], [0.48627450980392156, 0.10588235294117647, 0.48627450980392156], [0.49019607843137253, 0.10588235294117647, 0.49019607843137253], [0.49411764705882355, 0.10588235294117647, 0.49411764705882355], [0.49411764705882355, 0.10588235294117647, 0.49411764705882355], [0.4980392156862745, 0.10588235294117647, 0.4980392156862745], [0.4980392156862745, 0.10588235294117647, 0.4980392156862745], [0.5019607843137255, 0.10588235294117647, 0.5019607843137255], [0.5019607843137255, 0.10588235294117647, 0.5019607843137255], [0.5058823529411764, 0.10980392156862745, 0.5058823529411764], [0.5098039215686274, 0.10980392156862745, 0.5098039215686274], [0.5098039215686274, 0.10980392156862745, 0.5098039215686274], [0.5137254901960784, 0.10980392156862745, 0.5137254901960784], [0.5137254901960784, 0.10980392156862745, 0.5137254901960784], [0.5176470588235295, 0.10980392156862745, 0.5176470588235295], [0.5176470588235295, 0.10980392156862745, 0.5176470588235295], [0.5215686274509804, 0.10980392156862745, 0.5215686274509804], [0.5254901960784314, 0.11372549019607843, 0.5254901960784314], [0.5254901960784314, 0.11372549019607843, 0.5254901960784314], [0.5294117647058824, 0.11372549019607843, 0.5294117647058824], [0.5294117647058824, 0.11372549019607843, 0.5294117647058824], [0.5333333333333333, 0.11372549019607843, 0.5333333333333333], [0.5372549019607843, 0.11372549019607843, 0.5372549019607843], [0.5372549019607843, 0.11372549019607843, 0.5372549019607843], [0.5411764705882353, 0.11372549019607843, 0.5411764705882353], [0.5411764705882353, 0.11764705882352941, 0.5411764705882353], [0.5450980392156862, 0.11764705882352941, 0.5450980392156862], [0.5450980392156862, 0.11764705882352941, 0.5450980392156862], [0.5490196078431373, 0.11764705882352941, 0.5490196078431373], [0.5529411764705883, 0.11764705882352941, 0.5529411764705883], [0.5529411764705883, 0.11764705882352941, 0.5529411764705883], [0.5568627450980392, 0.11764705882352941, 0.5568627450980392], [0.5568627450980392, 0.11764705882352941, 0.5568627450980392], [0.5607843137254902, 0.12156862745098039, 0.5607843137254902], [0.5607843137254902, 0.12156862745098039, 0.5607843137254902], [0.5647058823529412, 0.12156862745098039, 0.5647058823529412], [0.5686274509803921, 0.12156862745098039, 0.5686274509803921], [0.5686274509803921, 0.12156862745098039, 0.5686274509803921], [0.5725490196078431, 0.12156862745098039, 0.5725490196078431], [0.5725490196078431, 0.12156862745098039, 0.5725490196078431], [0.5803921568627451, 0.12549019607843137, 0.5803921568627451], ] bopd = { "bop blue": (trans._("bop blue"), bop_blue), "bop orange": (trans._("bop orange"), bop_orange), "bop purple": (trans._("bop purple"), bop_purple), } napari-0.5.0a1/napari/utils/colormaps/categorical_colormap.py000066400000000000000000000102571437041365600243620ustar00rootroot00000000000000from typing import Any, Dict, Union import numpy as np from napari.utils.color import ColorValue from napari.utils.colormaps.categorical_colormap_utils import ( ColorCycle, compare_colormap_dicts, ) from napari.utils.colormaps.standardize_color import transform_color from napari.utils.events import EventedModel from napari.utils.translations import trans class CategoricalColormap(EventedModel): """Colormap that relates categorical values to colors. Parameters ---------- colormap : Dict[Any, np.ndarray] The mapping between categorical property values and color. fallback_color : ColorCycle The color to be used in the case that a value is mapped that is not in colormap. This can be given as any ColorType and it will be converted to a ColorCycle. An array of the values contained in the ColorCycle.cycle is stored in ColorCycle.values. The default value is a cycle of all white. """ colormap: Dict[Any, ColorValue] = {} fallback_color: ColorCycle = ColorCycle.validate_type('white') def map(self, color_properties: Union[list, np.ndarray]) -> np.ndarray: """Map an array of values to an array of colors Parameters ---------- color_properties : Union[list, np.ndarray] The property values to be converted to colors. Returns ------- colors : np.ndarray An Nx4 color array where N is the number of property values provided. """ if isinstance(color_properties, (list, np.ndarray)): color_properties = np.asarray(color_properties) else: color_properties = np.asarray([color_properties]) # add properties if they are not in the colormap color_cycle_keys = [*self.colormap] props_in_map = np.in1d(color_properties, color_cycle_keys) if not np.all(props_in_map): new_prop_values = color_properties[np.logical_not(props_in_map)] indices_to_add = np.unique(new_prop_values, return_index=True)[1] props_to_add = [ new_prop_values[index] for index in sorted(indices_to_add) ] for prop in props_to_add: new_color = next(self.fallback_color.cycle) self.colormap[prop] = np.squeeze(transform_color(new_color)) # map the colors colors = np.array([self.colormap[x] for x in color_properties]) return colors @classmethod def from_array(cls, fallback_color): return cls(fallback_color=fallback_color) @classmethod def from_dict(cls, params: dict): if ('colormap' in params) or ('fallback_color' in params): if 'colormap' in params: colormap = { k: transform_color(v)[0] for k, v in params['colormap'].items() } else: colormap = {} if 'fallback_color' in params: fallback_color = params['fallback_color'] else: fallback_color = 'white' else: colormap = {k: transform_color(v)[0] for k, v in params.items()} fallback_color = 'white' return cls(colormap=colormap, fallback_color=fallback_color) @classmethod def __get_validators__(cls): yield cls.validate_type @classmethod def validate_type(cls, val): if isinstance(val, cls): return val if isinstance(val, list) or isinstance(val, np.ndarray): return cls.from_array(val) elif isinstance(val, dict): return cls.from_dict(val) else: raise TypeError( trans._( 'colormap should be an array or dict', deferred=True, ) ) def __eq__(self, other): if isinstance(other, CategoricalColormap): if not compare_colormap_dicts(self.colormap, other.colormap): return False if not np.allclose( self.fallback_color.values, other.fallback_color.values ): return False return True else: return False napari-0.5.0a1/napari/utils/colormaps/categorical_colormap_utils.py000066400000000000000000000053631437041365600256040ustar00rootroot00000000000000from dataclasses import dataclass from itertools import cycle from typing import Dict, Union import numpy as np from napari.layers.utils.color_transformations import ( transform_color, transform_color_cycle, ) from napari.utils.translations import trans @dataclass(eq=False) class ColorCycle: """A dataclass to hold a color cycle for the fallback_colors in the CategoricalColormap Attributes ---------- values : np.ndarray The (Nx4) color array of all colors contained in the color cycle. cycle : cycle The cycle object that gives fallback colors. """ values: np.ndarray cycle: cycle @classmethod def __get_validators__(cls): yield cls.validate_type @classmethod def validate_type(cls, val): # turn a generic dict into object if isinstance(val, dict): return _coerce_colorcycle_from_dict(val) elif isinstance(val, ColorCycle): return val else: return _coerce_colorcycle_from_colors(val) def _json_encode(self): return {'values': self.values.tolist()} def __eq__(self, other): if isinstance(other, ColorCycle): eq = np.array_equal(self.values, other.values) else: eq = False return eq def _coerce_colorcycle_from_dict( val: Dict[str, Union[str, list, np.ndarray, cycle]] ) -> ColorCycle: # validate values color_values = val.get('values') if color_values is None: raise ValueError( trans._('ColorCycle requires a values argument', deferred=True) ) transformed_color_values = transform_color(color_values) # validate cycle color_cycle = val.get('cycle') if color_cycle is None: transformed_color_cycle = transform_color_cycle( color_cycle=color_values, elem_name='color_cycle', default="white", )[0] else: transformed_color_cycle = color_cycle return ColorCycle( values=transformed_color_values, cycle=transformed_color_cycle ) def _coerce_colorcycle_from_colors( val: Union[str, list, np.ndarray] ) -> ColorCycle: if isinstance(val, str): val = [val] ( transformed_color_cycle, transformed_color_values, ) = transform_color_cycle( color_cycle=val, elem_name='color_cycle', default="white", ) return ColorCycle( values=transformed_color_values, cycle=transformed_color_cycle ) def compare_colormap_dicts(cmap_1, cmap_2): if len(cmap_1) != len(cmap_2): return False for k, v in cmap_1.items(): if k not in cmap_2: return False if not np.allclose(v, cmap_2[k]): return False return True napari-0.5.0a1/napari/utils/colormaps/colorbars.py000066400000000000000000000015501437041365600221730ustar00rootroot00000000000000import numpy as np def make_colorbar(cmap, size=(18, 28), horizontal=True): """Make a colorbar from a colormap. Parameters ---------- cmap : vispy.color.Colormap Colormap to create colorbar with. size : 2-tuple Shape of colorbar. horizontal : bool If True colobar is oriented horizontal, otherwise it is oriented vertical. Returns ------- cbar : array Array of colorbar in uint8. """ if horizontal: input = np.linspace(0, 1, size[1]) bar = np.tile(np.expand_dims(input, 1), size[0]).transpose((1, 0)) else: input = np.linspace(0, 1, size[0]) bar = np.tile(np.expand_dims(input, 1), size[1]) color_array = cmap.map(bar.ravel()) cbar = color_array.reshape(bar.shape + (4,)) return np.round(255 * cbar).astype(np.uint8).copy(order='C') napari-0.5.0a1/napari/utils/colormaps/colormap.py000066400000000000000000000114451437041365600220250ustar00rootroot00000000000000from enum import Enum from typing import Optional import numpy as np from pydantic import PrivateAttr, validator from napari.utils.color import ColorArray from napari.utils.colormaps.colorbars import make_colorbar from napari.utils.events import EventedModel from napari.utils.events.custom_types import Array from napari.utils.translations import trans class ColormapInterpolationMode(str, Enum): """INTERPOLATION: Interpolation mode for colormaps. Selects an interpolation mode for the colormap. * linear: colors are defined by linear interpolation between colors of neighboring controls points. * zero: colors are defined by the value of the color in the bin between by neighboring controls points. """ LINEAR = 'linear' ZERO = 'zero' class Colormap(EventedModel): """Colormap that relates intensity values to colors. Attributes ---------- colors : array, shape (N, 4) Data used in the colormap. name : str Name of the colormap. display_name : str Display name of the colormap. controls : array, shape (N,) or (N+1,) Control points of the colormap. interpolation : str Colormap interpolation mode, either 'linear' or 'zero'. If 'linear', ncontrols = ncolors (one color per control point). If 'zero', ncontrols = ncolors+1 (one color per bin). """ # fields colors: ColorArray name: str = 'custom' _display_name: Optional[str] = PrivateAttr(None) interpolation: ColormapInterpolationMode = ColormapInterpolationMode.LINEAR controls: Array[np.float32, (-1,)] = None def __init__( self, colors, display_name: Optional[str] = None, **data ) -> None: if display_name is None: display_name = data.get('name', 'custom') super().__init__(colors=colors, **data) self._display_name = display_name # controls validator must be called even if None for correct initialization @validator('controls', pre=True, always=True) def _check_controls(cls, v, values): # If no control points provided generate defaults if v is None or len(v) == 0: n_controls = len(values['colors']) + int( values['interpolation'] == ColormapInterpolationMode.ZERO ) return np.linspace(0, 1, n_controls, dtype=np.float32) # Check control end points are correct if v[0] != 0 or (len(v) > 1 and v[-1] != 1): raise ValueError( trans._( 'Control points must start with 0.0 and end with 1.0. Got {start_control_point} and {end_control_point}', deferred=True, start_control_point=v[0], end_control_point=v[-1], ) ) # Check control points are sorted correctly if not np.array_equal(v, sorted(v)): raise ValueError( trans._( 'Control points need to be sorted in ascending order', deferred=True, ) ) # Check number of control points is correct n_controls_target = len(values['colors']) + int( values['interpolation'] == ColormapInterpolationMode.ZERO ) n_controls = len(v) if n_controls != n_controls_target: raise ValueError( trans._( 'Wrong number of control points provided. Expected {n_controls_target}, got {n_controls}', deferred=True, n_controls_target=n_controls_target, n_controls=n_controls, ) ) return v def __iter__(self): yield from (self.colors, self.controls, self.interpolation) def map(self, values): values = np.atleast_1d(values) if self.interpolation == ColormapInterpolationMode.LINEAR: # One color per control point cols = [ np.interp(values, self.controls, self.colors[:, i]) for i in range(4) ] cols = np.stack(cols, axis=1) elif self.interpolation == ColormapInterpolationMode.ZERO: # One color per bin # Colors beyond max clipped to final bin indices = np.clip( np.searchsorted(self.controls, values, side="right") - 1, 0, len(self.colors) - 1, ) cols = self.colors[indices.astype(np.int32)] else: raise ValueError( trans._( 'Unrecognized Colormap Interpolation Mode', deferred=True, ) ) return cols @property def colorbar(self): return make_colorbar(self) napari-0.5.0a1/napari/utils/colormaps/colormap_utils.py000066400000000000000000000600151437041365600232420ustar00rootroot00000000000000import warnings from collections import OrderedDict from threading import Lock from typing import Dict, List, Optional, Tuple, Union import numpy as np import skimage.color as colorconv from vispy.color import BaseColormap as VispyColormap from vispy.color import Color, ColorArray, get_colormap, get_colormaps from vispy.color.colormap import LUT_len from napari.utils.colormaps.bop_colors import bopd from napari.utils.colormaps.colormap import Colormap, ColormapInterpolationMode from napari.utils.colormaps.inverse_colormaps import inverse_cmaps from napari.utils.colormaps.standardize_color import transform_color from napari.utils.colormaps.vendored import cm from napari.utils.translations import trans # All parsable input color types that a user can provide ColorType = Union[List, Tuple, np.ndarray, str, Color, ColorArray] ValidColormapArg = Union[ str, ColorType, VispyColormap, Colormap, Tuple[str, VispyColormap], Tuple[str, Colormap], Dict[str, VispyColormap], Dict[str, Colormap], Dict, ] matplotlib_colormaps = _MATPLOTLIB_COLORMAP_NAMES = OrderedDict( viridis=trans._p('colormap', 'viridis'), magma=trans._p('colormap', 'magma'), inferno=trans._p('colormap', 'inferno'), plasma=trans._p('colormap', 'plasma'), gray=trans._p('colormap', 'gray'), gray_r=trans._p('colormap', 'gray r'), hsv=trans._p('colormap', 'hsv'), turbo=trans._p('colormap', 'turbo'), twilight=trans._p('colormap', 'twilight'), twilight_shifted=trans._p('colormap', 'twilight shifted'), gist_earth=trans._p('colormap', 'gist earth'), PiYG=trans._p('colormap', 'PiYG'), ) _MATPLOTLIB_COLORMAP_NAMES_REVERSE = { v: k for k, v in matplotlib_colormaps.items() } _VISPY_COLORMAPS_ORIGINAL = _VCO = get_colormaps() _VISPY_COLORMAPS_TRANSLATIONS = OrderedDict( autumn=(trans._p('colormap', 'autumn'), _VCO['autumn']), blues=(trans._p('colormap', 'blues'), _VCO['blues']), cool=(trans._p('colormap', 'cool'), _VCO['cool']), greens=(trans._p('colormap', 'greens'), _VCO['greens']), reds=(trans._p('colormap', 'reds'), _VCO['reds']), spring=(trans._p('colormap', 'spring'), _VCO['spring']), summer=(trans._p('colormap', 'summer'), _VCO['summer']), fire=(trans._p('colormap', 'fire'), _VCO['fire']), grays=(trans._p('colormap', 'grays'), _VCO['grays']), hot=(trans._p('colormap', 'hot'), _VCO['hot']), ice=(trans._p('colormap', 'ice'), _VCO['ice']), winter=(trans._p('colormap', 'winter'), _VCO['winter']), light_blues=(trans._p('colormap', 'light blues'), _VCO['light_blues']), orange=(trans._p('colormap', 'orange'), _VCO['orange']), viridis=(trans._p('colormap', 'viridis'), _VCO['viridis']), coolwarm=(trans._p('colormap', 'coolwarm'), _VCO['coolwarm']), PuGr=(trans._p('colormap', 'PuGr'), _VCO['PuGr']), GrBu=(trans._p('colormap', 'GrBu'), _VCO['GrBu']), GrBu_d=(trans._p('colormap', 'GrBu_d'), _VCO['GrBu_d']), RdBu=(trans._p('colormap', 'RdBu'), _VCO['RdBu']), cubehelix=(trans._p('colormap', 'cubehelix'), _VCO['cubehelix']), single_hue=(trans._p('colormap', 'single hue'), _VCO['single_hue']), hsl=(trans._p('colormap', 'hsl'), _VCO['hsl']), husl=(trans._p('colormap', 'husl'), _VCO['husl']), diverging=(trans._p('colormap', 'diverging'), _VCO['diverging']), RdYeBuCy=(trans._p('colormap', 'RdYeBuCy'), _VCO['RdYeBuCy']), ) _VISPY_COLORMAPS_TRANSLATIONS_REVERSE = { v[0]: k for k, v in _VISPY_COLORMAPS_TRANSLATIONS.items() } _PRIMARY_COLORS = OrderedDict( red=(trans._p('colormap', 'red'), [1.0, 0.0, 0.0]), green=(trans._p('colormap', 'green'), [0.0, 1.0, 0.0]), blue=(trans._p('colormap', 'blue'), [0.0, 0.0, 1.0]), cyan=(trans._p('colormap', 'cyan'), [0.0, 1.0, 1.0]), magenta=(trans._p('colormap', 'magenta'), [1.0, 0.0, 1.0]), yellow=(trans._p('colormap', 'yellow'), [1.0, 1.0, 0.0]), ) SIMPLE_COLORMAPS = { name: Colormap( name=name, display_name=display_name, colors=[[0.0, 0.0, 0.0], color] ) for name, (display_name, color) in _PRIMARY_COLORS.items() } # dictionay for bop colormap objects BOP_COLORMAPS = { name: Colormap(value, name=name, display_name=display_name) for name, (display_name, value) in bopd.items() } INVERSE_COLORMAPS = { name: Colormap(value, name=name, display_name=display_name) for name, (display_name, value) in inverse_cmaps.items() } def _all_rgb(): """Return all 256**3 valid rgb tuples.""" base = np.arange(256, dtype=np.uint8) r, g, b = np.meshgrid(base, base, base, indexing='ij') return np.stack((r, g, b), axis=-1).reshape((-1, 3)) # The following values were precomputed and stored as constants # here to avoid heavy computation when importing this module. # The following code can be used to reproduce these values. # # rgb_colors = _all_rgb() # luv_colors = colorconv.rgb2luv(rgb_colors) # LUVMIN = np.amin(luv_colors, axis=(0,)) # LUVMAX = np.amax(luv_colors, axis=(0,)) # lab_colors = colorconv.rgb2lab(rgb_colors) # LABMIN = np.amin(lab_colors, axis=(0,)) # LABMAX = np.amax(lab_colors, axis=(0,)) LUVMIN = np.array([0.0, -83.07790815, -134.09790293]) LUVMAX = np.array([100.0, 175.01447356, 107.39905336]) LUVRNG = LUVMAX - LUVMIN LABMIN = np.array([0.0, -86.18302974, -107.85730021]) LABMAX = np.array([100.0, 98.23305386, 94.47812228]) LABRNG = LABMAX - LABMIN def convert_vispy_colormap(colormap, name='vispy'): """Convert a vispy colormap object to a napari colormap. Parameters ---------- colormap : vispy.color.Colormap Vispy colormap object that should be converted. name : str Name of colormap, optional. Returns ------- napari.utils.Colormap """ if not isinstance(colormap, VispyColormap): raise TypeError( trans._( 'Colormap must be a vispy colormap if passed to from_vispy', deferred=True, ) ) # Not all vispy colormaps have an `_controls` # but if they do, we want to use it if hasattr(colormap, '_controls'): controls = colormap._controls else: controls = np.zeros((0,)) # Not all vispy colormaps have an `interpolation` # but if they do, we want to use it if hasattr(colormap, 'interpolation'): interpolation = colormap.interpolation else: interpolation = 'linear' if name in _VISPY_COLORMAPS_TRANSLATIONS: display_name, _cmap = _VISPY_COLORMAPS_TRANSLATIONS[name] else: # Unnamed colormap display_name = trans._(name) return Colormap( name=name, display_name=display_name, colors=colormap.colors.rgba, controls=controls, interpolation=interpolation, ) def _validate_rgb(colors, *, tolerance=0.0): """Return the subset of colors that is in [0, 1] for all channels. Parameters ---------- colors : array of float, shape (N, 3) Input colors in RGB space. Returns ------- filtered_colors : array of float, shape (M, 3), M <= N The subset of colors that are in valid RGB space. Other Parameters ---------------- tolerance : float, optional Values outside of the range by less than ``tolerance`` are allowed and clipped to be within the range. Examples -------- >>> colors = np.array([[ 0. , 1., 1. ], ... [ 1.1, 0., -0.03], ... [ 1.2, 1., 0.5 ]]) >>> _validate_rgb(colors) array([[0., 1., 1.]]) >>> _validate_rgb(colors, tolerance=0.15) array([[0., 1., 1.], [1., 0., 0.]]) """ lo = 0 - tolerance hi = 1 + tolerance valid = np.all((colors > lo) & (colors < hi), axis=1) filtered_colors = np.clip(colors[valid], 0, 1) return filtered_colors def low_discrepancy_image(image, seed=0.5, margin=1 / 256): """Generate a 1d low discrepancy sequence of coordinates. Parameters ---------- image : array of int A set of labels or label image. seed : float The seed from which to start the quasirandom sequence. margin : float Values too close to 0 or 1 will get mapped to the edge of the colormap, so we need to offset to a margin slightly inside those values. Since the bin size is 1/256 by default, we offset by that amount. Returns ------- image_out : array of float The set of ``labels`` remapped to [0, 1] quasirandomly. """ phi_mod = 0.6180339887498948482 image_float = seed + image * phi_mod # We now map the floats to the range [0 + margin, 1 - margin] image_out = margin + (1 - 2 * margin) * ( image_float - np.floor(image_float) ) return image_out def color_dict_to_colormap(colors): """ Generate a color map based on the given color dictionary Parameters ---------- colors : dict of int to array of float, shape (4) Mapping between labels and color Returns ------- colormap : napari.utils.Colormap Colormap constructed with provided control colors label_color_index : dict of int Mapping of Label to color control point within colormap """ MAX_DISTINCT_COLORS = LUT_len control_colors = np.unique(list(colors.values()), axis=0) if len(control_colors) >= MAX_DISTINCT_COLORS: warnings.warn( trans._( 'Label layers with more than {max_distinct_colors} distinct colors will not render correctly. This layer has {distinct_colors}.', deferred=True, distinct_colors=str(len(control_colors)), max_distinct_colors=str(MAX_DISTINCT_COLORS), ), category=UserWarning, ) colormap = Colormap( colors=control_colors, interpolation=ColormapInterpolationMode.ZERO ) control2index = { tuple(color): control_point for color, control_point in zip(colormap.colors, colormap.controls) } control_small_delta = 0.5 / len(control_colors) label_color_index = { label: np.float32(control2index[tuple(color)] + control_small_delta) for label, color in colors.items() } return colormap, label_color_index def _low_discrepancy(dim, n, seed=0.5): """Generate a 1d, 2d, or 3d low discrepancy sequence of coordinates. Parameters ---------- dim : one of {1, 2, 3} The dimensionality of the sequence. n : int How many points to generate. seed : float or array of float, shape (dim,) The seed from which to start the quasirandom sequence. Returns ------- pts : array of float, shape (n, dim) The sampled points. References ---------- ..[1]: http://extremelearning.com.au/unreasonable-effectiveness-of-quasirandom-sequences/ # noqa: E501 """ phi1 = 1.6180339887498948482 phi2 = 1.32471795724474602596 phi3 = 1.22074408460575947536 seed = np.broadcast_to(seed, (1, dim)) phi = np.array([phi1, phi2, phi3]) g = 1 / phi n = np.reshape(np.arange(n), (n, 1)) pts = (seed + (n * g[:dim])) % 1 return pts def _color_random(n, *, colorspace='lab', tolerance=0.0, seed=0.5): """Generate n random RGB colors uniformly from LAB or LUV space. Parameters ---------- n : int Number of colors to generate. colorspace : str, one of {'lab', 'luv', 'rgb'} The colorspace from which to get random colors. tolerance : float How much margin to allow for out-of-range RGB values (these are clipped to be in-range). seed : float or array of float, shape (3,) Value from which to start the quasirandom sequence. Returns ------- rgb : array of float, shape (n, 3) RGB colors chosen uniformly at random from given colorspace. """ factor = 6 # about 1/5 of random LUV tuples are inside the space expand_factor = 2 rgb = np.zeros((0, 3)) while len(rgb) < n: random = _low_discrepancy(3, n * factor, seed=seed) if colorspace == 'luv': raw_rgb = colorconv.luv2rgb(random * LUVRNG + LUVMIN) elif colorspace == 'rgb': raw_rgb = random else: # 'lab' by default # The values in random are in [0, 1], but since the LAB colorspace # is not exactly contained in the unit-box, some 3-tuples might not # be valid LAB color coordinates. scikit-image handles this by projecting # such coordinates into the colorspace, but will also warn when doing this. with warnings.catch_warnings(): warnings.filterwarnings( action='ignore', message='Color data out of range', category=UserWarning, ) raw_rgb = colorconv.lab2rgb(random * LABRNG + LABMIN) rgb = _validate_rgb(raw_rgb, tolerance=tolerance) factor *= expand_factor return rgb[:n] def label_colormap(num_colors=256, seed=0.5): """Produce a colormap suitable for use with a given label set. Parameters ---------- num_colors : int, optional Number of unique colors to use. Default used if not given. Colors are in addition to a transparent color 0. seed : float or array of float, length 3 The seed for the random color generator. Returns ------- colormap : napari.utils.Colormap A colormap for use with labels remapped to [0, 1]. Notes ----- 0 always maps to fully transparent. """ # Starting the control points slightly above 0 and below 1 is necessary # to ensure that the background pixel 0 is transparent midpoints = np.linspace(0.00001, 1 - 0.00001, num_colors) control_points = np.concatenate(([0], midpoints, [1.0])) # make sure to add an alpha channel to the colors colors = np.concatenate( ( _color_random(num_colors + 1, seed=seed), np.full((num_colors + 1, 1), 1), ), axis=1, ) # Insert alpha at layer 0 colors[0, :] = 0 # ensure alpha is 0 for label 0 return Colormap( name='label_colormap', display_name=trans._p('colormap', 'low discrepancy colors'), colors=colors, controls=control_points, interpolation='zero', ) def vispy_or_mpl_colormap(name): """Try to get a colormap from vispy, or convert an mpl one to vispy format. Parameters ---------- name : str The name of the colormap. Returns ------- colormap : napari.utils.Colormap The found colormap. Raises ------ KeyError If no colormap with that name is found within vispy or matplotlib. """ if name in _VISPY_COLORMAPS_TRANSLATIONS: cmap = get_colormap(name) colormap = convert_vispy_colormap(cmap, name=name) else: try: mpl_cmap = getattr(cm, name) if name in _MATPLOTLIB_COLORMAP_NAMES: display_name = _MATPLOTLIB_COLORMAP_NAMES[name] else: display_name = name except AttributeError as e: suggestion = _MATPLOTLIB_COLORMAP_NAMES_REVERSE.get( name ) or _MATPLOTLIB_COLORMAP_NAMES_REVERSE.get(name) if suggestion: raise KeyError( trans._( 'Colormap "{name}" not found in either vispy or matplotlib but you might want to use "{suggestion}".', deferred=True, name=name, suggestion=suggestion, ) ) from e else: colormaps = set(_VISPY_COLORMAPS_ORIGINAL).union( set(_MATPLOTLIB_COLORMAP_NAMES) ) raise KeyError( trans._( 'Colormap "{name}" not found in either vispy or matplotlib. Recognized colormaps are: {colormaps}', deferred=True, name=name, colormaps=", ".join( sorted(f'"{cm}"' for cm in colormaps) ), ) ) mpl_colors = mpl_cmap(np.linspace(0, 1, 256)) colormap = Colormap( name=name, display_name=display_name, colors=mpl_colors ) return colormap # A dictionary mapping names to VisPy colormap objects ALL_COLORMAPS = { k: vispy_or_mpl_colormap(k) for k in _MATPLOTLIB_COLORMAP_NAMES } ALL_COLORMAPS.update(SIMPLE_COLORMAPS) ALL_COLORMAPS.update(BOP_COLORMAPS) ALL_COLORMAPS.update(INVERSE_COLORMAPS) # ... sorted alphabetically by name AVAILABLE_COLORMAPS = { k: v for k, v in sorted(ALL_COLORMAPS.items(), key=lambda cmap: cmap[0].lower()) } # lock to allow update of AVAILABLE_COLORMAPS in threads AVAILABLE_COLORMAPS_LOCK = Lock() # curated colormap sets # these are selected to look good or at least reasonable when using additive # blending of multiple channels. MAGENTA_GREEN = ['magenta', 'green'] RGB = ['red', 'green', 'blue'] CYMRGB = ['cyan', 'yellow', 'magenta', 'red', 'green', 'blue'] def _increment_unnamed_colormap( existing: List[str], name: str = '[unnamed colormap]' ) -> Tuple[str, str]: """Increment name for unnamed colormap. Parameters ---------- existing : list of str Names of existing colormaps. name : str, optional Name of colormap to be incremented. by default '[unnamed colormap]' Returns ------- name : str Name of colormap after incrementing. display_name : str Display name of colormap after incrementing. """ display_name = trans._('[unnamed colormap]') if name == '[unnamed colormap]': past_names = [n for n in existing if n.startswith('[unnamed colormap')] name = f'[unnamed colormap {len(past_names)}]' display_name = trans._( "[unnamed colormap {number}]", number=len(past_names), ) return name, display_name def ensure_colormap(colormap: ValidColormapArg) -> Colormap: """Accept any valid colormap argument, and return Colormap, or raise. Adds any new colormaps to AVAILABLE_COLORMAPS in the process, except for custom unnamed colormaps created from color values. Parameters ---------- colormap : ValidColormapArg See ValidColormapArg for supported input types. Returns ------- Colormap Warns ----- UserWarning If ``colormap`` is not a valid colormap argument type. Raises ------ KeyError If a string is provided that is not in AVAILABLE_COLORMAPS TypeError If a tuple is provided and the first element is not a string or the second element is not a Colormap. TypeError If a dict is provided and any of the values are not Colormap instances or valid inputs to the Colormap constructor. """ with AVAILABLE_COLORMAPS_LOCK: if isinstance(colormap, str): name = colormap if name not in AVAILABLE_COLORMAPS: cmap = vispy_or_mpl_colormap( name ) # raises KeyError if not found AVAILABLE_COLORMAPS[name] = cmap elif isinstance(colormap, Colormap): AVAILABLE_COLORMAPS[colormap.name] = colormap name = colormap.name elif isinstance(colormap, VispyColormap): # if a vispy colormap instance is provided, make sure we don't already # know about it before adding a new unnamed colormap name = None for key, val in AVAILABLE_COLORMAPS.items(): if colormap == val: name = key break if not name: name, _display_name = _increment_unnamed_colormap( AVAILABLE_COLORMAPS ) # Convert from vispy colormap cmap = convert_vispy_colormap(colormap, name=name) AVAILABLE_COLORMAPS[name] = cmap elif isinstance(colormap, tuple): if ( len(colormap) == 2 and isinstance(colormap[0], str) and isinstance(colormap[1], (VispyColormap, Colormap)) ): name, cmap = colormap # Convert from vispy colormap if isinstance(cmap, VispyColormap): cmap = convert_vispy_colormap(cmap, name=name) else: cmap.name = name AVAILABLE_COLORMAPS[name] = cmap else: colormap = _colormap_from_colors(colormap) if colormap is not None: # Return early because we don't have a name for this colormap. return colormap raise TypeError( trans._( "When providing a tuple as a colormap argument, either 1) the first element must be a string and the second a Colormap instance 2) or the tuple should be convertible to one or more colors", deferred=True, ) ) elif isinstance(colormap, dict): if 'colors' in colormap and not ( isinstance(colormap['colors'], VispyColormap) or isinstance(colormap['colors'], Colormap) ): cmap = Colormap(**colormap) name = cmap.name AVAILABLE_COLORMAPS[name] = cmap elif not all( (isinstance(i, VispyColormap) or isinstance(i, Colormap)) for i in colormap.values() ): raise TypeError( trans._( "When providing a dict as a colormap, all values must be Colormap instances", deferred=True, ) ) else: # Convert from vispy colormaps for key, cmap in colormap.items(): # Convert from vispy colormap if isinstance(cmap, VispyColormap): cmap = convert_vispy_colormap(cmap, name=key) else: cmap.name = key name = key colormap[name] = cmap AVAILABLE_COLORMAPS.update(colormap) if len(colormap) == 1: name = list(colormap)[0] # first key in dict elif len(colormap) > 1: name = list(colormap.keys())[0] warnings.warn( trans._( "only the first item in a colormap dict is used as an argument", deferred=True, ) ) else: raise ValueError( trans._( "Received an empty dict as a colormap argument.", deferred=True, ) ) else: colormap = _colormap_from_colors(colormap) if colormap is not None: # Return early because we don't have a name for this colormap. return colormap warnings.warn( trans._( 'invalid type for colormap: {cm_type}. Must be a {{str, tuple, dict, napari.utils.Colormap, vispy.colors.Colormap}}. Reverting to default', deferred=True, cm_type=type(colormap), ) ) # Use default colormap name = 'gray' return AVAILABLE_COLORMAPS[name] def _colormap_from_colors(colors: ColorType) -> Optional[Colormap]: try: color_array = transform_color(colors) except (ValueError, AttributeError, KeyError): return None if color_array.shape[0] == 1: color_array = np.array([[0, 0, 0, 1], color_array[0]]) return Colormap(color_array) def make_default_color_array(): return np.array([0, 0, 0, 1]) def display_name_to_name(display_name): display_name_map = { v._display_name: k for k, v in AVAILABLE_COLORMAPS.items() } return display_name_map.get( display_name, list(AVAILABLE_COLORMAPS.keys())[0] ) napari-0.5.0a1/napari/utils/colormaps/inverse_colormaps.py000066400000000000000000000015211437041365600237350ustar00rootroot00000000000000"""This module contains the colormap dictionaries for inverse lookup tables taken from https://github.com/cleterrier/ChrisLUTs. To make it compatible with napari's colormap classes, all the values in the colormap are normalized (divide by 255). """ from napari.utils.translations import trans I_Bordeaux = [[1, 1, 1], [204 / 255, 0, 51 / 255]] I_Blue = [[1, 1, 1], [0, 51 / 255, 204 / 255]] I_Forest = [[1, 1, 1], [0, 153 / 255, 0]] I_Orange = [[1, 1, 1], [1, 117 / 255, 0]] # inverted ChrisLUT OPF Orange I_Purple = [[1, 1, 1], [117 / 255, 0, 1]] # inverted ChrisLUT OPF Purple inverse_cmaps = { "I Bordeaux": (trans._("I Bordeaux"), I_Bordeaux), "I Blue": (trans._("I Blue"), I_Blue), "I Forest": (trans._("I Forest"), I_Forest), "I Orange": (trans._("I Orange"), I_Orange), "I Purple": (trans._("I Purple"), I_Purple), } napari-0.5.0a1/napari/utils/colormaps/standardize_color.py000066400000000000000000000367501437041365600237250ustar00rootroot00000000000000"""This module contains functions that 'standardize' the color handling of napari layers by supplying functions that are able to convert most color representation the user had in mind into a single representation - a numpy Nx4 array of float32 values between 0 and 1 - that is used across the codebase. The color is always in an RGBA format. To handle colors in HSV, for example, we should point users to skimage, matplotlib and others. The main function of the module is "transform_color", which might call a cascade of other, private, function in the module to do the hard work of converting the input. This function will either be called directly, or used by the function "transform_color_with_defaults", which is a helper function for the layer objects located in ``layers.utils.color_transformations.py``. In general, when handling colors we try to catch invalid color representations, warn the users of their misbehaving and return a default white color array, since it seems unreasonable to crash the entire napari session due to mis-represented colors. """ import functools import types import warnings from typing import Any, Callable, Dict, Sequence import numpy as np from vispy.color import ColorArray, get_color_dict, get_color_names from vispy.color.color_array import _string_to_rgb from napari.utils.translations import trans def transform_color(colors: Any) -> np.ndarray: """Transforms provided color(s) to an Nx4 array of RGBA np.float32 values. N is the number of given colors. The function is designed to parse all valid color representations a user might have and convert them properly. That being said, combinations of different color representation in the same list of colors is prohibited, and will error. This means that a list of ['red', np.array([1, 0, 0])] cannot be parsed and has to be manually pre-processed by the user before sent to this function. In addition, the provided colors - if numeric - should already be in an RGB(A) format. To convert an existing numeric color array to RGBA format use skimage before calling this function. Parameters ---------- colors : string and array-like. The color(s) to interpret and convert Returns ------- colors : np.ndarray An instance of np.ndarray with a data type of float32, 4 columns in RGBA order and N rows, with N being the number of colors. The array will always be 2D even if a single color is passed. Raises ------ ValueError, AttributeError, KeyError invalid inputs """ colortype = type(colors) return _color_switch[colortype](colors) @functools.lru_cache(maxsize=1024) def _handle_str(color: str) -> np.ndarray: """Creates an array from a color of type string. The function uses an LRU cache to enhance performance. Parameters ---------- color : str A single string as an input color. Can be a color name or a hex representation of a color, with either 6 or 8 hex digits. Returns ------- colorarray : np.ndarray 1x4 array """ if len(color) == 0: warnings.warn( trans._( "Empty string detected. Returning black instead.", deferred=True, ) ) return np.zeros((1, 4), dtype=np.float32) colorarray = np.atleast_2d(_string_to_rgb(color)).astype(np.float32) if colorarray.shape[1] == 3: colorarray = np.column_stack([colorarray, np.float32(1.0)]) return colorarray def _handle_list_like(colors: Sequence) -> np.ndarray: """Parse a list-like container of colors into a numpy Nx4 array. Handles all list-like containers of colors using recursion (if necessary). The colors inside the container should all be represented in the same manner. This means that a list containing ['r', (1., 1., 1.)] will raise an error. Note that numpy arrays are handled in _handle_array. Lists which are known to contain strings will be parsed with _handle_str_list_like. Generators should first visit _handle_generator before arriving as input. Parameters ---------- colors : Sequence A list-like container of colors. The colors inside should be homogeneous in their representation. Returns ------- color_array : np.ndarray Nx4 numpy array, with N being the length of ``colors``. """ try: # The following conversion works for most cases, and so it's expected # that most valid inputs will pass this .asarray() call # with ease. Those who don't are usually too cryptic to decipher. # If only some of the colors are strings, explicitly provide an object # dtype to avoid the deprecated behavior described in: # https://github.com/napari/napari/issues/2791 num_str = len([c for c in colors if isinstance(c, str)]) dtype = 'O' if 0 < num_str < len(colors) else None color_array = np.atleast_2d(np.asarray(colors, dtype=dtype)) except ValueError: warnings.warn( trans._( "Couldn't convert input color array to a proper numpy array. Please make sure that your input data is in a parsable format. Converting input to a white color array.", deferred=True, ) ) return np.ones((max(len(colors), 1), 4), dtype=np.float32) # Happy path - converted to a float\integer array if color_array.dtype.kind in ['f', 'i']: return _handle_array(color_array) # User input was an iterable with strings if color_array.dtype.kind in ['U', 'O']: return _handle_str_list_like(color_array.ravel()) def _handle_generator(colors) -> np.ndarray: """Generators are converted to lists since we need to know their length to instantiate a proper array. """ return _handle_list_like(list(colors)) def handle_nested_colors(colors) -> ColorArray: """In case of an array-like container holding colors, unpack it.""" colors_as_rbga = np.ones((len(colors), 4), dtype=np.float32) for idx, color in enumerate(colors): colors_as_rbga[idx] = _color_switch[type(color)](color) return ColorArray(colors_as_rbga) def _handle_array(colors: np.ndarray) -> np.ndarray: """Converts the given array into an array in the right format.""" kind = colors.dtype.kind # Object arrays aren't handled by napari if kind == 'O': warnings.warn( trans._( "An object array was passed as the color input. Please convert its datatype before sending it to napari. Converting input to a white color array.", deferred=True, ) ) return np.ones((max(len(colors), 1), 4), dtype=np.float32) # An array of strings will be treated as a list if compatible elif kind == 'U': if colors.ndim == 1: return _handle_str_list_like(colors) else: warnings.warn( trans._( "String color arrays should be one-dimensional. Converting input to a white color array.", deferred=True, ) ) return np.ones((len(colors), 4), dtype=np.float32) # Test the dimensionality of the input array # Empty color array can be a way for the user to signal # that it wants the "default" colors of napari. We return # a single white color. if colors.shape[-1] == 0: warnings.warn( trans._( "Given color input is empty. Converting input to a white color array.", deferred=True, ) ) return np.ones((1, 4), dtype=np.float32) colors = np.atleast_2d(colors) # Arrays with more than two dimensions don't have a clear # conversion method to a color array and thus raise an error. if colors.ndim > 2: raise ValueError( trans._( "Given colors input should contain one or two dimensions. Received array with {ndim} dimensions.", deferred=True, ndim=colors.ndim, ) ) # User provided a list of numbers as color input. This input # cannot be coerced into something understandable and thus # will return an error. if colors.shape[0] == 1 and colors.shape[1] not in {3, 4}: raise ValueError( trans._( "Given color array has an unsupported format. Received the following array:\n{colors}\nA proper color array should have 3-4 columns with a row per data entry.", deferred=True, colors=colors, ) ) # The user gave a list of colors, but it contains a wrong number # of columns. This check will also drop Nx1 (2D) arrays, since # numpy has vectors, and representing colors in this way # (column vector-like) is redundant. However, this results in a # warning and not a ValueError since we know the number of colors # in this dataset, meaning we can save the napari session by # rendering the data in white, which better than crashing. if not 3 <= colors.shape[1] <= 4: warnings.warn( trans._( "Given colors input should contain three or four columns. Received array with {shape} columns. Converting input to a white color array.", deferred=True, shape=colors.shape[1], ) ) return np.ones((len(colors), 4), dtype=np.float32) # Arrays with floats and ints can be safely converted to the proper format if kind in ['f', 'i', 'u']: return _convert_array_to_correct_format(colors) else: raise ValueError( trans._( "Data type of array ({color_dtype}) not supported.", deferred=True, color_dtype=colors.dtype, ) ) def _convert_array_to_correct_format(colors: np.ndarray) -> np.ndarray: """Asserts shape, dtype and normalization of given color array. This function deals with arrays which are already 'well-behaved', i.e. have (almost) the correct number of columns and are able to represent colors correctly. It then it makes sure that the array indeed has exactly four columns and that its values are normalized between 0 and 1, with a data type of float32. Parameters ---------- colors : np.ndarray Input color array, perhaps un-normalized and without the alpha channel. Returns ------- colors : np.ndarray Nx4, float32 color array with values in the range [0, 1] """ if colors.shape[1] == 3: colors = np.column_stack( [colors, np.ones(len(colors), dtype=np.float32)] ) if colors.min() < 0: raise ValueError( trans._( "Colors input had negative values.", deferred=True, ) ) if colors.max() > 1: warnings.warn( trans._( "Colors with values larger than one detected. napari will normalize these colors for you. If you'd like to convert these yourself, please use the proper method from skimage.color.", deferred=True, ) ) colors = _normalize_color_array(colors) return np.atleast_2d(np.asarray(colors, dtype=np.float32)) def _handle_str_list_like(colors: Sequence) -> np.ndarray: """Converts lists or arrays filled with strings to the proper color array format. Parameters ---------- colors : list-like A sequence of string colors Returns ------- color_array : np.ndarray Nx4, float32 color array """ color_array = np.empty((len(colors), 4), dtype=np.float32) for idx, c in enumerate(colors): try: color_array[idx, :] = _color_switch[type(c)](c) except (ValueError, TypeError, KeyError) as e: raise ValueError( trans._( "Invalid color found: {color} at index {idx}.", deferred=True, color=c, idx=idx, ) ) from e return color_array def _handle_none(color) -> np.ndarray: """Converts color given as None to black. Parameters ---------- color : NoneType None value given as a color Returns ------- arr : np.ndarray 1x4 numpy array of float32 zeros """ return np.zeros((1, 4), dtype=np.float32) def _normalize_color_array(colors: np.ndarray) -> np.ndarray: """Normalize all array values to the range [0, 1]. The added complexity here stems from the fact that if a row in the given array contains four identical value a simple normalization might raise a division by zero exception. Parameters ---------- colors : np.ndarray A numpy array with values possibly outside the range of [0, 1] Returns ------- colors : np.ndarray Copy of input array with normalized values """ colors = colors.astype(np.float32, copy=True) out_of_bounds_idx = np.unique(np.where((colors > 1) | (colors < 0))[0]) out_of_bounds = colors[out_of_bounds_idx] norm = np.linalg.norm(out_of_bounds, np.inf, axis=1) out_of_bounds = out_of_bounds / norm[:, np.newaxis] colors[out_of_bounds_idx] = out_of_bounds return colors.astype(np.float32) _color_switch: Dict[Any, Callable] = { str: _handle_str, np.str_: _handle_str, list: _handle_list_like, tuple: _handle_list_like, types.GeneratorType: _handle_generator, np.ndarray: _handle_array, type(None): _handle_none, } def _create_hex_to_name_dict(): """Create a dictionary mapping hexadecimal RGB colors into their 'official' name. Returns ------- hex_to_rgb : dict Mapping from hexadecimal RGB ('#ff0000') to name ('red'). """ colordict = get_color_dict() hex_to_name = {f"{v.lower()}ff": k for k, v in colordict.items()} return hex_to_name def get_color_namelist(): """Gets all the color names supported by napari. Returns ------- list[str] All the color names supported by napari. """ return get_color_names() hex_to_name = _create_hex_to_name_dict() def _check_color_dim(val): """Ensures input is Nx4. Parameters ---------- val : np.ndarray A color array of possibly less than 4 columns Returns ------- val : np.ndarray A four columns version of the input array. If the original array was a missing the fourth channel, it's added as 1.0 values. """ val = np.atleast_2d(val) if val.shape[1] not in (3, 4): strval = str(val) if len(strval) > 100: strval = strval[:97] + '...' raise RuntimeError( trans._( 'Value must have second dimension of size 3 or 4. Got `{val}`, shape={shape}', deferred=True, shape=val.shape, val=strval, ) ) if val.shape[1] == 3: val = np.column_stack([val, np.float32(1.0)]) return val def rgb_to_hex(rgbs: Sequence) -> np.ndarray: """Convert RGB to hex quadruplet. Taken from vispy with slight modifications. Parameters ---------- rgbs : Sequence A list-like container of colors in RGBA format with values between [0, 1] Returns ------- arr : np.ndarray An array of the hex representation of the input colors """ rgbs = _check_color_dim(rgbs) return np.array( [ f'#{"%02x" * 4}' % tuple((255 * rgb).astype(np.uint8)) for rgb in rgbs ], '|U9', ) napari-0.5.0a1/napari/utils/colormaps/vendored/000077500000000000000000000000001437041365600214405ustar00rootroot00000000000000napari-0.5.0a1/napari/utils/colormaps/vendored/__init__.py000066400000000000000000000000001437041365600235370ustar00rootroot00000000000000napari-0.5.0a1/napari/utils/colormaps/vendored/_cm.py000066400000000000000000002020701437041365600225510ustar00rootroot00000000000000""" Nothing here but dictionaries for generating LinearSegmentedColormaps, and a dictionary of these dictionaries. Documentation for each is in pyplot.colormaps(). Please update this with the purpose and type of your colormap if you add data for one here. """ from functools import partial import numpy as np _binary_data = { 'red': ((0., 1., 1.), (1., 0., 0.)), 'green': ((0., 1., 1.), (1., 0., 0.)), 'blue': ((0., 1., 1.), (1., 0., 0.)) } _autumn_data = {'red': ((0., 1.0, 1.0), (1.0, 1.0, 1.0)), 'green': ((0., 0., 0.), (1.0, 1.0, 1.0)), 'blue': ((0., 0., 0.), (1.0, 0., 0.))} _bone_data = {'red': ((0., 0., 0.), (0.746032, 0.652778, 0.652778), (1.0, 1.0, 1.0)), 'green': ((0., 0., 0.), (0.365079, 0.319444, 0.319444), (0.746032, 0.777778, 0.777778), (1.0, 1.0, 1.0)), 'blue': ((0., 0., 0.), (0.365079, 0.444444, 0.444444), (1.0, 1.0, 1.0))} _cool_data = {'red': ((0., 0., 0.), (1.0, 1.0, 1.0)), 'green': ((0., 1., 1.), (1.0, 0., 0.)), 'blue': ((0., 1., 1.), (1.0, 1., 1.))} _copper_data = {'red': ((0., 0., 0.), (0.809524, 1.000000, 1.000000), (1.0, 1.0, 1.0)), 'green': ((0., 0., 0.), (1.0, 0.7812, 0.7812)), 'blue': ((0., 0., 0.), (1.0, 0.4975, 0.4975))} def _flag_red(x): return 0.75 * np.sin((x * 31.5 + 0.25) * np.pi) + 0.5 def _flag_green(x): return np.sin(x * 31.5 * np.pi) def _flag_blue(x): return 0.75 * np.sin((x * 31.5 - 0.25) * np.pi) + 0.5 _flag_data = {'red': _flag_red, 'green': _flag_green, 'blue': _flag_blue} def _prism_red(x): return 0.75 * np.sin((x * 20.9 + 0.25) * np.pi) + 0.67 def _prism_green(x): return 0.75 * np.sin((x * 20.9 - 0.25) * np.pi) + 0.33 def _prism_blue(x): return -1.1 * np.sin((x * 20.9) * np.pi) _prism_data = {'red': _prism_red, 'green': _prism_green, 'blue': _prism_blue} def _ch_helper(gamma, s, r, h, p0, p1, x): """Helper function for generating picklable cubehelix color maps.""" # Apply gamma factor to emphasise low or high intensity values xg = x ** gamma # Calculate amplitude and angle of deviation from the black to white # diagonal in the plane of constant perceived intensity. a = h * xg * (1 - xg) / 2 phi = 2 * np.pi * (s / 3 + r * x) return xg + a * (p0 * np.cos(phi) + p1 * np.sin(phi)) def cubehelix(gamma=1.0, s=0.5, r=-1.5, h=1.0): """ Return custom data dictionary of (r,g,b) conversion functions, which can be used with :func:`register_cmap`, for the cubehelix color scheme. Unlike most other color schemes cubehelix was designed by D.A. Green to be monotonically increasing in terms of perceived brightness. Also, when printed on a black and white postscript printer, the scheme results in a greyscale with monotonically increasing brightness. This color scheme is named cubehelix because the r,g,b values produced can be visualised as a squashed helix around the diagonal in the r,g,b color cube. For a unit color cube (i.e. 3-D coordinates for r,g,b each in the range 0 to 1) the color scheme starts at (r,g,b) = (0,0,0), i.e. black, and finishes at (r,g,b) = (1,1,1), i.e. white. For some fraction *x*, between 0 and 1, the color is the corresponding grey value at that fraction along the black to white diagonal (x,x,x) plus a color element. This color element is calculated in a plane of constant perceived intensity and controlled by the following parameters. Optional keyword arguments: ========= ======================================================= Keyword Description ========= ======================================================= gamma gamma factor to emphasise either low intensity values (gamma < 1), or high intensity values (gamma > 1); defaults to 1.0. s the start color; defaults to 0.5 (i.e. purple). r the number of r,g,b rotations in color that are made from the start to the end of the color scheme; defaults to -1.5 (i.e. -> B -> G -> R -> B). h the hue parameter which controls how saturated the colors are. If this parameter is zero then the color scheme is purely a greyscale; defaults to 1.0. ========= ======================================================= """ return {'red': partial(_ch_helper, gamma, s, r, h, -0.14861, 1.78277), 'green': partial(_ch_helper, gamma, s, r, h, -0.29227, -0.90649), 'blue': partial(_ch_helper, gamma, s, r, h, 1.97294, 0.0)} _cubehelix_data = cubehelix() _bwr_data = ((0.0, 0.0, 1.0), (1.0, 1.0, 1.0), (1.0, 0.0, 0.0)) _brg_data = ((0.0, 0.0, 1.0), (1.0, 0.0, 0.0), (0.0, 1.0, 0.0)) # Gnuplot palette functions def _g0(x): return 0 def _g1(x): return 0.5 def _g2(x): return 1 def _g3(x): return x def _g4(x): return x ** 2 def _g5(x): return x ** 3 def _g6(x): return x ** 4 def _g7(x): return np.sqrt(x) def _g8(x): return np.sqrt(np.sqrt(x)) def _g9(x): return np.sin(x * np.pi / 2) def _g10(x): return np.cos(x * np.pi / 2) def _g11(x): return np.abs(x - 0.5) def _g12(x): return (2 * x - 1) ** 2 def _g13(x): return np.sin(x * np.pi) def _g14(x): return np.abs(np.cos(x * np.pi)) def _g15(x): return np.sin(x * 2 * np.pi) def _g16(x): return np.cos(x * 2 * np.pi) def _g17(x): return np.abs(np.sin(x * 2 * np.pi)) def _g18(x): return np.abs(np.cos(x * 2 * np.pi)) def _g19(x): return np.abs(np.sin(x * 4 * np.pi)) def _g20(x): return np.abs(np.cos(x * 4 * np.pi)) def _g21(x): return 3 * x def _g22(x): return 3 * x - 1 def _g23(x): return 3 * x - 2 def _g24(x): return np.abs(3 * x - 1) def _g25(x): return np.abs(3 * x - 2) def _g26(x): return (3 * x - 1) / 2 def _g27(x): return (3 * x - 2) / 2 def _g28(x): return np.abs((3 * x - 1) / 2) def _g29(x): return np.abs((3 * x - 2) / 2) def _g30(x): return x / 0.32 - 0.78125 def _g31(x): return 2 * x - 0.84 def _g32(x): ret = np.zeros(len(x)) m = (x < 0.25) ret[m] = 4 * x[m] m = (x >= 0.25) & (x < 0.92) ret[m] = -2 * x[m] + 1.84 m = (x >= 0.92) ret[m] = x[m] / 0.08 - 11.5 return ret def _g33(x): return np.abs(2 * x - 0.5) def _g34(x): return 2 * x def _g35(x): return 2 * x - 0.5 def _g36(x): return 2 * x - 1 gfunc = {i: globals()["_g{}".format(i)] for i in range(37)} _gnuplot_data = { 'red': gfunc[7], 'green': gfunc[5], 'blue': gfunc[15], } _gnuplot2_data = { 'red': gfunc[30], 'green': gfunc[31], 'blue': gfunc[32], } _ocean_data = { 'red': gfunc[23], 'green': gfunc[28], 'blue': gfunc[3], } _afmhot_data = { 'red': gfunc[34], 'green': gfunc[35], 'blue': gfunc[36], } _rainbow_data = { 'red': gfunc[33], 'green': gfunc[13], 'blue': gfunc[10], } _seismic_data = ( (0.0, 0.0, 0.3), (0.0, 0.0, 1.0), (1.0, 1.0, 1.0), (1.0, 0.0, 0.0), (0.5, 0.0, 0.0)) _terrain_data = ( (0.00, (0.2, 0.2, 0.6)), (0.15, (0.0, 0.6, 1.0)), (0.25, (0.0, 0.8, 0.4)), (0.50, (1.0, 1.0, 0.6)), (0.75, (0.5, 0.36, 0.33)), (1.00, (1.0, 1.0, 1.0))) _gray_data = {'red': ((0., 0, 0), (1., 1, 1)), 'green': ((0., 0, 0), (1., 1, 1)), 'blue': ((0., 0, 0), (1., 1, 1))} _hot_data = {'red': ((0., 0.0416, 0.0416), (0.365079, 1.000000, 1.000000), (1.0, 1.0, 1.0)), 'green': ((0., 0., 0.), (0.365079, 0.000000, 0.000000), (0.746032, 1.000000, 1.000000), (1.0, 1.0, 1.0)), 'blue': ((0., 0., 0.), (0.746032, 0.000000, 0.000000), (1.0, 1.0, 1.0))} _hsv_data = {'red': ((0., 1., 1.), (0.158730, 1.000000, 1.000000), (0.174603, 0.968750, 0.968750), (0.333333, 0.031250, 0.031250), (0.349206, 0.000000, 0.000000), (0.666667, 0.000000, 0.000000), (0.682540, 0.031250, 0.031250), (0.841270, 0.968750, 0.968750), (0.857143, 1.000000, 1.000000), (1.0, 1.0, 1.0)), 'green': ((0., 0., 0.), (0.158730, 0.937500, 0.937500), (0.174603, 1.000000, 1.000000), (0.507937, 1.000000, 1.000000), (0.666667, 0.062500, 0.062500), (0.682540, 0.000000, 0.000000), (1.0, 0., 0.)), 'blue': ((0., 0., 0.), (0.333333, 0.000000, 0.000000), (0.349206, 0.062500, 0.062500), (0.507937, 1.000000, 1.000000), (0.841270, 1.000000, 1.000000), (0.857143, 0.937500, 0.937500), (1.0, 0.09375, 0.09375))} _jet_data = {'red': ((0., 0, 0), (0.35, 0, 0), (0.66, 1, 1), (0.89, 1, 1), (1, 0.5, 0.5)), 'green': ((0., 0, 0), (0.125, 0, 0), (0.375, 1, 1), (0.64, 1, 1), (0.91, 0, 0), (1, 0, 0)), 'blue': ((0., 0.5, 0.5), (0.11, 1, 1), (0.34, 1, 1), (0.65, 0, 0), (1, 0, 0))} _pink_data = {'red': ((0., 0.1178, 0.1178), (0.015873, 0.195857, 0.195857), (0.031746, 0.250661, 0.250661), (0.047619, 0.295468, 0.295468), (0.063492, 0.334324, 0.334324), (0.079365, 0.369112, 0.369112), (0.095238, 0.400892, 0.400892), (0.111111, 0.430331, 0.430331), (0.126984, 0.457882, 0.457882), (0.142857, 0.483867, 0.483867), (0.158730, 0.508525, 0.508525), (0.174603, 0.532042, 0.532042), (0.190476, 0.554563, 0.554563), (0.206349, 0.576204, 0.576204), (0.222222, 0.597061, 0.597061), (0.238095, 0.617213, 0.617213), (0.253968, 0.636729, 0.636729), (0.269841, 0.655663, 0.655663), (0.285714, 0.674066, 0.674066), (0.301587, 0.691980, 0.691980), (0.317460, 0.709441, 0.709441), (0.333333, 0.726483, 0.726483), (0.349206, 0.743134, 0.743134), (0.365079, 0.759421, 0.759421), (0.380952, 0.766356, 0.766356), (0.396825, 0.773229, 0.773229), (0.412698, 0.780042, 0.780042), (0.428571, 0.786796, 0.786796), (0.444444, 0.793492, 0.793492), (0.460317, 0.800132, 0.800132), (0.476190, 0.806718, 0.806718), (0.492063, 0.813250, 0.813250), (0.507937, 0.819730, 0.819730), (0.523810, 0.826160, 0.826160), (0.539683, 0.832539, 0.832539), (0.555556, 0.838870, 0.838870), (0.571429, 0.845154, 0.845154), (0.587302, 0.851392, 0.851392), (0.603175, 0.857584, 0.857584), (0.619048, 0.863731, 0.863731), (0.634921, 0.869835, 0.869835), (0.650794, 0.875897, 0.875897), (0.666667, 0.881917, 0.881917), (0.682540, 0.887896, 0.887896), (0.698413, 0.893835, 0.893835), (0.714286, 0.899735, 0.899735), (0.730159, 0.905597, 0.905597), (0.746032, 0.911421, 0.911421), (0.761905, 0.917208, 0.917208), (0.777778, 0.922958, 0.922958), (0.793651, 0.928673, 0.928673), (0.809524, 0.934353, 0.934353), (0.825397, 0.939999, 0.939999), (0.841270, 0.945611, 0.945611), (0.857143, 0.951190, 0.951190), (0.873016, 0.956736, 0.956736), (0.888889, 0.962250, 0.962250), (0.904762, 0.967733, 0.967733), (0.920635, 0.973185, 0.973185), (0.936508, 0.978607, 0.978607), (0.952381, 0.983999, 0.983999), (0.968254, 0.989361, 0.989361), (0.984127, 0.994695, 0.994695), (1.0, 1.0, 1.0)), 'green': ((0., 0., 0.), (0.015873, 0.102869, 0.102869), (0.031746, 0.145479, 0.145479), (0.047619, 0.178174, 0.178174), (0.063492, 0.205738, 0.205738), (0.079365, 0.230022, 0.230022), (0.095238, 0.251976, 0.251976), (0.111111, 0.272166, 0.272166), (0.126984, 0.290957, 0.290957), (0.142857, 0.308607, 0.308607), (0.158730, 0.325300, 0.325300), (0.174603, 0.341178, 0.341178), (0.190476, 0.356348, 0.356348), (0.206349, 0.370899, 0.370899), (0.222222, 0.384900, 0.384900), (0.238095, 0.398410, 0.398410), (0.253968, 0.411476, 0.411476), (0.269841, 0.424139, 0.424139), (0.285714, 0.436436, 0.436436), (0.301587, 0.448395, 0.448395), (0.317460, 0.460044, 0.460044), (0.333333, 0.471405, 0.471405), (0.349206, 0.482498, 0.482498), (0.365079, 0.493342, 0.493342), (0.380952, 0.517549, 0.517549), (0.396825, 0.540674, 0.540674), (0.412698, 0.562849, 0.562849), (0.428571, 0.584183, 0.584183), (0.444444, 0.604765, 0.604765), (0.460317, 0.624669, 0.624669), (0.476190, 0.643958, 0.643958), (0.492063, 0.662687, 0.662687), (0.507937, 0.680900, 0.680900), (0.523810, 0.698638, 0.698638), (0.539683, 0.715937, 0.715937), (0.555556, 0.732828, 0.732828), (0.571429, 0.749338, 0.749338), (0.587302, 0.765493, 0.765493), (0.603175, 0.781313, 0.781313), (0.619048, 0.796819, 0.796819), (0.634921, 0.812029, 0.812029), (0.650794, 0.826960, 0.826960), (0.666667, 0.841625, 0.841625), (0.682540, 0.856040, 0.856040), (0.698413, 0.870216, 0.870216), (0.714286, 0.884164, 0.884164), (0.730159, 0.897896, 0.897896), (0.746032, 0.911421, 0.911421), (0.761905, 0.917208, 0.917208), (0.777778, 0.922958, 0.922958), (0.793651, 0.928673, 0.928673), (0.809524, 0.934353, 0.934353), (0.825397, 0.939999, 0.939999), (0.841270, 0.945611, 0.945611), (0.857143, 0.951190, 0.951190), (0.873016, 0.956736, 0.956736), (0.888889, 0.962250, 0.962250), (0.904762, 0.967733, 0.967733), (0.920635, 0.973185, 0.973185), (0.936508, 0.978607, 0.978607), (0.952381, 0.983999, 0.983999), (0.968254, 0.989361, 0.989361), (0.984127, 0.994695, 0.994695), (1.0, 1.0, 1.0)), 'blue': ((0., 0., 0.), (0.015873, 0.102869, 0.102869), (0.031746, 0.145479, 0.145479), (0.047619, 0.178174, 0.178174), (0.063492, 0.205738, 0.205738), (0.079365, 0.230022, 0.230022), (0.095238, 0.251976, 0.251976), (0.111111, 0.272166, 0.272166), (0.126984, 0.290957, 0.290957), (0.142857, 0.308607, 0.308607), (0.158730, 0.325300, 0.325300), (0.174603, 0.341178, 0.341178), (0.190476, 0.356348, 0.356348), (0.206349, 0.370899, 0.370899), (0.222222, 0.384900, 0.384900), (0.238095, 0.398410, 0.398410), (0.253968, 0.411476, 0.411476), (0.269841, 0.424139, 0.424139), (0.285714, 0.436436, 0.436436), (0.301587, 0.448395, 0.448395), (0.317460, 0.460044, 0.460044), (0.333333, 0.471405, 0.471405), (0.349206, 0.482498, 0.482498), (0.365079, 0.493342, 0.493342), (0.380952, 0.503953, 0.503953), (0.396825, 0.514344, 0.514344), (0.412698, 0.524531, 0.524531), (0.428571, 0.534522, 0.534522), (0.444444, 0.544331, 0.544331), (0.460317, 0.553966, 0.553966), (0.476190, 0.563436, 0.563436), (0.492063, 0.572750, 0.572750), (0.507937, 0.581914, 0.581914), (0.523810, 0.590937, 0.590937), (0.539683, 0.599824, 0.599824), (0.555556, 0.608581, 0.608581), (0.571429, 0.617213, 0.617213), (0.587302, 0.625727, 0.625727), (0.603175, 0.634126, 0.634126), (0.619048, 0.642416, 0.642416), (0.634921, 0.650600, 0.650600), (0.650794, 0.658682, 0.658682), (0.666667, 0.666667, 0.666667), (0.682540, 0.674556, 0.674556), (0.698413, 0.682355, 0.682355), (0.714286, 0.690066, 0.690066), (0.730159, 0.697691, 0.697691), (0.746032, 0.705234, 0.705234), (0.761905, 0.727166, 0.727166), (0.777778, 0.748455, 0.748455), (0.793651, 0.769156, 0.769156), (0.809524, 0.789314, 0.789314), (0.825397, 0.808969, 0.808969), (0.841270, 0.828159, 0.828159), (0.857143, 0.846913, 0.846913), (0.873016, 0.865261, 0.865261), (0.888889, 0.883229, 0.883229), (0.904762, 0.900837, 0.900837), (0.920635, 0.918109, 0.918109), (0.936508, 0.935061, 0.935061), (0.952381, 0.951711, 0.951711), (0.968254, 0.968075, 0.968075), (0.984127, 0.984167, 0.984167), (1.0, 1.0, 1.0))} _spring_data = {'red': ((0., 1., 1.), (1.0, 1.0, 1.0)), 'green': ((0., 0., 0.), (1.0, 1.0, 1.0)), 'blue': ((0., 1., 1.), (1.0, 0.0, 0.0))} _summer_data = {'red': ((0., 0., 0.), (1.0, 1.0, 1.0)), 'green': ((0., 0.5, 0.5), (1.0, 1.0, 1.0)), 'blue': ((0., 0.4, 0.4), (1.0, 0.4, 0.4))} _winter_data = {'red': ((0., 0., 0.), (1.0, 0.0, 0.0)), 'green': ((0., 0., 0.), (1.0, 1.0, 1.0)), 'blue': ((0., 1., 1.), (1.0, 0.5, 0.5))} _nipy_spectral_data = { 'red': [(0.0, 0.0, 0.0), (0.05, 0.4667, 0.4667), (0.10, 0.5333, 0.5333), (0.15, 0.0, 0.0), (0.20, 0.0, 0.0), (0.25, 0.0, 0.0), (0.30, 0.0, 0.0), (0.35, 0.0, 0.0), (0.40, 0.0, 0.0), (0.45, 0.0, 0.0), (0.50, 0.0, 0.0), (0.55, 0.0, 0.0), (0.60, 0.0, 0.0), (0.65, 0.7333, 0.7333), (0.70, 0.9333, 0.9333), (0.75, 1.0, 1.0), (0.80, 1.0, 1.0), (0.85, 1.0, 1.0), (0.90, 0.8667, 0.8667), (0.95, 0.80, 0.80), (1.0, 0.80, 0.80)], 'green': [(0.0, 0.0, 0.0), (0.05, 0.0, 0.0), (0.10, 0.0, 0.0), (0.15, 0.0, 0.0), (0.20, 0.0, 0.0), (0.25, 0.4667, 0.4667), (0.30, 0.6000, 0.6000), (0.35, 0.6667, 0.6667), (0.40, 0.6667, 0.6667), (0.45, 0.6000, 0.6000), (0.50, 0.7333, 0.7333), (0.55, 0.8667, 0.8667), (0.60, 1.0, 1.0), (0.65, 1.0, 1.0), (0.70, 0.9333, 0.9333), (0.75, 0.8000, 0.8000), (0.80, 0.6000, 0.6000), (0.85, 0.0, 0.0), (0.90, 0.0, 0.0), (0.95, 0.0, 0.0), (1.0, 0.80, 0.80)], 'blue': [(0.0, 0.0, 0.0), (0.05, 0.5333, 0.5333), (0.10, 0.6000, 0.6000), (0.15, 0.6667, 0.6667), (0.20, 0.8667, 0.8667), (0.25, 0.8667, 0.8667), (0.30, 0.8667, 0.8667), (0.35, 0.6667, 0.6667), (0.40, 0.5333, 0.5333), (0.45, 0.0, 0.0), (0.5, 0.0, 0.0), (0.55, 0.0, 0.0), (0.60, 0.0, 0.0), (0.65, 0.0, 0.0), (0.70, 0.0, 0.0), (0.75, 0.0, 0.0), (0.80, 0.0, 0.0), (0.85, 0.0, 0.0), (0.90, 0.0, 0.0), (0.95, 0.0, 0.0), (1.0, 0.80, 0.80)], } # 34 colormaps based on color specifications and designs # developed by Cynthia Brewer (http://colorbrewer.org). # The ColorBrewer palettes have been included under the terms # of an Apache-stype license (for details, see the file # LICENSE_COLORBREWER in the license directory of the matplotlib # source distribution). # RGB values taken from Brewer's Excel sheet, divided by 255 _Blues_data = ( (0.96862745098039216, 0.98431372549019602, 1.0 ), (0.87058823529411766, 0.92156862745098034, 0.96862745098039216), (0.77647058823529413, 0.85882352941176465, 0.93725490196078431), (0.61960784313725492, 0.792156862745098 , 0.88235294117647056), (0.41960784313725491, 0.68235294117647061, 0.83921568627450982), (0.25882352941176473, 0.5725490196078431 , 0.77647058823529413), (0.12941176470588237, 0.44313725490196076, 0.70980392156862748), (0.03137254901960784, 0.31764705882352939, 0.61176470588235299), (0.03137254901960784, 0.18823529411764706, 0.41960784313725491) ) _BrBG_data = ( (0.32941176470588235, 0.18823529411764706, 0.0196078431372549 ), (0.5490196078431373 , 0.31764705882352939, 0.0392156862745098 ), (0.74901960784313726, 0.50588235294117645, 0.17647058823529413), (0.87450980392156863, 0.76078431372549016, 0.49019607843137253), (0.96470588235294119, 0.90980392156862744, 0.76470588235294112), (0.96078431372549022, 0.96078431372549022, 0.96078431372549022), (0.7803921568627451 , 0.91764705882352937, 0.89803921568627454), (0.50196078431372548, 0.80392156862745101, 0.75686274509803919), (0.20784313725490197, 0.59215686274509804, 0.5607843137254902 ), (0.00392156862745098, 0.4 , 0.36862745098039218), (0.0 , 0.23529411764705882, 0.18823529411764706) ) _BuGn_data = ( (0.96862745098039216, 0.9882352941176471 , 0.99215686274509807), (0.89803921568627454, 0.96078431372549022, 0.97647058823529409), (0.8 , 0.92549019607843142, 0.90196078431372551), (0.6 , 0.84705882352941175, 0.78823529411764703), (0.4 , 0.76078431372549016, 0.64313725490196083), (0.25490196078431371, 0.68235294117647061, 0.46274509803921571), (0.13725490196078433, 0.54509803921568623, 0.27058823529411763), (0.0 , 0.42745098039215684, 0.17254901960784313), (0.0 , 0.26666666666666666, 0.10588235294117647) ) _BuPu_data = ( (0.96862745098039216, 0.9882352941176471 , 0.99215686274509807), (0.8784313725490196 , 0.92549019607843142, 0.95686274509803926), (0.74901960784313726, 0.82745098039215681, 0.90196078431372551), (0.61960784313725492, 0.73725490196078436, 0.85490196078431369), (0.5490196078431373 , 0.58823529411764708, 0.77647058823529413), (0.5490196078431373 , 0.41960784313725491, 0.69411764705882351), (0.53333333333333333, 0.25490196078431371, 0.61568627450980395), (0.50588235294117645, 0.05882352941176471, 0.48627450980392156), (0.30196078431372547, 0.0 , 0.29411764705882354) ) _GnBu_data = ( (0.96862745098039216, 0.9882352941176471 , 0.94117647058823528), (0.8784313725490196 , 0.95294117647058818, 0.85882352941176465), (0.8 , 0.92156862745098034, 0.77254901960784317), (0.6588235294117647 , 0.8666666666666667 , 0.70980392156862748), (0.4823529411764706 , 0.8 , 0.7686274509803922 ), (0.30588235294117649, 0.70196078431372544, 0.82745098039215681), (0.16862745098039217, 0.5490196078431373 , 0.74509803921568629), (0.03137254901960784, 0.40784313725490196, 0.67450980392156867), (0.03137254901960784, 0.25098039215686274, 0.50588235294117645) ) _Greens_data = ( (0.96862745098039216, 0.9882352941176471 , 0.96078431372549022), (0.89803921568627454, 0.96078431372549022, 0.8784313725490196 ), (0.7803921568627451 , 0.9137254901960784 , 0.75294117647058822), (0.63137254901960782, 0.85098039215686272, 0.60784313725490191), (0.45490196078431372, 0.7686274509803922 , 0.46274509803921571), (0.25490196078431371, 0.6705882352941176 , 0.36470588235294116), (0.13725490196078433, 0.54509803921568623, 0.27058823529411763), (0.0 , 0.42745098039215684, 0.17254901960784313), (0.0 , 0.26666666666666666, 0.10588235294117647) ) _Greys_data = ( (1.0 , 1.0 , 1.0 ), (0.94117647058823528, 0.94117647058823528, 0.94117647058823528), (0.85098039215686272, 0.85098039215686272, 0.85098039215686272), (0.74117647058823533, 0.74117647058823533, 0.74117647058823533), (0.58823529411764708, 0.58823529411764708, 0.58823529411764708), (0.45098039215686275, 0.45098039215686275, 0.45098039215686275), (0.32156862745098042, 0.32156862745098042, 0.32156862745098042), (0.14509803921568629, 0.14509803921568629, 0.14509803921568629), (0.0 , 0.0 , 0.0 ) ) _Oranges_data = ( (1.0 , 0.96078431372549022, 0.92156862745098034), (0.99607843137254903, 0.90196078431372551, 0.80784313725490198), (0.99215686274509807, 0.81568627450980391, 0.63529411764705879), (0.99215686274509807, 0.68235294117647061, 0.41960784313725491), (0.99215686274509807, 0.55294117647058827, 0.23529411764705882), (0.94509803921568625, 0.41176470588235292, 0.07450980392156863), (0.85098039215686272, 0.28235294117647058, 0.00392156862745098), (0.65098039215686276, 0.21176470588235294, 0.01176470588235294), (0.49803921568627452, 0.15294117647058825, 0.01568627450980392) ) _OrRd_data = ( (1.0 , 0.96862745098039216, 0.92549019607843142), (0.99607843137254903, 0.90980392156862744, 0.78431372549019607), (0.99215686274509807, 0.83137254901960789, 0.61960784313725492), (0.99215686274509807, 0.73333333333333328, 0.51764705882352946), (0.9882352941176471 , 0.55294117647058827, 0.34901960784313724), (0.93725490196078431, 0.396078431372549 , 0.28235294117647058), (0.84313725490196079, 0.18823529411764706, 0.12156862745098039), (0.70196078431372544, 0.0 , 0.0 ), (0.49803921568627452, 0.0 , 0.0 ) ) _PiYG_data = ( (0.55686274509803924, 0.00392156862745098, 0.32156862745098042), (0.77254901960784317, 0.10588235294117647, 0.49019607843137253), (0.87058823529411766, 0.46666666666666667, 0.68235294117647061), (0.94509803921568625, 0.71372549019607845, 0.85490196078431369), (0.99215686274509807, 0.8784313725490196 , 0.93725490196078431), (0.96862745098039216, 0.96862745098039216, 0.96862745098039216), (0.90196078431372551, 0.96078431372549022, 0.81568627450980391), (0.72156862745098038, 0.88235294117647056, 0.52549019607843139), (0.49803921568627452, 0.73725490196078436, 0.25490196078431371), (0.30196078431372547, 0.5725490196078431 , 0.12941176470588237), (0.15294117647058825, 0.39215686274509803, 0.09803921568627451) ) _PRGn_data = ( (0.25098039215686274, 0.0 , 0.29411764705882354), (0.46274509803921571, 0.16470588235294117, 0.51372549019607838), (0.6 , 0.4392156862745098 , 0.6705882352941176 ), (0.76078431372549016, 0.6470588235294118 , 0.81176470588235294), (0.90588235294117647, 0.83137254901960789, 0.90980392156862744), (0.96862745098039216, 0.96862745098039216, 0.96862745098039216), (0.85098039215686272, 0.94117647058823528, 0.82745098039215681), (0.65098039215686276, 0.85882352941176465, 0.62745098039215685), (0.35294117647058826, 0.68235294117647061, 0.38039215686274508), (0.10588235294117647, 0.47058823529411764, 0.21568627450980393), (0.0 , 0.26666666666666666, 0.10588235294117647) ) _PuBu_data = ( (1.0 , 0.96862745098039216, 0.98431372549019602), (0.92549019607843142, 0.90588235294117647, 0.94901960784313721), (0.81568627450980391, 0.81960784313725488, 0.90196078431372551), (0.65098039215686276, 0.74117647058823533, 0.85882352941176465), (0.45490196078431372, 0.66274509803921566, 0.81176470588235294), (0.21176470588235294, 0.56470588235294117, 0.75294117647058822), (0.0196078431372549 , 0.4392156862745098 , 0.69019607843137254), (0.01568627450980392, 0.35294117647058826, 0.55294117647058827), (0.00784313725490196, 0.2196078431372549 , 0.34509803921568627) ) _PuBuGn_data = ( (1.0 , 0.96862745098039216, 0.98431372549019602), (0.92549019607843142, 0.88627450980392153, 0.94117647058823528), (0.81568627450980391, 0.81960784313725488, 0.90196078431372551), (0.65098039215686276, 0.74117647058823533, 0.85882352941176465), (0.40392156862745099, 0.66274509803921566, 0.81176470588235294), (0.21176470588235294, 0.56470588235294117, 0.75294117647058822), (0.00784313725490196, 0.50588235294117645, 0.54117647058823526), (0.00392156862745098, 0.42352941176470588, 0.34901960784313724), (0.00392156862745098, 0.27450980392156865, 0.21176470588235294) ) _PuOr_data = ( (0.49803921568627452, 0.23137254901960785, 0.03137254901960784), (0.70196078431372544, 0.34509803921568627, 0.02352941176470588), (0.8784313725490196 , 0.50980392156862742, 0.07843137254901961), (0.99215686274509807, 0.72156862745098038, 0.38823529411764707), (0.99607843137254903, 0.8784313725490196 , 0.71372549019607845), (0.96862745098039216, 0.96862745098039216, 0.96862745098039216), (0.84705882352941175, 0.85490196078431369, 0.92156862745098034), (0.69803921568627447, 0.6705882352941176 , 0.82352941176470584), (0.50196078431372548, 0.45098039215686275, 0.67450980392156867), (0.32941176470588235, 0.15294117647058825, 0.53333333333333333), (0.17647058823529413, 0.0 , 0.29411764705882354) ) _PuRd_data = ( (0.96862745098039216, 0.95686274509803926, 0.97647058823529409), (0.90588235294117647, 0.88235294117647056, 0.93725490196078431), (0.83137254901960789, 0.72549019607843135, 0.85490196078431369), (0.78823529411764703, 0.58039215686274515, 0.7803921568627451 ), (0.87450980392156863, 0.396078431372549 , 0.69019607843137254), (0.90588235294117647, 0.16078431372549021, 0.54117647058823526), (0.80784313725490198, 0.07058823529411765, 0.33725490196078434), (0.59607843137254901, 0.0 , 0.2627450980392157 ), (0.40392156862745099, 0.0 , 0.12156862745098039) ) _Purples_data = ( (0.9882352941176471 , 0.98431372549019602, 0.99215686274509807), (0.93725490196078431, 0.92941176470588238, 0.96078431372549022), (0.85490196078431369, 0.85490196078431369, 0.92156862745098034), (0.73725490196078436, 0.74117647058823533, 0.86274509803921573), (0.61960784313725492, 0.60392156862745094, 0.78431372549019607), (0.50196078431372548, 0.49019607843137253, 0.72941176470588232), (0.41568627450980394, 0.31764705882352939, 0.63921568627450975), (0.32941176470588235, 0.15294117647058825, 0.5607843137254902 ), (0.24705882352941178, 0.0 , 0.49019607843137253) ) _RdBu_data = ( (0.40392156862745099, 0.0 , 0.12156862745098039), (0.69803921568627447, 0.09411764705882353, 0.16862745098039217), (0.83921568627450982, 0.37647058823529411, 0.30196078431372547), (0.95686274509803926, 0.6470588235294118 , 0.50980392156862742), (0.99215686274509807, 0.85882352941176465, 0.7803921568627451 ), (0.96862745098039216, 0.96862745098039216, 0.96862745098039216), (0.81960784313725488, 0.89803921568627454, 0.94117647058823528), (0.5725490196078431 , 0.77254901960784317, 0.87058823529411766), (0.2627450980392157 , 0.57647058823529407, 0.76470588235294112), (0.12941176470588237, 0.4 , 0.67450980392156867), (0.0196078431372549 , 0.18823529411764706, 0.38039215686274508) ) _RdGy_data = ( (0.40392156862745099, 0.0 , 0.12156862745098039), (0.69803921568627447, 0.09411764705882353, 0.16862745098039217), (0.83921568627450982, 0.37647058823529411, 0.30196078431372547), (0.95686274509803926, 0.6470588235294118 , 0.50980392156862742), (0.99215686274509807, 0.85882352941176465, 0.7803921568627451 ), (1.0 , 1.0 , 1.0 ), (0.8784313725490196 , 0.8784313725490196 , 0.8784313725490196 ), (0.72941176470588232, 0.72941176470588232, 0.72941176470588232), (0.52941176470588236, 0.52941176470588236, 0.52941176470588236), (0.30196078431372547, 0.30196078431372547, 0.30196078431372547), (0.10196078431372549, 0.10196078431372549, 0.10196078431372549) ) _RdPu_data = ( (1.0 , 0.96862745098039216, 0.95294117647058818), (0.99215686274509807, 0.8784313725490196 , 0.86666666666666667), (0.9882352941176471 , 0.77254901960784317, 0.75294117647058822), (0.98039215686274506, 0.62352941176470589, 0.70980392156862748), (0.96862745098039216, 0.40784313725490196, 0.63137254901960782), (0.86666666666666667, 0.20392156862745098, 0.59215686274509804), (0.68235294117647061, 0.00392156862745098, 0.49411764705882355), (0.47843137254901963, 0.00392156862745098, 0.46666666666666667), (0.28627450980392155, 0.0 , 0.41568627450980394) ) _RdYlBu_data = ( (0.6470588235294118 , 0.0 , 0.14901960784313725), (0.84313725490196079, 0.18823529411764706 , 0.15294117647058825), (0.95686274509803926, 0.42745098039215684 , 0.2627450980392157 ), (0.99215686274509807, 0.68235294117647061 , 0.38039215686274508), (0.99607843137254903, 0.8784313725490196 , 0.56470588235294117), (1.0 , 1.0 , 0.74901960784313726), (0.8784313725490196 , 0.95294117647058818 , 0.97254901960784312), (0.6705882352941176 , 0.85098039215686272 , 0.9137254901960784 ), (0.45490196078431372, 0.67843137254901964 , 0.81960784313725488), (0.27058823529411763, 0.45882352941176469 , 0.70588235294117652), (0.19215686274509805, 0.21176470588235294 , 0.58431372549019611) ) _RdYlGn_data = ( (0.6470588235294118 , 0.0 , 0.14901960784313725), (0.84313725490196079, 0.18823529411764706 , 0.15294117647058825), (0.95686274509803926, 0.42745098039215684 , 0.2627450980392157 ), (0.99215686274509807, 0.68235294117647061 , 0.38039215686274508), (0.99607843137254903, 0.8784313725490196 , 0.54509803921568623), (1.0 , 1.0 , 0.74901960784313726), (0.85098039215686272, 0.93725490196078431 , 0.54509803921568623), (0.65098039215686276, 0.85098039215686272 , 0.41568627450980394), (0.4 , 0.74117647058823533 , 0.38823529411764707), (0.10196078431372549, 0.59607843137254901 , 0.31372549019607843), (0.0 , 0.40784313725490196 , 0.21568627450980393) ) _Reds_data = ( (1.0 , 0.96078431372549022 , 0.94117647058823528), (0.99607843137254903, 0.8784313725490196 , 0.82352941176470584), (0.9882352941176471 , 0.73333333333333328 , 0.63137254901960782), (0.9882352941176471 , 0.5725490196078431 , 0.44705882352941179), (0.98431372549019602, 0.41568627450980394 , 0.29019607843137257), (0.93725490196078431, 0.23137254901960785 , 0.17254901960784313), (0.79607843137254897, 0.094117647058823528, 0.11372549019607843), (0.6470588235294118 , 0.058823529411764705, 0.08235294117647058), (0.40392156862745099, 0.0 , 0.05098039215686274) ) _Spectral_data = ( (0.61960784313725492, 0.003921568627450980, 0.25882352941176473), (0.83529411764705885, 0.24313725490196078 , 0.30980392156862746), (0.95686274509803926, 0.42745098039215684 , 0.2627450980392157 ), (0.99215686274509807, 0.68235294117647061 , 0.38039215686274508), (0.99607843137254903, 0.8784313725490196 , 0.54509803921568623), (1.0 , 1.0 , 0.74901960784313726), (0.90196078431372551, 0.96078431372549022 , 0.59607843137254901), (0.6705882352941176 , 0.8666666666666667 , 0.64313725490196083), (0.4 , 0.76078431372549016 , 0.6470588235294118 ), (0.19607843137254902, 0.53333333333333333 , 0.74117647058823533), (0.36862745098039218, 0.30980392156862746 , 0.63529411764705879) ) _YlGn_data = ( (1.0 , 1.0 , 0.89803921568627454), (0.96862745098039216, 0.9882352941176471 , 0.72549019607843135), (0.85098039215686272, 0.94117647058823528 , 0.63921568627450975), (0.67843137254901964, 0.8666666666666667 , 0.55686274509803924), (0.47058823529411764, 0.77647058823529413 , 0.47450980392156861), (0.25490196078431371, 0.6705882352941176 , 0.36470588235294116), (0.13725490196078433, 0.51764705882352946 , 0.2627450980392157 ), (0.0 , 0.40784313725490196 , 0.21568627450980393), (0.0 , 0.27058823529411763 , 0.16078431372549021) ) _YlGnBu_data = ( (1.0 , 1.0 , 0.85098039215686272), (0.92941176470588238, 0.97254901960784312 , 0.69411764705882351), (0.7803921568627451 , 0.9137254901960784 , 0.70588235294117652), (0.49803921568627452, 0.80392156862745101 , 0.73333333333333328), (0.25490196078431371, 0.71372549019607845 , 0.7686274509803922 ), (0.11372549019607843, 0.56862745098039214 , 0.75294117647058822), (0.13333333333333333, 0.36862745098039218 , 0.6588235294117647 ), (0.14509803921568629, 0.20392156862745098 , 0.58039215686274515), (0.03137254901960784, 0.11372549019607843 , 0.34509803921568627) ) _YlOrBr_data = ( (1.0 , 1.0 , 0.89803921568627454), (1.0 , 0.96862745098039216 , 0.73725490196078436), (0.99607843137254903, 0.8901960784313725 , 0.56862745098039214), (0.99607843137254903, 0.7686274509803922 , 0.30980392156862746), (0.99607843137254903, 0.6 , 0.16078431372549021), (0.92549019607843142, 0.4392156862745098 , 0.07843137254901961), (0.8 , 0.29803921568627451 , 0.00784313725490196), (0.6 , 0.20392156862745098 , 0.01568627450980392), (0.4 , 0.14509803921568629 , 0.02352941176470588) ) _YlOrRd_data = ( (1.0 , 1.0 , 0.8 ), (1.0 , 0.92941176470588238 , 0.62745098039215685), (0.99607843137254903, 0.85098039215686272 , 0.46274509803921571), (0.99607843137254903, 0.69803921568627447 , 0.29803921568627451), (0.99215686274509807, 0.55294117647058827 , 0.23529411764705882), (0.9882352941176471 , 0.30588235294117649 , 0.16470588235294117), (0.8901960784313725 , 0.10196078431372549 , 0.10980392156862745), (0.74117647058823533, 0.0 , 0.14901960784313725), (0.50196078431372548, 0.0 , 0.14901960784313725) ) # ColorBrewer's qualitative maps, implemented using ListedColormap # for use with mpl.colors.NoNorm _Accent_data = ( (0.49803921568627452, 0.78823529411764703, 0.49803921568627452), (0.74509803921568629, 0.68235294117647061, 0.83137254901960789), (0.99215686274509807, 0.75294117647058822, 0.52549019607843139), (1.0, 1.0, 0.6 ), (0.2196078431372549, 0.42352941176470588, 0.69019607843137254), (0.94117647058823528, 0.00784313725490196, 0.49803921568627452), (0.74901960784313726, 0.35686274509803922, 0.09019607843137254), (0.4, 0.4, 0.4 ), ) _Dark2_data = ( (0.10588235294117647, 0.61960784313725492, 0.46666666666666667), (0.85098039215686272, 0.37254901960784315, 0.00784313725490196), (0.45882352941176469, 0.4392156862745098, 0.70196078431372544), (0.90588235294117647, 0.16078431372549021, 0.54117647058823526), (0.4, 0.65098039215686276, 0.11764705882352941), (0.90196078431372551, 0.6705882352941176, 0.00784313725490196), (0.65098039215686276, 0.46274509803921571, 0.11372549019607843), (0.4, 0.4, 0.4 ), ) _Paired_data = ( (0.65098039215686276, 0.80784313725490198, 0.8901960784313725 ), (0.12156862745098039, 0.47058823529411764, 0.70588235294117652), (0.69803921568627447, 0.87450980392156863, 0.54117647058823526), (0.2, 0.62745098039215685, 0.17254901960784313), (0.98431372549019602, 0.60392156862745094, 0.6 ), (0.8901960784313725, 0.10196078431372549, 0.10980392156862745), (0.99215686274509807, 0.74901960784313726, 0.43529411764705883), (1.0, 0.49803921568627452, 0.0 ), (0.792156862745098, 0.69803921568627447, 0.83921568627450982), (0.41568627450980394, 0.23921568627450981, 0.60392156862745094), (1.0, 1.0, 0.6 ), (0.69411764705882351, 0.34901960784313724, 0.15686274509803921), ) _Pastel1_data = ( (0.98431372549019602, 0.70588235294117652, 0.68235294117647061), (0.70196078431372544, 0.80392156862745101, 0.8901960784313725 ), (0.8, 0.92156862745098034, 0.77254901960784317), (0.87058823529411766, 0.79607843137254897, 0.89411764705882357), (0.99607843137254903, 0.85098039215686272, 0.65098039215686276), (1.0, 1.0, 0.8 ), (0.89803921568627454, 0.84705882352941175, 0.74117647058823533), (0.99215686274509807, 0.85490196078431369, 0.92549019607843142), (0.94901960784313721, 0.94901960784313721, 0.94901960784313721), ) _Pastel2_data = ( (0.70196078431372544, 0.88627450980392153, 0.80392156862745101), (0.99215686274509807, 0.80392156862745101, 0.67450980392156867), (0.79607843137254897, 0.83529411764705885, 0.90980392156862744), (0.95686274509803926, 0.792156862745098, 0.89411764705882357), (0.90196078431372551, 0.96078431372549022, 0.78823529411764703), (1.0, 0.94901960784313721, 0.68235294117647061), (0.94509803921568625, 0.88627450980392153, 0.8 ), (0.8, 0.8, 0.8 ), ) _Set1_data = ( (0.89411764705882357, 0.10196078431372549, 0.10980392156862745), (0.21568627450980393, 0.49411764705882355, 0.72156862745098038), (0.30196078431372547, 0.68627450980392157, 0.29019607843137257), (0.59607843137254901, 0.30588235294117649, 0.63921568627450975), (1.0, 0.49803921568627452, 0.0 ), (1.0, 1.0, 0.2 ), (0.65098039215686276, 0.33725490196078434, 0.15686274509803921), (0.96862745098039216, 0.50588235294117645, 0.74901960784313726), (0.6, 0.6, 0.6), ) _Set2_data = ( (0.4, 0.76078431372549016, 0.6470588235294118 ), (0.9882352941176471, 0.55294117647058827, 0.3843137254901961 ), (0.55294117647058827, 0.62745098039215685, 0.79607843137254897), (0.90588235294117647, 0.54117647058823526, 0.76470588235294112), (0.65098039215686276, 0.84705882352941175, 0.32941176470588235), (1.0, 0.85098039215686272, 0.18431372549019609), (0.89803921568627454, 0.7686274509803922, 0.58039215686274515), (0.70196078431372544, 0.70196078431372544, 0.70196078431372544), ) _Set3_data = ( (0.55294117647058827, 0.82745098039215681, 0.7803921568627451 ), (1.0, 1.0, 0.70196078431372544), (0.74509803921568629, 0.72941176470588232, 0.85490196078431369), (0.98431372549019602, 0.50196078431372548, 0.44705882352941179), (0.50196078431372548, 0.69411764705882351, 0.82745098039215681), (0.99215686274509807, 0.70588235294117652, 0.3843137254901961 ), (0.70196078431372544, 0.87058823529411766, 0.41176470588235292), (0.9882352941176471, 0.80392156862745101, 0.89803921568627454), (0.85098039215686272, 0.85098039215686272, 0.85098039215686272), (0.73725490196078436, 0.50196078431372548, 0.74117647058823533), (0.8, 0.92156862745098034, 0.77254901960784317), (1.0, 0.92941176470588238, 0.43529411764705883), ) # The next 7 palettes are from the Yorick scientific visalisation package, # an evolution of the GIST package, both by David H. Munro. # They are released under a BSD-like license (see LICENSE_YORICK in # the license directory of the matplotlib source distribution). # # Most palette functions have been reduced to simple function descriptions # by Reinier Heeres, since the rgb components were mostly straight lines. # gist_earth_data and gist_ncar_data were simplified by a script and some # manual effort. _gist_earth_data = \ {'red': ( (0.0, 0.0, 0.0000), (0.2824, 0.1882, 0.1882), (0.4588, 0.2714, 0.2714), (0.5490, 0.4719, 0.4719), (0.6980, 0.7176, 0.7176), (0.7882, 0.7553, 0.7553), (1.0000, 0.9922, 0.9922), ), 'green': ( (0.0, 0.0, 0.0000), (0.0275, 0.0000, 0.0000), (0.1098, 0.1893, 0.1893), (0.1647, 0.3035, 0.3035), (0.2078, 0.3841, 0.3841), (0.2824, 0.5020, 0.5020), (0.5216, 0.6397, 0.6397), (0.6980, 0.7171, 0.7171), (0.7882, 0.6392, 0.6392), (0.7922, 0.6413, 0.6413), (0.8000, 0.6447, 0.6447), (0.8078, 0.6481, 0.6481), (0.8157, 0.6549, 0.6549), (0.8667, 0.6991, 0.6991), (0.8745, 0.7103, 0.7103), (0.8824, 0.7216, 0.7216), (0.8902, 0.7323, 0.7323), (0.8980, 0.7430, 0.7430), (0.9412, 0.8275, 0.8275), (0.9569, 0.8635, 0.8635), (0.9647, 0.8816, 0.8816), (0.9961, 0.9733, 0.9733), (1.0000, 0.9843, 0.9843), ), 'blue': ( (0.0, 0.0, 0.0000), (0.0039, 0.1684, 0.1684), (0.0078, 0.2212, 0.2212), (0.0275, 0.4329, 0.4329), (0.0314, 0.4549, 0.4549), (0.2824, 0.5004, 0.5004), (0.4667, 0.2748, 0.2748), (0.5451, 0.3205, 0.3205), (0.7843, 0.3961, 0.3961), (0.8941, 0.6651, 0.6651), (1.0000, 0.9843, 0.9843), )} _gist_gray_data = { 'red': gfunc[3], 'green': gfunc[3], 'blue': gfunc[3], } def _gist_heat_red(x): return 1.5 * x def _gist_heat_green(x): return 2 * x - 1 def _gist_heat_blue(x): return 4 * x - 3 _gist_heat_data = { 'red': _gist_heat_red, 'green': _gist_heat_green, 'blue': _gist_heat_blue} _gist_ncar_data = \ {'red': ( (0.0, 0.0, 0.0000), (0.3098, 0.0000, 0.0000), (0.3725, 0.3993, 0.3993), (0.4235, 0.5003, 0.5003), (0.5333, 1.0000, 1.0000), (0.7922, 1.0000, 1.0000), (0.8471, 0.6218, 0.6218), (0.8980, 0.9235, 0.9235), (1.0000, 0.9961, 0.9961), ), 'green': ( (0.0, 0.0, 0.0000), (0.0510, 0.3722, 0.3722), (0.1059, 0.0000, 0.0000), (0.1569, 0.7202, 0.7202), (0.1608, 0.7537, 0.7537), (0.1647, 0.7752, 0.7752), (0.2157, 1.0000, 1.0000), (0.2588, 0.9804, 0.9804), (0.2706, 0.9804, 0.9804), (0.3176, 1.0000, 1.0000), (0.3686, 0.8081, 0.8081), (0.4275, 1.0000, 1.0000), (0.5216, 1.0000, 1.0000), (0.6314, 0.7292, 0.7292), (0.6863, 0.2796, 0.2796), (0.7451, 0.0000, 0.0000), (0.7922, 0.0000, 0.0000), (0.8431, 0.1753, 0.1753), (0.8980, 0.5000, 0.5000), (1.0000, 0.9725, 0.9725), ), 'blue': ( (0.0, 0.5020, 0.5020), (0.0510, 0.0222, 0.0222), (0.1098, 1.0000, 1.0000), (0.2039, 1.0000, 1.0000), (0.2627, 0.6145, 0.6145), (0.3216, 0.0000, 0.0000), (0.4157, 0.0000, 0.0000), (0.4745, 0.2342, 0.2342), (0.5333, 0.0000, 0.0000), (0.5804, 0.0000, 0.0000), (0.6314, 0.0549, 0.0549), (0.6902, 0.0000, 0.0000), (0.7373, 0.0000, 0.0000), (0.7922, 0.9738, 0.9738), (0.8000, 1.0000, 1.0000), (0.8431, 1.0000, 1.0000), (0.8980, 0.9341, 0.9341), (1.0000, 0.9961, 0.9961), )} _gist_rainbow_data = ( (0.000, (1.00, 0.00, 0.16)), (0.030, (1.00, 0.00, 0.00)), (0.215, (1.00, 1.00, 0.00)), (0.400, (0.00, 1.00, 0.00)), (0.586, (0.00, 1.00, 1.00)), (0.770, (0.00, 0.00, 1.00)), (0.954, (1.00, 0.00, 1.00)), (1.000, (1.00, 0.00, 0.75)) ) _gist_stern_data = { 'red': ( (0.000, 0.000, 0.000), (0.0547, 1.000, 1.000), (0.250, 0.027, 0.250), # (0.2500, 0.250, 0.250), (1.000, 1.000, 1.000)), 'green': ((0, 0, 0), (1, 1, 1)), 'blue': ( (0.000, 0.000, 0.000), (0.500, 1.000, 1.000), (0.735, 0.000, 0.000), (1.000, 1.000, 1.000)) } def _gist_yarg(x): return 1 - x _gist_yarg_data = {'red': _gist_yarg, 'green': _gist_yarg, 'blue': _gist_yarg} # This bipolar color map was generated from CoolWarmFloat33.csv of # "Diverging Color Maps for Scientific Visualization" by Kenneth Moreland. # _coolwarm_data = { 'red': [ (0.0, 0.2298057, 0.2298057), (0.03125, 0.26623388, 0.26623388), (0.0625, 0.30386891, 0.30386891), (0.09375, 0.342804478, 0.342804478), (0.125, 0.38301334, 0.38301334), (0.15625, 0.424369608, 0.424369608), (0.1875, 0.46666708, 0.46666708), (0.21875, 0.509635204, 0.509635204), (0.25, 0.552953156, 0.552953156), (0.28125, 0.596262162, 0.596262162), (0.3125, 0.639176211, 0.639176211), (0.34375, 0.681291281, 0.681291281), (0.375, 0.722193294, 0.722193294), (0.40625, 0.761464949, 0.761464949), (0.4375, 0.798691636, 0.798691636), (0.46875, 0.833466556, 0.833466556), (0.5, 0.865395197, 0.865395197), (0.53125, 0.897787179, 0.897787179), (0.5625, 0.924127593, 0.924127593), (0.59375, 0.944468518, 0.944468518), (0.625, 0.958852946, 0.958852946), (0.65625, 0.96732803, 0.96732803), (0.6875, 0.969954137, 0.969954137), (0.71875, 0.966811177, 0.966811177), (0.75, 0.958003065, 0.958003065), (0.78125, 0.943660866, 0.943660866), (0.8125, 0.923944917, 0.923944917), (0.84375, 0.89904617, 0.89904617), (0.875, 0.869186849, 0.869186849), (0.90625, 0.834620542, 0.834620542), (0.9375, 0.795631745, 0.795631745), (0.96875, 0.752534934, 0.752534934), (1.0, 0.705673158, 0.705673158)], 'green': [ (0.0, 0.298717966, 0.298717966), (0.03125, 0.353094838, 0.353094838), (0.0625, 0.406535296, 0.406535296), (0.09375, 0.458757618, 0.458757618), (0.125, 0.50941904, 0.50941904), (0.15625, 0.558148092, 0.558148092), (0.1875, 0.604562568, 0.604562568), (0.21875, 0.648280772, 0.648280772), (0.25, 0.688929332, 0.688929332), (0.28125, 0.726149107, 0.726149107), (0.3125, 0.759599947, 0.759599947), (0.34375, 0.788964712, 0.788964712), (0.375, 0.813952739, 0.813952739), (0.40625, 0.834302879, 0.834302879), (0.4375, 0.849786142, 0.849786142), (0.46875, 0.860207984, 0.860207984), (0.5, 0.86541021, 0.86541021), (0.53125, 0.848937047, 0.848937047), (0.5625, 0.827384882, 0.827384882), (0.59375, 0.800927443, 0.800927443), (0.625, 0.769767752, 0.769767752), (0.65625, 0.734132809, 0.734132809), (0.6875, 0.694266682, 0.694266682), (0.71875, 0.650421156, 0.650421156), (0.75, 0.602842431, 0.602842431), (0.78125, 0.551750968, 0.551750968), (0.8125, 0.49730856, 0.49730856), (0.84375, 0.439559467, 0.439559467), (0.875, 0.378313092, 0.378313092), (0.90625, 0.312874446, 0.312874446), (0.9375, 0.24128379, 0.24128379), (0.96875, 0.157246067, 0.157246067), (1.0, 0.01555616, 0.01555616)], 'blue': [ (0.0, 0.753683153, 0.753683153), (0.03125, 0.801466763, 0.801466763), (0.0625, 0.84495867, 0.84495867), (0.09375, 0.883725899, 0.883725899), (0.125, 0.917387822, 0.917387822), (0.15625, 0.945619588, 0.945619588), (0.1875, 0.968154911, 0.968154911), (0.21875, 0.98478814, 0.98478814), (0.25, 0.995375608, 0.995375608), (0.28125, 0.999836203, 0.999836203), (0.3125, 0.998151185, 0.998151185), (0.34375, 0.990363227, 0.990363227), (0.375, 0.976574709, 0.976574709), (0.40625, 0.956945269, 0.956945269), (0.4375, 0.931688648, 0.931688648), (0.46875, 0.901068838, 0.901068838), (0.5, 0.865395561, 0.865395561), (0.53125, 0.820880546, 0.820880546), (0.5625, 0.774508472, 0.774508472), (0.59375, 0.726736146, 0.726736146), (0.625, 0.678007945, 0.678007945), (0.65625, 0.628751763, 0.628751763), (0.6875, 0.579375448, 0.579375448), (0.71875, 0.530263762, 0.530263762), (0.75, 0.481775914, 0.481775914), (0.78125, 0.434243684, 0.434243684), (0.8125, 0.387970225, 0.387970225), (0.84375, 0.343229596, 0.343229596), (0.875, 0.300267182, 0.300267182), (0.90625, 0.259301199, 0.259301199), (0.9375, 0.220525627, 0.220525627), (0.96875, 0.184115123, 0.184115123), (1.0, 0.150232812, 0.150232812)] } # Implementation of Carey Rappaport's CMRmap. # See `A Color Map for Effective Black-and-White Rendering of Color-Scale # Images' by Carey Rappaport # http://www.mathworks.com/matlabcentral/fileexchange/2662-cmrmap-m _CMRmap_data = {'red': ((0.000, 0.00, 0.00), (0.125, 0.15, 0.15), (0.250, 0.30, 0.30), (0.375, 0.60, 0.60), (0.500, 1.00, 1.00), (0.625, 0.90, 0.90), (0.750, 0.90, 0.90), (0.875, 0.90, 0.90), (1.000, 1.00, 1.00)), 'green': ((0.000, 0.00, 0.00), (0.125, 0.15, 0.15), (0.250, 0.15, 0.15), (0.375, 0.20, 0.20), (0.500, 0.25, 0.25), (0.625, 0.50, 0.50), (0.750, 0.75, 0.75), (0.875, 0.90, 0.90), (1.000, 1.00, 1.00)), 'blue': ((0.000, 0.00, 0.00), (0.125, 0.50, 0.50), (0.250, 0.75, 0.75), (0.375, 0.50, 0.50), (0.500, 0.15, 0.15), (0.625, 0.00, 0.00), (0.750, 0.10, 0.10), (0.875, 0.50, 0.50), (1.000, 1.00, 1.00))} # An MIT licensed, colorblind-friendly heatmap from Wistia: # https://github.com/wistia/heatmap-palette # http://wistia.com/blog/heatmaps-for-colorblindness # # >>> import matplotlib.colors as c # >>> colors = ["#e4ff7a", "#ffe81a", "#ffbd00", "#ffa000", "#fc7f00"] # >>> cm = c.LinearSegmentedColormap.from_list('wistia', colors) # >>> _wistia_data = cm._segmentdata # >>> del _wistia_data['alpha'] # _wistia_data = { 'red': [(0.0, 0.8941176470588236, 0.8941176470588236), (0.25, 1.0, 1.0), (0.5, 1.0, 1.0), (0.75, 1.0, 1.0), (1.0, 0.9882352941176471, 0.9882352941176471)], 'green': [(0.0, 1.0, 1.0), (0.25, 0.9098039215686274, 0.9098039215686274), (0.5, 0.7411764705882353, 0.7411764705882353), (0.75, 0.6274509803921569, 0.6274509803921569), (1.0, 0.4980392156862745, 0.4980392156862745)], 'blue': [(0.0, 0.47843137254901963, 0.47843137254901963), (0.25, 0.10196078431372549, 0.10196078431372549), (0.5, 0.0, 0.0), (0.75, 0.0, 0.0), (1.0, 0.0, 0.0)], } # Categorical palettes from Vega: # https://github.com/vega/vega/wiki/Scales # (divided by 255) # _tab10_data = ( (0.12156862745098039, 0.4666666666666667, 0.7058823529411765 ), # 1f77b4 (1.0, 0.4980392156862745, 0.054901960784313725), # ff7f0e (0.17254901960784313, 0.6274509803921569, 0.17254901960784313 ), # 2ca02c (0.8392156862745098, 0.15294117647058825, 0.1568627450980392 ), # d62728 (0.5803921568627451, 0.403921568627451, 0.7411764705882353 ), # 9467bd (0.5490196078431373, 0.33725490196078434, 0.29411764705882354 ), # 8c564b (0.8901960784313725, 0.4666666666666667, 0.7607843137254902 ), # e377c2 (0.4980392156862745, 0.4980392156862745, 0.4980392156862745 ), # 7f7f7f (0.7372549019607844, 0.7411764705882353, 0.13333333333333333 ), # bcbd22 (0.09019607843137255, 0.7450980392156863, 0.8117647058823529), # 17becf ) _tab20_data = ( (0.12156862745098039, 0.4666666666666667, 0.7058823529411765 ), # 1f77b4 (0.6823529411764706, 0.7803921568627451, 0.9098039215686274 ), # aec7e8 (1.0, 0.4980392156862745, 0.054901960784313725), # ff7f0e (1.0, 0.7333333333333333, 0.47058823529411764 ), # ffbb78 (0.17254901960784313, 0.6274509803921569, 0.17254901960784313 ), # 2ca02c (0.596078431372549, 0.8745098039215686, 0.5411764705882353 ), # 98df8a (0.8392156862745098, 0.15294117647058825, 0.1568627450980392 ), # d62728 (1.0, 0.596078431372549, 0.5882352941176471 ), # ff9896 (0.5803921568627451, 0.403921568627451, 0.7411764705882353 ), # 9467bd (0.7725490196078432, 0.6901960784313725, 0.8352941176470589 ), # c5b0d5 (0.5490196078431373, 0.33725490196078434, 0.29411764705882354 ), # 8c564b (0.7686274509803922, 0.611764705882353, 0.5803921568627451 ), # c49c94 (0.8901960784313725, 0.4666666666666667, 0.7607843137254902 ), # e377c2 (0.9686274509803922, 0.7137254901960784, 0.8235294117647058 ), # f7b6d2 (0.4980392156862745, 0.4980392156862745, 0.4980392156862745 ), # 7f7f7f (0.7803921568627451, 0.7803921568627451, 0.7803921568627451 ), # c7c7c7 (0.7372549019607844, 0.7411764705882353, 0.13333333333333333 ), # bcbd22 (0.8588235294117647, 0.8588235294117647, 0.5529411764705883 ), # dbdb8d (0.09019607843137255, 0.7450980392156863, 0.8117647058823529 ), # 17becf (0.6196078431372549, 0.8549019607843137, 0.8980392156862745), # 9edae5 ) _tab20b_data = ( (0.2235294117647059, 0.23137254901960785, 0.4745098039215686 ), # 393b79 (0.3215686274509804, 0.32941176470588235, 0.6392156862745098 ), # 5254a3 (0.4196078431372549, 0.43137254901960786, 0.8117647058823529 ), # 6b6ecf (0.611764705882353, 0.6196078431372549, 0.8705882352941177 ), # 9c9ede (0.38823529411764707, 0.4745098039215686, 0.2235294117647059 ), # 637939 (0.5490196078431373, 0.6352941176470588, 0.3215686274509804 ), # 8ca252 (0.7098039215686275, 0.8117647058823529, 0.4196078431372549 ), # b5cf6b (0.807843137254902, 0.8588235294117647, 0.611764705882353 ), # cedb9c (0.5490196078431373, 0.42745098039215684, 0.19215686274509805), # 8c6d31 (0.7411764705882353, 0.6196078431372549, 0.2235294117647059 ), # bd9e39 (0.9058823529411765, 0.7294117647058823, 0.3215686274509804 ), # e7ba52 (0.9058823529411765, 0.796078431372549, 0.5803921568627451 ), # e7cb94 (0.5176470588235295, 0.23529411764705882, 0.2235294117647059 ), # 843c39 (0.6784313725490196, 0.28627450980392155, 0.2901960784313726 ), # ad494a (0.8392156862745098, 0.3803921568627451, 0.4196078431372549 ), # d6616b (0.9058823529411765, 0.5882352941176471, 0.611764705882353 ), # e7969c (0.4823529411764706, 0.2549019607843137, 0.45098039215686275), # 7b4173 (0.6470588235294118, 0.3176470588235294, 0.5803921568627451 ), # a55194 (0.807843137254902, 0.42745098039215684, 0.7411764705882353 ), # ce6dbd (0.8705882352941177, 0.6196078431372549, 0.8392156862745098 ), # de9ed6 ) _tab20c_data = ( (0.19215686274509805, 0.5098039215686274, 0.7411764705882353 ), # 3182bd (0.4196078431372549, 0.6823529411764706, 0.8392156862745098 ), # 6baed6 (0.6196078431372549, 0.792156862745098, 0.8823529411764706 ), # 9ecae1 (0.7764705882352941, 0.8588235294117647, 0.9372549019607843 ), # c6dbef (0.9019607843137255, 0.3333333333333333, 0.050980392156862744), # e6550d (0.9921568627450981, 0.5529411764705883, 0.23529411764705882 ), # fd8d3c (0.9921568627450981, 0.6823529411764706, 0.4196078431372549 ), # fdae6b (0.9921568627450981, 0.8156862745098039, 0.6352941176470588 ), # fdd0a2 (0.19215686274509805, 0.6392156862745098, 0.32941176470588235 ), # 31a354 (0.4549019607843137, 0.7686274509803922, 0.4627450980392157 ), # 74c476 (0.6313725490196078, 0.8509803921568627, 0.6078431372549019 ), # a1d99b (0.7803921568627451, 0.9137254901960784, 0.7529411764705882 ), # c7e9c0 (0.4588235294117647, 0.4196078431372549, 0.6941176470588235 ), # 756bb1 (0.6196078431372549, 0.6039215686274509, 0.7843137254901961 ), # 9e9ac8 (0.7372549019607844, 0.7411764705882353, 0.8627450980392157 ), # bcbddc (0.8549019607843137, 0.8549019607843137, 0.9215686274509803 ), # dadaeb (0.38823529411764707, 0.38823529411764707, 0.38823529411764707 ), # 636363 (0.5882352941176471, 0.5882352941176471, 0.5882352941176471 ), # 969696 (0.7411764705882353, 0.7411764705882353, 0.7411764705882353 ), # bdbdbd (0.8509803921568627, 0.8509803921568627, 0.8509803921568627 ), # d9d9d9 ) datad = { 'Blues': _Blues_data, 'BrBG': _BrBG_data, 'BuGn': _BuGn_data, 'BuPu': _BuPu_data, 'CMRmap': _CMRmap_data, 'GnBu': _GnBu_data, 'Greens': _Greens_data, 'Greys': _Greys_data, 'OrRd': _OrRd_data, 'Oranges': _Oranges_data, 'PRGn': _PRGn_data, 'PiYG': _PiYG_data, 'PuBu': _PuBu_data, 'PuBuGn': _PuBuGn_data, 'PuOr': _PuOr_data, 'PuRd': _PuRd_data, 'Purples': _Purples_data, 'RdBu': _RdBu_data, 'RdGy': _RdGy_data, 'RdPu': _RdPu_data, 'RdYlBu': _RdYlBu_data, 'RdYlGn': _RdYlGn_data, 'Reds': _Reds_data, 'Spectral': _Spectral_data, 'Wistia': _wistia_data, 'YlGn': _YlGn_data, 'YlGnBu': _YlGnBu_data, 'YlOrBr': _YlOrBr_data, 'YlOrRd': _YlOrRd_data, 'afmhot': _afmhot_data, 'autumn': _autumn_data, 'binary': _binary_data, 'bone': _bone_data, 'brg': _brg_data, 'bwr': _bwr_data, 'cool': _cool_data, 'coolwarm': _coolwarm_data, 'copper': _copper_data, 'cubehelix': _cubehelix_data, 'flag': _flag_data, 'gist_earth': _gist_earth_data, 'gist_gray': _gist_gray_data, 'gist_heat': _gist_heat_data, 'gist_ncar': _gist_ncar_data, 'gist_rainbow': _gist_rainbow_data, 'gist_stern': _gist_stern_data, 'gist_yarg': _gist_yarg_data, 'gnuplot': _gnuplot_data, 'gnuplot2': _gnuplot2_data, 'gray': _gray_data, 'hot': _hot_data, 'hsv': _hsv_data, 'jet': _jet_data, 'nipy_spectral': _nipy_spectral_data, 'ocean': _ocean_data, 'pink': _pink_data, 'prism': _prism_data, 'rainbow': _rainbow_data, 'seismic': _seismic_data, 'spring': _spring_data, 'summer': _summer_data, 'terrain': _terrain_data, 'winter': _winter_data, # Qualitative 'Accent': {'listed': _Accent_data}, 'Dark2': {'listed': _Dark2_data}, 'Paired': {'listed': _Paired_data}, 'Pastel1': {'listed': _Pastel1_data}, 'Pastel2': {'listed': _Pastel2_data}, 'Set1': {'listed': _Set1_data}, 'Set2': {'listed': _Set2_data}, 'Set3': {'listed': _Set3_data}, 'tab10': {'listed': _tab10_data}, 'tab20': {'listed': _tab20_data}, 'tab20b': {'listed': _tab20b_data}, 'tab20c': {'listed': _tab20c_data}, } napari-0.5.0a1/napari/utils/colormaps/vendored/_cm_listed.py000066400000000000000000002605531437041365600241270ustar00rootroot00000000000000from napari.utils.colormaps.vendored.colors import ListedColormap _magma_data = [ [0.001462, 0.000466, 0.013866], [0.002258, 0.001295, 0.018331], [0.003279, 0.002305, 0.023708], [0.004512, 0.003490, 0.029965], [0.005950, 0.004843, 0.037130], [0.007588, 0.006356, 0.044973], [0.009426, 0.008022, 0.052844], [0.011465, 0.009828, 0.060750], [0.013708, 0.011771, 0.068667], [0.016156, 0.013840, 0.076603], [0.018815, 0.016026, 0.084584], [0.021692, 0.018320, 0.092610], [0.024792, 0.020715, 0.100676], [0.028123, 0.023201, 0.108787], [0.031696, 0.025765, 0.116965], [0.035520, 0.028397, 0.125209], [0.039608, 0.031090, 0.133515], [0.043830, 0.033830, 0.141886], [0.048062, 0.036607, 0.150327], [0.052320, 0.039407, 0.158841], [0.056615, 0.042160, 0.167446], [0.060949, 0.044794, 0.176129], [0.065330, 0.047318, 0.184892], [0.069764, 0.049726, 0.193735], [0.074257, 0.052017, 0.202660], [0.078815, 0.054184, 0.211667], [0.083446, 0.056225, 0.220755], [0.088155, 0.058133, 0.229922], [0.092949, 0.059904, 0.239164], [0.097833, 0.061531, 0.248477], [0.102815, 0.063010, 0.257854], [0.107899, 0.064335, 0.267289], [0.113094, 0.065492, 0.276784], [0.118405, 0.066479, 0.286321], [0.123833, 0.067295, 0.295879], [0.129380, 0.067935, 0.305443], [0.135053, 0.068391, 0.315000], [0.140858, 0.068654, 0.324538], [0.146785, 0.068738, 0.334011], [0.152839, 0.068637, 0.343404], [0.159018, 0.068354, 0.352688], [0.165308, 0.067911, 0.361816], [0.171713, 0.067305, 0.370771], [0.178212, 0.066576, 0.379497], [0.184801, 0.065732, 0.387973], [0.191460, 0.064818, 0.396152], [0.198177, 0.063862, 0.404009], [0.204935, 0.062907, 0.411514], [0.211718, 0.061992, 0.418647], [0.218512, 0.061158, 0.425392], [0.225302, 0.060445, 0.431742], [0.232077, 0.059889, 0.437695], [0.238826, 0.059517, 0.443256], [0.245543, 0.059352, 0.448436], [0.252220, 0.059415, 0.453248], [0.258857, 0.059706, 0.457710], [0.265447, 0.060237, 0.461840], [0.271994, 0.060994, 0.465660], [0.278493, 0.061978, 0.469190], [0.284951, 0.063168, 0.472451], [0.291366, 0.064553, 0.475462], [0.297740, 0.066117, 0.478243], [0.304081, 0.067835, 0.480812], [0.310382, 0.069702, 0.483186], [0.316654, 0.071690, 0.485380], [0.322899, 0.073782, 0.487408], [0.329114, 0.075972, 0.489287], [0.335308, 0.078236, 0.491024], [0.341482, 0.080564, 0.492631], [0.347636, 0.082946, 0.494121], [0.353773, 0.085373, 0.495501], [0.359898, 0.087831, 0.496778], [0.366012, 0.090314, 0.497960], [0.372116, 0.092816, 0.499053], [0.378211, 0.095332, 0.500067], [0.384299, 0.097855, 0.501002], [0.390384, 0.100379, 0.501864], [0.396467, 0.102902, 0.502658], [0.402548, 0.105420, 0.503386], [0.408629, 0.107930, 0.504052], [0.414709, 0.110431, 0.504662], [0.420791, 0.112920, 0.505215], [0.426877, 0.115395, 0.505714], [0.432967, 0.117855, 0.506160], [0.439062, 0.120298, 0.506555], [0.445163, 0.122724, 0.506901], [0.451271, 0.125132, 0.507198], [0.457386, 0.127522, 0.507448], [0.463508, 0.129893, 0.507652], [0.469640, 0.132245, 0.507809], [0.475780, 0.134577, 0.507921], [0.481929, 0.136891, 0.507989], [0.488088, 0.139186, 0.508011], [0.494258, 0.141462, 0.507988], [0.500438, 0.143719, 0.507920], [0.506629, 0.145958, 0.507806], [0.512831, 0.148179, 0.507648], [0.519045, 0.150383, 0.507443], [0.525270, 0.152569, 0.507192], [0.531507, 0.154739, 0.506895], [0.537755, 0.156894, 0.506551], [0.544015, 0.159033, 0.506159], [0.550287, 0.161158, 0.505719], [0.556571, 0.163269, 0.505230], [0.562866, 0.165368, 0.504692], [0.569172, 0.167454, 0.504105], [0.575490, 0.169530, 0.503466], [0.581819, 0.171596, 0.502777], [0.588158, 0.173652, 0.502035], [0.594508, 0.175701, 0.501241], [0.600868, 0.177743, 0.500394], [0.607238, 0.179779, 0.499492], [0.613617, 0.181811, 0.498536], [0.620005, 0.183840, 0.497524], [0.626401, 0.185867, 0.496456], [0.632805, 0.187893, 0.495332], [0.639216, 0.189921, 0.494150], [0.645633, 0.191952, 0.492910], [0.652056, 0.193986, 0.491611], [0.658483, 0.196027, 0.490253], [0.664915, 0.198075, 0.488836], [0.671349, 0.200133, 0.487358], [0.677786, 0.202203, 0.485819], [0.684224, 0.204286, 0.484219], [0.690661, 0.206384, 0.482558], [0.697098, 0.208501, 0.480835], [0.703532, 0.210638, 0.479049], [0.709962, 0.212797, 0.477201], [0.716387, 0.214982, 0.475290], [0.722805, 0.217194, 0.473316], [0.729216, 0.219437, 0.471279], [0.735616, 0.221713, 0.469180], [0.742004, 0.224025, 0.467018], [0.748378, 0.226377, 0.464794], [0.754737, 0.228772, 0.462509], [0.761077, 0.231214, 0.460162], [0.767398, 0.233705, 0.457755], [0.773695, 0.236249, 0.455289], [0.779968, 0.238851, 0.452765], [0.786212, 0.241514, 0.450184], [0.792427, 0.244242, 0.447543], [0.798608, 0.247040, 0.444848], [0.804752, 0.249911, 0.442102], [0.810855, 0.252861, 0.439305], [0.816914, 0.255895, 0.436461], [0.822926, 0.259016, 0.433573], [0.828886, 0.262229, 0.430644], [0.834791, 0.265540, 0.427671], [0.840636, 0.268953, 0.424666], [0.846416, 0.272473, 0.421631], [0.852126, 0.276106, 0.418573], [0.857763, 0.279857, 0.415496], [0.863320, 0.283729, 0.412403], [0.868793, 0.287728, 0.409303], [0.874176, 0.291859, 0.406205], [0.879464, 0.296125, 0.403118], [0.884651, 0.300530, 0.400047], [0.889731, 0.305079, 0.397002], [0.894700, 0.309773, 0.393995], [0.899552, 0.314616, 0.391037], [0.904281, 0.319610, 0.388137], [0.908884, 0.324755, 0.385308], [0.913354, 0.330052, 0.382563], [0.917689, 0.335500, 0.379915], [0.921884, 0.341098, 0.377376], [0.925937, 0.346844, 0.374959], [0.929845, 0.352734, 0.372677], [0.933606, 0.358764, 0.370541], [0.937221, 0.364929, 0.368567], [0.940687, 0.371224, 0.366762], [0.944006, 0.377643, 0.365136], [0.947180, 0.384178, 0.363701], [0.950210, 0.390820, 0.362468], [0.953099, 0.397563, 0.361438], [0.955849, 0.404400, 0.360619], [0.958464, 0.411324, 0.360014], [0.960949, 0.418323, 0.359630], [0.963310, 0.425390, 0.359469], [0.965549, 0.432519, 0.359529], [0.967671, 0.439703, 0.359810], [0.969680, 0.446936, 0.360311], [0.971582, 0.454210, 0.361030], [0.973381, 0.461520, 0.361965], [0.975082, 0.468861, 0.363111], [0.976690, 0.476226, 0.364466], [0.978210, 0.483612, 0.366025], [0.979645, 0.491014, 0.367783], [0.981000, 0.498428, 0.369734], [0.982279, 0.505851, 0.371874], [0.983485, 0.513280, 0.374198], [0.984622, 0.520713, 0.376698], [0.985693, 0.528148, 0.379371], [0.986700, 0.535582, 0.382210], [0.987646, 0.543015, 0.385210], [0.988533, 0.550446, 0.388365], [0.989363, 0.557873, 0.391671], [0.990138, 0.565296, 0.395122], [0.990871, 0.572706, 0.398714], [0.991558, 0.580107, 0.402441], [0.992196, 0.587502, 0.406299], [0.992785, 0.594891, 0.410283], [0.993326, 0.602275, 0.414390], [0.993834, 0.609644, 0.418613], [0.994309, 0.616999, 0.422950], [0.994738, 0.624350, 0.427397], [0.995122, 0.631696, 0.431951], [0.995480, 0.639027, 0.436607], [0.995810, 0.646344, 0.441361], [0.996096, 0.653659, 0.446213], [0.996341, 0.660969, 0.451160], [0.996580, 0.668256, 0.456192], [0.996775, 0.675541, 0.461314], [0.996925, 0.682828, 0.466526], [0.997077, 0.690088, 0.471811], [0.997186, 0.697349, 0.477182], [0.997254, 0.704611, 0.482635], [0.997325, 0.711848, 0.488154], [0.997351, 0.719089, 0.493755], [0.997351, 0.726324, 0.499428], [0.997341, 0.733545, 0.505167], [0.997285, 0.740772, 0.510983], [0.997228, 0.747981, 0.516859], [0.997138, 0.755190, 0.522806], [0.997019, 0.762398, 0.528821], [0.996898, 0.769591, 0.534892], [0.996727, 0.776795, 0.541039], [0.996571, 0.783977, 0.547233], [0.996369, 0.791167, 0.553499], [0.996162, 0.798348, 0.559820], [0.995932, 0.805527, 0.566202], [0.995680, 0.812706, 0.572645], [0.995424, 0.819875, 0.579140], [0.995131, 0.827052, 0.585701], [0.994851, 0.834213, 0.592307], [0.994524, 0.841387, 0.598983], [0.994222, 0.848540, 0.605696], [0.993866, 0.855711, 0.612482], [0.993545, 0.862859, 0.619299], [0.993170, 0.870024, 0.626189], [0.992831, 0.877168, 0.633109], [0.992440, 0.884330, 0.640099], [0.992089, 0.891470, 0.647116], [0.991688, 0.898627, 0.654202], [0.991332, 0.905763, 0.661309], [0.990930, 0.912915, 0.668481], [0.990570, 0.920049, 0.675675], [0.990175, 0.927196, 0.682926], [0.989815, 0.934329, 0.690198], [0.989434, 0.941470, 0.697519], [0.989077, 0.948604, 0.704863], [0.988717, 0.955742, 0.712242], [0.988367, 0.962878, 0.719649], [0.988033, 0.970012, 0.727077], [0.987691, 0.977154, 0.734536], [0.987387, 0.984288, 0.742002], [0.987053, 0.991438, 0.749504], ] _inferno_data = [ [0.001462, 0.000466, 0.013866], [0.002267, 0.001270, 0.018570], [0.003299, 0.002249, 0.024239], [0.004547, 0.003392, 0.030909], [0.006006, 0.004692, 0.038558], [0.007676, 0.006136, 0.046836], [0.009561, 0.007713, 0.055143], [0.011663, 0.009417, 0.063460], [0.013995, 0.011225, 0.071862], [0.016561, 0.013136, 0.080282], [0.019373, 0.015133, 0.088767], [0.022447, 0.017199, 0.097327], [0.025793, 0.019331, 0.105930], [0.029432, 0.021503, 0.114621], [0.033385, 0.023702, 0.123397], [0.037668, 0.025921, 0.132232], [0.042253, 0.028139, 0.141141], [0.046915, 0.030324, 0.150164], [0.051644, 0.032474, 0.159254], [0.056449, 0.034569, 0.168414], [0.061340, 0.036590, 0.177642], [0.066331, 0.038504, 0.186962], [0.071429, 0.040294, 0.196354], [0.076637, 0.041905, 0.205799], [0.081962, 0.043328, 0.215289], [0.087411, 0.044556, 0.224813], [0.092990, 0.045583, 0.234358], [0.098702, 0.046402, 0.243904], [0.104551, 0.047008, 0.253430], [0.110536, 0.047399, 0.262912], [0.116656, 0.047574, 0.272321], [0.122908, 0.047536, 0.281624], [0.129285, 0.047293, 0.290788], [0.135778, 0.046856, 0.299776], [0.142378, 0.046242, 0.308553], [0.149073, 0.045468, 0.317085], [0.155850, 0.044559, 0.325338], [0.162689, 0.043554, 0.333277], [0.169575, 0.042489, 0.340874], [0.176493, 0.041402, 0.348111], [0.183429, 0.040329, 0.354971], [0.190367, 0.039309, 0.361447], [0.197297, 0.038400, 0.367535], [0.204209, 0.037632, 0.373238], [0.211095, 0.037030, 0.378563], [0.217949, 0.036615, 0.383522], [0.224763, 0.036405, 0.388129], [0.231538, 0.036405, 0.392400], [0.238273, 0.036621, 0.396353], [0.244967, 0.037055, 0.400007], [0.251620, 0.037705, 0.403378], [0.258234, 0.038571, 0.406485], [0.264810, 0.039647, 0.409345], [0.271347, 0.040922, 0.411976], [0.277850, 0.042353, 0.414392], [0.284321, 0.043933, 0.416608], [0.290763, 0.045644, 0.418637], [0.297178, 0.047470, 0.420491], [0.303568, 0.049396, 0.422182], [0.309935, 0.051407, 0.423721], [0.316282, 0.053490, 0.425116], [0.322610, 0.055634, 0.426377], [0.328921, 0.057827, 0.427511], [0.335217, 0.060060, 0.428524], [0.341500, 0.062325, 0.429425], [0.347771, 0.064616, 0.430217], [0.354032, 0.066925, 0.430906], [0.360284, 0.069247, 0.431497], [0.366529, 0.071579, 0.431994], [0.372768, 0.073915, 0.432400], [0.379001, 0.076253, 0.432719], [0.385228, 0.078591, 0.432955], [0.391453, 0.080927, 0.433109], [0.397674, 0.083257, 0.433183], [0.403894, 0.085580, 0.433179], [0.410113, 0.087896, 0.433098], [0.416331, 0.090203, 0.432943], [0.422549, 0.092501, 0.432714], [0.428768, 0.094790, 0.432412], [0.434987, 0.097069, 0.432039], [0.441207, 0.099338, 0.431594], [0.447428, 0.101597, 0.431080], [0.453651, 0.103848, 0.430498], [0.459875, 0.106089, 0.429846], [0.466100, 0.108322, 0.429125], [0.472328, 0.110547, 0.428334], [0.478558, 0.112764, 0.427475], [0.484789, 0.114974, 0.426548], [0.491022, 0.117179, 0.425552], [0.497257, 0.119379, 0.424488], [0.503493, 0.121575, 0.423356], [0.509730, 0.123769, 0.422156], [0.515967, 0.125960, 0.420887], [0.522206, 0.128150, 0.419549], [0.528444, 0.130341, 0.418142], [0.534683, 0.132534, 0.416667], [0.540920, 0.134729, 0.415123], [0.547157, 0.136929, 0.413511], [0.553392, 0.139134, 0.411829], [0.559624, 0.141346, 0.410078], [0.565854, 0.143567, 0.408258], [0.572081, 0.145797, 0.406369], [0.578304, 0.148039, 0.404411], [0.584521, 0.150294, 0.402385], [0.590734, 0.152563, 0.400290], [0.596940, 0.154848, 0.398125], [0.603139, 0.157151, 0.395891], [0.609330, 0.159474, 0.393589], [0.615513, 0.161817, 0.391219], [0.621685, 0.164184, 0.388781], [0.627847, 0.166575, 0.386276], [0.633998, 0.168992, 0.383704], [0.640135, 0.171438, 0.381065], [0.646260, 0.173914, 0.378359], [0.652369, 0.176421, 0.375586], [0.658463, 0.178962, 0.372748], [0.664540, 0.181539, 0.369846], [0.670599, 0.184153, 0.366879], [0.676638, 0.186807, 0.363849], [0.682656, 0.189501, 0.360757], [0.688653, 0.192239, 0.357603], [0.694627, 0.195021, 0.354388], [0.700576, 0.197851, 0.351113], [0.706500, 0.200728, 0.347777], [0.712396, 0.203656, 0.344383], [0.718264, 0.206636, 0.340931], [0.724103, 0.209670, 0.337424], [0.729909, 0.212759, 0.333861], [0.735683, 0.215906, 0.330245], [0.741423, 0.219112, 0.326576], [0.747127, 0.222378, 0.322856], [0.752794, 0.225706, 0.319085], [0.758422, 0.229097, 0.315266], [0.764010, 0.232554, 0.311399], [0.769556, 0.236077, 0.307485], [0.775059, 0.239667, 0.303526], [0.780517, 0.243327, 0.299523], [0.785929, 0.247056, 0.295477], [0.791293, 0.250856, 0.291390], [0.796607, 0.254728, 0.287264], [0.801871, 0.258674, 0.283099], [0.807082, 0.262692, 0.278898], [0.812239, 0.266786, 0.274661], [0.817341, 0.270954, 0.270390], [0.822386, 0.275197, 0.266085], [0.827372, 0.279517, 0.261750], [0.832299, 0.283913, 0.257383], [0.837165, 0.288385, 0.252988], [0.841969, 0.292933, 0.248564], [0.846709, 0.297559, 0.244113], [0.851384, 0.302260, 0.239636], [0.855992, 0.307038, 0.235133], [0.860533, 0.311892, 0.230606], [0.865006, 0.316822, 0.226055], [0.869409, 0.321827, 0.221482], [0.873741, 0.326906, 0.216886], [0.878001, 0.332060, 0.212268], [0.882188, 0.337287, 0.207628], [0.886302, 0.342586, 0.202968], [0.890341, 0.347957, 0.198286], [0.894305, 0.353399, 0.193584], [0.898192, 0.358911, 0.188860], [0.902003, 0.364492, 0.184116], [0.905735, 0.370140, 0.179350], [0.909390, 0.375856, 0.174563], [0.912966, 0.381636, 0.169755], [0.916462, 0.387481, 0.164924], [0.919879, 0.393389, 0.160070], [0.923215, 0.399359, 0.155193], [0.926470, 0.405389, 0.150292], [0.929644, 0.411479, 0.145367], [0.932737, 0.417627, 0.140417], [0.935747, 0.423831, 0.135440], [0.938675, 0.430091, 0.130438], [0.941521, 0.436405, 0.125409], [0.944285, 0.442772, 0.120354], [0.946965, 0.449191, 0.115272], [0.949562, 0.455660, 0.110164], [0.952075, 0.462178, 0.105031], [0.954506, 0.468744, 0.099874], [0.956852, 0.475356, 0.094695], [0.959114, 0.482014, 0.089499], [0.961293, 0.488716, 0.084289], [0.963387, 0.495462, 0.079073], [0.965397, 0.502249, 0.073859], [0.967322, 0.509078, 0.068659], [0.969163, 0.515946, 0.063488], [0.970919, 0.522853, 0.058367], [0.972590, 0.529798, 0.053324], [0.974176, 0.536780, 0.048392], [0.975677, 0.543798, 0.043618], [0.977092, 0.550850, 0.039050], [0.978422, 0.557937, 0.034931], [0.979666, 0.565057, 0.031409], [0.980824, 0.572209, 0.028508], [0.981895, 0.579392, 0.026250], [0.982881, 0.586606, 0.024661], [0.983779, 0.593849, 0.023770], [0.984591, 0.601122, 0.023606], [0.985315, 0.608422, 0.024202], [0.985952, 0.615750, 0.025592], [0.986502, 0.623105, 0.027814], [0.986964, 0.630485, 0.030908], [0.987337, 0.637890, 0.034916], [0.987622, 0.645320, 0.039886], [0.987819, 0.652773, 0.045581], [0.987926, 0.660250, 0.051750], [0.987945, 0.667748, 0.058329], [0.987874, 0.675267, 0.065257], [0.987714, 0.682807, 0.072489], [0.987464, 0.690366, 0.079990], [0.987124, 0.697944, 0.087731], [0.986694, 0.705540, 0.095694], [0.986175, 0.713153, 0.103863], [0.985566, 0.720782, 0.112229], [0.984865, 0.728427, 0.120785], [0.984075, 0.736087, 0.129527], [0.983196, 0.743758, 0.138453], [0.982228, 0.751442, 0.147565], [0.981173, 0.759135, 0.156863], [0.980032, 0.766837, 0.166353], [0.978806, 0.774545, 0.176037], [0.977497, 0.782258, 0.185923], [0.976108, 0.789974, 0.196018], [0.974638, 0.797692, 0.206332], [0.973088, 0.805409, 0.216877], [0.971468, 0.813122, 0.227658], [0.969783, 0.820825, 0.238686], [0.968041, 0.828515, 0.249972], [0.966243, 0.836191, 0.261534], [0.964394, 0.843848, 0.273391], [0.962517, 0.851476, 0.285546], [0.960626, 0.859069, 0.298010], [0.958720, 0.866624, 0.310820], [0.956834, 0.874129, 0.323974], [0.954997, 0.881569, 0.337475], [0.953215, 0.888942, 0.351369], [0.951546, 0.896226, 0.365627], [0.950018, 0.903409, 0.380271], [0.948683, 0.910473, 0.395289], [0.947594, 0.917399, 0.410665], [0.946809, 0.924168, 0.426373], [0.946392, 0.930761, 0.442367], [0.946403, 0.937159, 0.458592], [0.946903, 0.943348, 0.474970], [0.947937, 0.949318, 0.491426], [0.949545, 0.955063, 0.507860], [0.951740, 0.960587, 0.524203], [0.954529, 0.965896, 0.540361], [0.957896, 0.971003, 0.556275], [0.961812, 0.975924, 0.571925], [0.966249, 0.980678, 0.587206], [0.971162, 0.985282, 0.602154], [0.976511, 0.989753, 0.616760], [0.982257, 0.994109, 0.631017], [0.988362, 0.998364, 0.644924], ] _plasma_data = [ [0.050383, 0.029803, 0.527975], [0.063536, 0.028426, 0.533124], [0.075353, 0.027206, 0.538007], [0.086222, 0.026125, 0.542658], [0.096379, 0.025165, 0.547103], [0.105980, 0.024309, 0.551368], [0.115124, 0.023556, 0.555468], [0.123903, 0.022878, 0.559423], [0.132381, 0.022258, 0.563250], [0.140603, 0.021687, 0.566959], [0.148607, 0.021154, 0.570562], [0.156421, 0.020651, 0.574065], [0.164070, 0.020171, 0.577478], [0.171574, 0.019706, 0.580806], [0.178950, 0.019252, 0.584054], [0.186213, 0.018803, 0.587228], [0.193374, 0.018354, 0.590330], [0.200445, 0.017902, 0.593364], [0.207435, 0.017442, 0.596333], [0.214350, 0.016973, 0.599239], [0.221197, 0.016497, 0.602083], [0.227983, 0.016007, 0.604867], [0.234715, 0.015502, 0.607592], [0.241396, 0.014979, 0.610259], [0.248032, 0.014439, 0.612868], [0.254627, 0.013882, 0.615419], [0.261183, 0.013308, 0.617911], [0.267703, 0.012716, 0.620346], [0.274191, 0.012109, 0.622722], [0.280648, 0.011488, 0.625038], [0.287076, 0.010855, 0.627295], [0.293478, 0.010213, 0.629490], [0.299855, 0.009561, 0.631624], [0.306210, 0.008902, 0.633694], [0.312543, 0.008239, 0.635700], [0.318856, 0.007576, 0.637640], [0.325150, 0.006915, 0.639512], [0.331426, 0.006261, 0.641316], [0.337683, 0.005618, 0.643049], [0.343925, 0.004991, 0.644710], [0.350150, 0.004382, 0.646298], [0.356359, 0.003798, 0.647810], [0.362553, 0.003243, 0.649245], [0.368733, 0.002724, 0.650601], [0.374897, 0.002245, 0.651876], [0.381047, 0.001814, 0.653068], [0.387183, 0.001434, 0.654177], [0.393304, 0.001114, 0.655199], [0.399411, 0.000859, 0.656133], [0.405503, 0.000678, 0.656977], [0.411580, 0.000577, 0.657730], [0.417642, 0.000564, 0.658390], [0.423689, 0.000646, 0.658956], [0.429719, 0.000831, 0.659425], [0.435734, 0.001127, 0.659797], [0.441732, 0.001540, 0.660069], [0.447714, 0.002080, 0.660240], [0.453677, 0.002755, 0.660310], [0.459623, 0.003574, 0.660277], [0.465550, 0.004545, 0.660139], [0.471457, 0.005678, 0.659897], [0.477344, 0.006980, 0.659549], [0.483210, 0.008460, 0.659095], [0.489055, 0.010127, 0.658534], [0.494877, 0.011990, 0.657865], [0.500678, 0.014055, 0.657088], [0.506454, 0.016333, 0.656202], [0.512206, 0.018833, 0.655209], [0.517933, 0.021563, 0.654109], [0.523633, 0.024532, 0.652901], [0.529306, 0.027747, 0.651586], [0.534952, 0.031217, 0.650165], [0.540570, 0.034950, 0.648640], [0.546157, 0.038954, 0.647010], [0.551715, 0.043136, 0.645277], [0.557243, 0.047331, 0.643443], [0.562738, 0.051545, 0.641509], [0.568201, 0.055778, 0.639477], [0.573632, 0.060028, 0.637349], [0.579029, 0.064296, 0.635126], [0.584391, 0.068579, 0.632812], [0.589719, 0.072878, 0.630408], [0.595011, 0.077190, 0.627917], [0.600266, 0.081516, 0.625342], [0.605485, 0.085854, 0.622686], [0.610667, 0.090204, 0.619951], [0.615812, 0.094564, 0.617140], [0.620919, 0.098934, 0.614257], [0.625987, 0.103312, 0.611305], [0.631017, 0.107699, 0.608287], [0.636008, 0.112092, 0.605205], [0.640959, 0.116492, 0.602065], [0.645872, 0.120898, 0.598867], [0.650746, 0.125309, 0.595617], [0.655580, 0.129725, 0.592317], [0.660374, 0.134144, 0.588971], [0.665129, 0.138566, 0.585582], [0.669845, 0.142992, 0.582154], [0.674522, 0.147419, 0.578688], [0.679160, 0.151848, 0.575189], [0.683758, 0.156278, 0.571660], [0.688318, 0.160709, 0.568103], [0.692840, 0.165141, 0.564522], [0.697324, 0.169573, 0.560919], [0.701769, 0.174005, 0.557296], [0.706178, 0.178437, 0.553657], [0.710549, 0.182868, 0.550004], [0.714883, 0.187299, 0.546338], [0.719181, 0.191729, 0.542663], [0.723444, 0.196158, 0.538981], [0.727670, 0.200586, 0.535293], [0.731862, 0.205013, 0.531601], [0.736019, 0.209439, 0.527908], [0.740143, 0.213864, 0.524216], [0.744232, 0.218288, 0.520524], [0.748289, 0.222711, 0.516834], [0.752312, 0.227133, 0.513149], [0.756304, 0.231555, 0.509468], [0.760264, 0.235976, 0.505794], [0.764193, 0.240396, 0.502126], [0.768090, 0.244817, 0.498465], [0.771958, 0.249237, 0.494813], [0.775796, 0.253658, 0.491171], [0.779604, 0.258078, 0.487539], [0.783383, 0.262500, 0.483918], [0.787133, 0.266922, 0.480307], [0.790855, 0.271345, 0.476706], [0.794549, 0.275770, 0.473117], [0.798216, 0.280197, 0.469538], [0.801855, 0.284626, 0.465971], [0.805467, 0.289057, 0.462415], [0.809052, 0.293491, 0.458870], [0.812612, 0.297928, 0.455338], [0.816144, 0.302368, 0.451816], [0.819651, 0.306812, 0.448306], [0.823132, 0.311261, 0.444806], [0.826588, 0.315714, 0.441316], [0.830018, 0.320172, 0.437836], [0.833422, 0.324635, 0.434366], [0.836801, 0.329105, 0.430905], [0.840155, 0.333580, 0.427455], [0.843484, 0.338062, 0.424013], [0.846788, 0.342551, 0.420579], [0.850066, 0.347048, 0.417153], [0.853319, 0.351553, 0.413734], [0.856547, 0.356066, 0.410322], [0.859750, 0.360588, 0.406917], [0.862927, 0.365119, 0.403519], [0.866078, 0.369660, 0.400126], [0.869203, 0.374212, 0.396738], [0.872303, 0.378774, 0.393355], [0.875376, 0.383347, 0.389976], [0.878423, 0.387932, 0.386600], [0.881443, 0.392529, 0.383229], [0.884436, 0.397139, 0.379860], [0.887402, 0.401762, 0.376494], [0.890340, 0.406398, 0.373130], [0.893250, 0.411048, 0.369768], [0.896131, 0.415712, 0.366407], [0.898984, 0.420392, 0.363047], [0.901807, 0.425087, 0.359688], [0.904601, 0.429797, 0.356329], [0.907365, 0.434524, 0.352970], [0.910098, 0.439268, 0.349610], [0.912800, 0.444029, 0.346251], [0.915471, 0.448807, 0.342890], [0.918109, 0.453603, 0.339529], [0.920714, 0.458417, 0.336166], [0.923287, 0.463251, 0.332801], [0.925825, 0.468103, 0.329435], [0.928329, 0.472975, 0.326067], [0.930798, 0.477867, 0.322697], [0.933232, 0.482780, 0.319325], [0.935630, 0.487712, 0.315952], [0.937990, 0.492667, 0.312575], [0.940313, 0.497642, 0.309197], [0.942598, 0.502639, 0.305816], [0.944844, 0.507658, 0.302433], [0.947051, 0.512699, 0.299049], [0.949217, 0.517763, 0.295662], [0.951344, 0.522850, 0.292275], [0.953428, 0.527960, 0.288883], [0.955470, 0.533093, 0.285490], [0.957469, 0.538250, 0.282096], [0.959424, 0.543431, 0.278701], [0.961336, 0.548636, 0.275305], [0.963203, 0.553865, 0.271909], [0.965024, 0.559118, 0.268513], [0.966798, 0.564396, 0.265118], [0.968526, 0.569700, 0.261721], [0.970205, 0.575028, 0.258325], [0.971835, 0.580382, 0.254931], [0.973416, 0.585761, 0.251540], [0.974947, 0.591165, 0.248151], [0.976428, 0.596595, 0.244767], [0.977856, 0.602051, 0.241387], [0.979233, 0.607532, 0.238013], [0.980556, 0.613039, 0.234646], [0.981826, 0.618572, 0.231287], [0.983041, 0.624131, 0.227937], [0.984199, 0.629718, 0.224595], [0.985301, 0.635330, 0.221265], [0.986345, 0.640969, 0.217948], [0.987332, 0.646633, 0.214648], [0.988260, 0.652325, 0.211364], [0.989128, 0.658043, 0.208100], [0.989935, 0.663787, 0.204859], [0.990681, 0.669558, 0.201642], [0.991365, 0.675355, 0.198453], [0.991985, 0.681179, 0.195295], [0.992541, 0.687030, 0.192170], [0.993032, 0.692907, 0.189084], [0.993456, 0.698810, 0.186041], [0.993814, 0.704741, 0.183043], [0.994103, 0.710698, 0.180097], [0.994324, 0.716681, 0.177208], [0.994474, 0.722691, 0.174381], [0.994553, 0.728728, 0.171622], [0.994561, 0.734791, 0.168938], [0.994495, 0.740880, 0.166335], [0.994355, 0.746995, 0.163821], [0.994141, 0.753137, 0.161404], [0.993851, 0.759304, 0.159092], [0.993482, 0.765499, 0.156891], [0.993033, 0.771720, 0.154808], [0.992505, 0.777967, 0.152855], [0.991897, 0.784239, 0.151042], [0.991209, 0.790537, 0.149377], [0.990439, 0.796859, 0.147870], [0.989587, 0.803205, 0.146529], [0.988648, 0.809579, 0.145357], [0.987621, 0.815978, 0.144363], [0.986509, 0.822401, 0.143557], [0.985314, 0.828846, 0.142945], [0.984031, 0.835315, 0.142528], [0.982653, 0.841812, 0.142303], [0.981190, 0.848329, 0.142279], [0.979644, 0.854866, 0.142453], [0.977995, 0.861432, 0.142808], [0.976265, 0.868016, 0.143351], [0.974443, 0.874622, 0.144061], [0.972530, 0.881250, 0.144923], [0.970533, 0.887896, 0.145919], [0.968443, 0.894564, 0.147014], [0.966271, 0.901249, 0.148180], [0.964021, 0.907950, 0.149370], [0.961681, 0.914672, 0.150520], [0.959276, 0.921407, 0.151566], [0.956808, 0.928152, 0.152409], [0.954287, 0.934908, 0.152921], [0.951726, 0.941671, 0.152925], [0.949151, 0.948435, 0.152178], [0.946602, 0.955190, 0.150328], [0.944152, 0.961916, 0.146861], [0.941896, 0.968590, 0.140956], [0.940015, 0.975158, 0.131326], ] _viridis_data = [ [0.267004, 0.004874, 0.329415], [0.268510, 0.009605, 0.335427], [0.269944, 0.014625, 0.341379], [0.271305, 0.019942, 0.347269], [0.272594, 0.025563, 0.353093], [0.273809, 0.031497, 0.358853], [0.274952, 0.037752, 0.364543], [0.276022, 0.044167, 0.370164], [0.277018, 0.050344, 0.375715], [0.277941, 0.056324, 0.381191], [0.278791, 0.062145, 0.386592], [0.279566, 0.067836, 0.391917], [0.280267, 0.073417, 0.397163], [0.280894, 0.078907, 0.402329], [0.281446, 0.084320, 0.407414], [0.281924, 0.089666, 0.412415], [0.282327, 0.094955, 0.417331], [0.282656, 0.100196, 0.422160], [0.282910, 0.105393, 0.426902], [0.283091, 0.110553, 0.431554], [0.283197, 0.115680, 0.436115], [0.283229, 0.120777, 0.440584], [0.283187, 0.125848, 0.444960], [0.283072, 0.130895, 0.449241], [0.282884, 0.135920, 0.453427], [0.282623, 0.140926, 0.457517], [0.282290, 0.145912, 0.461510], [0.281887, 0.150881, 0.465405], [0.281412, 0.155834, 0.469201], [0.280868, 0.160771, 0.472899], [0.280255, 0.165693, 0.476498], [0.279574, 0.170599, 0.479997], [0.278826, 0.175490, 0.483397], [0.278012, 0.180367, 0.486697], [0.277134, 0.185228, 0.489898], [0.276194, 0.190074, 0.493001], [0.275191, 0.194905, 0.496005], [0.274128, 0.199721, 0.498911], [0.273006, 0.204520, 0.501721], [0.271828, 0.209303, 0.504434], [0.270595, 0.214069, 0.507052], [0.269308, 0.218818, 0.509577], [0.267968, 0.223549, 0.512008], [0.266580, 0.228262, 0.514349], [0.265145, 0.232956, 0.516599], [0.263663, 0.237631, 0.518762], [0.262138, 0.242286, 0.520837], [0.260571, 0.246922, 0.522828], [0.258965, 0.251537, 0.524736], [0.257322, 0.256130, 0.526563], [0.255645, 0.260703, 0.528312], [0.253935, 0.265254, 0.529983], [0.252194, 0.269783, 0.531579], [0.250425, 0.274290, 0.533103], [0.248629, 0.278775, 0.534556], [0.246811, 0.283237, 0.535941], [0.244972, 0.287675, 0.537260], [0.243113, 0.292092, 0.538516], [0.241237, 0.296485, 0.539709], [0.239346, 0.300855, 0.540844], [0.237441, 0.305202, 0.541921], [0.235526, 0.309527, 0.542944], [0.233603, 0.313828, 0.543914], [0.231674, 0.318106, 0.544834], [0.229739, 0.322361, 0.545706], [0.227802, 0.326594, 0.546532], [0.225863, 0.330805, 0.547314], [0.223925, 0.334994, 0.548053], [0.221989, 0.339161, 0.548752], [0.220057, 0.343307, 0.549413], [0.218130, 0.347432, 0.550038], [0.216210, 0.351535, 0.550627], [0.214298, 0.355619, 0.551184], [0.212395, 0.359683, 0.551710], [0.210503, 0.363727, 0.552206], [0.208623, 0.367752, 0.552675], [0.206756, 0.371758, 0.553117], [0.204903, 0.375746, 0.553533], [0.203063, 0.379716, 0.553925], [0.201239, 0.383670, 0.554294], [0.199430, 0.387607, 0.554642], [0.197636, 0.391528, 0.554969], [0.195860, 0.395433, 0.555276], [0.194100, 0.399323, 0.555565], [0.192357, 0.403199, 0.555836], [0.190631, 0.407061, 0.556089], [0.188923, 0.410910, 0.556326], [0.187231, 0.414746, 0.556547], [0.185556, 0.418570, 0.556753], [0.183898, 0.422383, 0.556944], [0.182256, 0.426184, 0.557120], [0.180629, 0.429975, 0.557282], [0.179019, 0.433756, 0.557430], [0.177423, 0.437527, 0.557565], [0.175841, 0.441290, 0.557685], [0.174274, 0.445044, 0.557792], [0.172719, 0.448791, 0.557885], [0.171176, 0.452530, 0.557965], [0.169646, 0.456262, 0.558030], [0.168126, 0.459988, 0.558082], [0.166617, 0.463708, 0.558119], [0.165117, 0.467423, 0.558141], [0.163625, 0.471133, 0.558148], [0.162142, 0.474838, 0.558140], [0.160665, 0.478540, 0.558115], [0.159194, 0.482237, 0.558073], [0.157729, 0.485932, 0.558013], [0.156270, 0.489624, 0.557936], [0.154815, 0.493313, 0.557840], [0.153364, 0.497000, 0.557724], [0.151918, 0.500685, 0.557587], [0.150476, 0.504369, 0.557430], [0.149039, 0.508051, 0.557250], [0.147607, 0.511733, 0.557049], [0.146180, 0.515413, 0.556823], [0.144759, 0.519093, 0.556572], [0.143343, 0.522773, 0.556295], [0.141935, 0.526453, 0.555991], [0.140536, 0.530132, 0.555659], [0.139147, 0.533812, 0.555298], [0.137770, 0.537492, 0.554906], [0.136408, 0.541173, 0.554483], [0.135066, 0.544853, 0.554029], [0.133743, 0.548535, 0.553541], [0.132444, 0.552216, 0.553018], [0.131172, 0.555899, 0.552459], [0.129933, 0.559582, 0.551864], [0.128729, 0.563265, 0.551229], [0.127568, 0.566949, 0.550556], [0.126453, 0.570633, 0.549841], [0.125394, 0.574318, 0.549086], [0.124395, 0.578002, 0.548287], [0.123463, 0.581687, 0.547445], [0.122606, 0.585371, 0.546557], [0.121831, 0.589055, 0.545623], [0.121148, 0.592739, 0.544641], [0.120565, 0.596422, 0.543611], [0.120092, 0.600104, 0.542530], [0.119738, 0.603785, 0.541400], [0.119512, 0.607464, 0.540218], [0.119423, 0.611141, 0.538982], [0.119483, 0.614817, 0.537692], [0.119699, 0.618490, 0.536347], [0.120081, 0.622161, 0.534946], [0.120638, 0.625828, 0.533488], [0.121380, 0.629492, 0.531973], [0.122312, 0.633153, 0.530398], [0.123444, 0.636809, 0.528763], [0.124780, 0.640461, 0.527068], [0.126326, 0.644107, 0.525311], [0.128087, 0.647749, 0.523491], [0.130067, 0.651384, 0.521608], [0.132268, 0.655014, 0.519661], [0.134692, 0.658636, 0.517649], [0.137339, 0.662252, 0.515571], [0.140210, 0.665859, 0.513427], [0.143303, 0.669459, 0.511215], [0.146616, 0.673050, 0.508936], [0.150148, 0.676631, 0.506589], [0.153894, 0.680203, 0.504172], [0.157851, 0.683765, 0.501686], [0.162016, 0.687316, 0.499129], [0.166383, 0.690856, 0.496502], [0.170948, 0.694384, 0.493803], [0.175707, 0.697900, 0.491033], [0.180653, 0.701402, 0.488189], [0.185783, 0.704891, 0.485273], [0.191090, 0.708366, 0.482284], [0.196571, 0.711827, 0.479221], [0.202219, 0.715272, 0.476084], [0.208030, 0.718701, 0.472873], [0.214000, 0.722114, 0.469588], [0.220124, 0.725509, 0.466226], [0.226397, 0.728888, 0.462789], [0.232815, 0.732247, 0.459277], [0.239374, 0.735588, 0.455688], [0.246070, 0.738910, 0.452024], [0.252899, 0.742211, 0.448284], [0.259857, 0.745492, 0.444467], [0.266941, 0.748751, 0.440573], [0.274149, 0.751988, 0.436601], [0.281477, 0.755203, 0.432552], [0.288921, 0.758394, 0.428426], [0.296479, 0.761561, 0.424223], [0.304148, 0.764704, 0.419943], [0.311925, 0.767822, 0.415586], [0.319809, 0.770914, 0.411152], [0.327796, 0.773980, 0.406640], [0.335885, 0.777018, 0.402049], [0.344074, 0.780029, 0.397381], [0.352360, 0.783011, 0.392636], [0.360741, 0.785964, 0.387814], [0.369214, 0.788888, 0.382914], [0.377779, 0.791781, 0.377939], [0.386433, 0.794644, 0.372886], [0.395174, 0.797475, 0.367757], [0.404001, 0.800275, 0.362552], [0.412913, 0.803041, 0.357269], [0.421908, 0.805774, 0.351910], [0.430983, 0.808473, 0.346476], [0.440137, 0.811138, 0.340967], [0.449368, 0.813768, 0.335384], [0.458674, 0.816363, 0.329727], [0.468053, 0.818921, 0.323998], [0.477504, 0.821444, 0.318195], [0.487026, 0.823929, 0.312321], [0.496615, 0.826376, 0.306377], [0.506271, 0.828786, 0.300362], [0.515992, 0.831158, 0.294279], [0.525776, 0.833491, 0.288127], [0.535621, 0.835785, 0.281908], [0.545524, 0.838039, 0.275626], [0.555484, 0.840254, 0.269281], [0.565498, 0.842430, 0.262877], [0.575563, 0.844566, 0.256415], [0.585678, 0.846661, 0.249897], [0.595839, 0.848717, 0.243329], [0.606045, 0.850733, 0.236712], [0.616293, 0.852709, 0.230052], [0.626579, 0.854645, 0.223353], [0.636902, 0.856542, 0.216620], [0.647257, 0.858400, 0.209861], [0.657642, 0.860219, 0.203082], [0.668054, 0.861999, 0.196293], [0.678489, 0.863742, 0.189503], [0.688944, 0.865448, 0.182725], [0.699415, 0.867117, 0.175971], [0.709898, 0.868751, 0.169257], [0.720391, 0.870350, 0.162603], [0.730889, 0.871916, 0.156029], [0.741388, 0.873449, 0.149561], [0.751884, 0.874951, 0.143228], [0.762373, 0.876424, 0.137064], [0.772852, 0.877868, 0.131109], [0.783315, 0.879285, 0.125405], [0.793760, 0.880678, 0.120005], [0.804182, 0.882046, 0.114965], [0.814576, 0.883393, 0.110347], [0.824940, 0.884720, 0.106217], [0.835270, 0.886029, 0.102646], [0.845561, 0.887322, 0.099702], [0.855810, 0.888601, 0.097452], [0.866013, 0.889868, 0.095953], [0.876168, 0.891125, 0.095250], [0.886271, 0.892374, 0.095374], [0.896320, 0.893616, 0.096335], [0.906311, 0.894855, 0.098125], [0.916242, 0.896091, 0.100717], [0.926106, 0.897330, 0.104071], [0.935904, 0.898570, 0.108131], [0.945636, 0.899815, 0.112838], [0.955300, 0.901065, 0.118128], [0.964894, 0.902323, 0.123941], [0.974417, 0.903590, 0.130215], [0.983868, 0.904867, 0.136897], [0.993248, 0.906157, 0.143936], ] _cividis_data = [ [0.000000, 0.135112, 0.304751], [0.000000, 0.138068, 0.311105], [0.000000, 0.141013, 0.317579], [0.000000, 0.143951, 0.323982], [0.000000, 0.146877, 0.330479], [0.000000, 0.149791, 0.337065], [0.000000, 0.152673, 0.343704], [0.000000, 0.155377, 0.350500], [0.000000, 0.157932, 0.357521], [0.000000, 0.160495, 0.364534], [0.000000, 0.163058, 0.371608], [0.000000, 0.165621, 0.378769], [0.000000, 0.168204, 0.385902], [0.000000, 0.170800, 0.393100], [0.000000, 0.173420, 0.400353], [0.000000, 0.176082, 0.407577], [0.000000, 0.178802, 0.414764], [0.000000, 0.181610, 0.421859], [0.000000, 0.184550, 0.428802], [0.000000, 0.186915, 0.435532], [0.000000, 0.188769, 0.439563], [0.000000, 0.190950, 0.441085], [0.000000, 0.193366, 0.441561], [0.003602, 0.195911, 0.441564], [0.017852, 0.198528, 0.441248], [0.032110, 0.201199, 0.440785], [0.046205, 0.203903, 0.440196], [0.058378, 0.206629, 0.439531], [0.068968, 0.209372, 0.438863], [0.078624, 0.212122, 0.438105], [0.087465, 0.214879, 0.437342], [0.095645, 0.217643, 0.436593], [0.103401, 0.220406, 0.435790], [0.110658, 0.223170, 0.435067], [0.117612, 0.225935, 0.434308], [0.124291, 0.228697, 0.433547], [0.130669, 0.231458, 0.432840], [0.136830, 0.234216, 0.432148], [0.142852, 0.236972, 0.431404], [0.148638, 0.239724, 0.430752], [0.154261, 0.242475, 0.430120], [0.159733, 0.245221, 0.429528], [0.165113, 0.247965, 0.428908], [0.170362, 0.250707, 0.428325], [0.175490, 0.253444, 0.427790], [0.180503, 0.256180, 0.427299], [0.185453, 0.258914, 0.426788], [0.190303, 0.261644, 0.426329], [0.195057, 0.264372, 0.425924], [0.199764, 0.267099, 0.425497], [0.204385, 0.269823, 0.425126], [0.208926, 0.272546, 0.424809], [0.213431, 0.275266, 0.424480], [0.217863, 0.277985, 0.424206], [0.222264, 0.280702, 0.423914], [0.226598, 0.283419, 0.423678], [0.230871, 0.286134, 0.423498], [0.235120, 0.288848, 0.423304], [0.239312, 0.291562, 0.423167], [0.243485, 0.294274, 0.423014], [0.247605, 0.296986, 0.422917], [0.251675, 0.299698, 0.422873], [0.255731, 0.302409, 0.422814], [0.259740, 0.305120, 0.422810], [0.263738, 0.307831, 0.422789], [0.267693, 0.310542, 0.422821], [0.271639, 0.313253, 0.422837], [0.275513, 0.315965, 0.422979], [0.279411, 0.318677, 0.423031], [0.283240, 0.321390, 0.423211], [0.287065, 0.324103, 0.423373], [0.290884, 0.326816, 0.423517], [0.294669, 0.329531, 0.423716], [0.298421, 0.332247, 0.423973], [0.302169, 0.334963, 0.424213], [0.305886, 0.337681, 0.424512], [0.309601, 0.340399, 0.424790], [0.313287, 0.343120, 0.425120], [0.316941, 0.345842, 0.425512], [0.320595, 0.348565, 0.425889], [0.324250, 0.351289, 0.426250], [0.327875, 0.354016, 0.426670], [0.331474, 0.356744, 0.427144], [0.335073, 0.359474, 0.427605], [0.338673, 0.362206, 0.428053], [0.342246, 0.364939, 0.428559], [0.345793, 0.367676, 0.429127], [0.349341, 0.370414, 0.429685], [0.352892, 0.373153, 0.430226], [0.356418, 0.375896, 0.430823], [0.359916, 0.378641, 0.431501], [0.363446, 0.381388, 0.432075], [0.366923, 0.384139, 0.432796], [0.370430, 0.386890, 0.433428], [0.373884, 0.389646, 0.434209], [0.377371, 0.392404, 0.434890], [0.380830, 0.395164, 0.435653], [0.384268, 0.397928, 0.436475], [0.387705, 0.400694, 0.437305], [0.391151, 0.403464, 0.438096], [0.394568, 0.406236, 0.438986], [0.397991, 0.409011, 0.439848], [0.401418, 0.411790, 0.440708], [0.404820, 0.414572, 0.441642], [0.408226, 0.417357, 0.442570], [0.411607, 0.420145, 0.443577], [0.414992, 0.422937, 0.444578], [0.418383, 0.425733, 0.445560], [0.421748, 0.428531, 0.446640], [0.425120, 0.431334, 0.447692], [0.428462, 0.434140, 0.448864], [0.431817, 0.436950, 0.449982], [0.435168, 0.439763, 0.451134], [0.438504, 0.442580, 0.452341], [0.441810, 0.445402, 0.453659], [0.445148, 0.448226, 0.454885], [0.448447, 0.451053, 0.456264], [0.451759, 0.453887, 0.457582], [0.455072, 0.456718, 0.458976], [0.458366, 0.459552, 0.460457], [0.461616, 0.462405, 0.461969], [0.464947, 0.465241, 0.463395], [0.468254, 0.468083, 0.464908], [0.471501, 0.470960, 0.466357], [0.474812, 0.473832, 0.467681], [0.478186, 0.476699, 0.468845], [0.481622, 0.479573, 0.469767], [0.485141, 0.482451, 0.470384], [0.488697, 0.485318, 0.471008], [0.492278, 0.488198, 0.471453], [0.495913, 0.491076, 0.471751], [0.499552, 0.493960, 0.472032], [0.503185, 0.496851, 0.472305], [0.506866, 0.499743, 0.472432], [0.510540, 0.502643, 0.472550], [0.514226, 0.505546, 0.472640], [0.517920, 0.508454, 0.472707], [0.521643, 0.511367, 0.472639], [0.525348, 0.514285, 0.472660], [0.529086, 0.517207, 0.472543], [0.532829, 0.520135, 0.472401], [0.536553, 0.523067, 0.472352], [0.540307, 0.526005, 0.472163], [0.544069, 0.528948, 0.471947], [0.547840, 0.531895, 0.471704], [0.551612, 0.534849, 0.471439], [0.555393, 0.537807, 0.471147], [0.559181, 0.540771, 0.470829], [0.562972, 0.543741, 0.470488], [0.566802, 0.546715, 0.469988], [0.570607, 0.549695, 0.469593], [0.574417, 0.552682, 0.469172], [0.578236, 0.555673, 0.468724], [0.582087, 0.558670, 0.468118], [0.585916, 0.561674, 0.467618], [0.589753, 0.564682, 0.467090], [0.593622, 0.567697, 0.466401], [0.597469, 0.570718, 0.465821], [0.601354, 0.573743, 0.465074], [0.605211, 0.576777, 0.464441], [0.609105, 0.579816, 0.463638], [0.612977, 0.582861, 0.462950], [0.616852, 0.585913, 0.462237], [0.620765, 0.588970, 0.461351], [0.624654, 0.592034, 0.460583], [0.628576, 0.595104, 0.459641], [0.632506, 0.598180, 0.458668], [0.636412, 0.601264, 0.457818], [0.640352, 0.604354, 0.456791], [0.644270, 0.607450, 0.455886], [0.648222, 0.610553, 0.454801], [0.652178, 0.613664, 0.453689], [0.656114, 0.616780, 0.452702], [0.660082, 0.619904, 0.451534], [0.664055, 0.623034, 0.450338], [0.668008, 0.626171, 0.449270], [0.671991, 0.629316, 0.448018], [0.675981, 0.632468, 0.446736], [0.679979, 0.635626, 0.445424], [0.683950, 0.638793, 0.444251], [0.687957, 0.641966, 0.442886], [0.691971, 0.645145, 0.441491], [0.695985, 0.648334, 0.440072], [0.700008, 0.651529, 0.438624], [0.704037, 0.654731, 0.437147], [0.708067, 0.657942, 0.435647], [0.712105, 0.661160, 0.434117], [0.716177, 0.664384, 0.432386], [0.720222, 0.667618, 0.430805], [0.724274, 0.670859, 0.429194], [0.728334, 0.674107, 0.427554], [0.732422, 0.677364, 0.425717], [0.736488, 0.680629, 0.424028], [0.740589, 0.683900, 0.422131], [0.744664, 0.687181, 0.420393], [0.748772, 0.690470, 0.418448], [0.752886, 0.693766, 0.416472], [0.756975, 0.697071, 0.414659], [0.761096, 0.700384, 0.412638], [0.765223, 0.703705, 0.410587], [0.769353, 0.707035, 0.408516], [0.773486, 0.710373, 0.406422], [0.777651, 0.713719, 0.404112], [0.781795, 0.717074, 0.401966], [0.785965, 0.720438, 0.399613], [0.790116, 0.723810, 0.397423], [0.794298, 0.727190, 0.395016], [0.798480, 0.730580, 0.392597], [0.802667, 0.733978, 0.390153], [0.806859, 0.737385, 0.387684], [0.811054, 0.740801, 0.385198], [0.815274, 0.744226, 0.382504], [0.819499, 0.747659, 0.379785], [0.823729, 0.751101, 0.377043], [0.827959, 0.754553, 0.374292], [0.832192, 0.758014, 0.371529], [0.836429, 0.761483, 0.368747], [0.840693, 0.764962, 0.365746], [0.844957, 0.768450, 0.362741], [0.849223, 0.771947, 0.359729], [0.853515, 0.775454, 0.356500], [0.857809, 0.778969, 0.353259], [0.862105, 0.782494, 0.350011], [0.866421, 0.786028, 0.346571], [0.870717, 0.789572, 0.343333], [0.875057, 0.793125, 0.339685], [0.879378, 0.796687, 0.336241], [0.883720, 0.800258, 0.332599], [0.888081, 0.803839, 0.328770], [0.892440, 0.807430, 0.324968], [0.896818, 0.811030, 0.320982], [0.901195, 0.814639, 0.317021], [0.905589, 0.818257, 0.312889], [0.910000, 0.821885, 0.308594], [0.914407, 0.825522, 0.304348], [0.918828, 0.829168, 0.299960], [0.923279, 0.832822, 0.295244], [0.927724, 0.836486, 0.290611], [0.932180, 0.840159, 0.285880], [0.936660, 0.843841, 0.280876], [0.941147, 0.847530, 0.275815], [0.945654, 0.851228, 0.270532], [0.950178, 0.854933, 0.265085], [0.954725, 0.858646, 0.259365], [0.959284, 0.862365, 0.253563], [0.963872, 0.866089, 0.247445], [0.968469, 0.869819, 0.241310], [0.973114, 0.873550, 0.234677], [0.977780, 0.877281, 0.227954], [0.982497, 0.881008, 0.220878], [0.987293, 0.884718, 0.213336], [0.992218, 0.888385, 0.205468], [0.994847, 0.892954, 0.203445], [0.995249, 0.898384, 0.207561], [0.995503, 0.903866, 0.212370], [0.995737, 0.909344, 0.217772], ] _twilight_data = [ [0.88575015840754434, 0.85000924943067835, 0.8879736506427196], [0.88378520195539056, 0.85072940540310626, 0.88723222096949894], [0.88172231059285788, 0.85127594077653468, 0.88638056925514819], [0.8795410528270573, 0.85165675407495722, 0.8854143767924102], [0.87724880858965482, 0.85187028338870274, 0.88434120381311432], [0.87485347508575972, 0.85191526123023187, 0.88316926967613829], [0.87233134085124076, 0.85180165478080894, 0.88189704355001619], [0.86970474853509816, 0.85152403004797894, 0.88053883390003362], [0.86696015505333579, 0.8510896085314068, 0.87909766977173343], [0.86408985081463996, 0.85050391167507788, 0.87757925784892632], [0.86110245436899846, 0.84976754857001258, 0.87599242923439569], [0.85798259245670372, 0.84888934810281835, 0.87434038553446281], [0.85472593189256985, 0.84787488124672816, 0.8726282980930582], [0.85133714570857189, 0.84672735796116472, 0.87086081657350445], [0.84780710702577922, 0.8454546229209523, 0.86904036783694438], [0.8441261828674842, 0.84406482711037389, 0.86716973322690072], [0.84030420805957784, 0.8425605950855084, 0.865250882410458], [0.83634031809191178, 0.84094796518951942, 0.86328528001070159], [0.83222705712934408, 0.83923490627754482, 0.86127563500427884], [0.82796894316013536, 0.83742600751395202, 0.85922399451306786], [0.82357429680252847, 0.83552487764795436, 0.85713191328514948], [0.81904654677937527, 0.8335364929949034, 0.85500206287010105], [0.81438982121143089, 0.83146558694197847, 0.85283759062147024], [0.8095999819094809, 0.82931896673505456, 0.85064441601050367], [0.80469164429814577, 0.82709838780560663, 0.84842449296974021], [0.79967075421267997, 0.82480781812080928, 0.84618210029578533], [0.79454305089231114, 0.82245116226304615, 0.84392184786827984], [0.78931445564608915, 0.82003213188702007, 0.8416486380471222], [0.78399101042764918, 0.81755426400533426, 0.83936747464036732], [0.77857892008227592, 0.81502089378742548, 0.8370834463093898], [0.77308416590170936, 0.81243524735466011, 0.83480172950579679], [0.76751108504417864, 0.8098007598713145, 0.83252816638059668], [0.76186907937980286, 0.80711949387647486, 0.830266486168872], [0.75616443584381976, 0.80439408733477935, 0.82802138994719998], [0.75040346765406696, 0.80162699008965321, 0.82579737851082424], [0.74459247771890169, 0.79882047719583249, 0.82359867586156521], [0.73873771700494939, 0.79597665735031009, 0.82142922780433014], [0.73284543645523459, 0.79309746468844067, 0.81929263384230377], [0.72692177512829703, 0.7901846863592763, 0.81719217466726379], [0.72097280665536778, 0.78723995923452639, 0.81513073920879264], [0.71500403076252128, 0.78426487091581187, 0.81311116559949914], [0.70902078134539304, 0.78126088716070907, 0.81113591855117928], [0.7030297722540817, 0.77822904973358131, 0.80920618848056969], [0.6970365443886174, 0.77517050008066057, 0.80732335380063447], [0.69104641009309098, 0.77208629460678091, 0.80548841690679074], [0.68506446154395928, 0.7689774029354699, 0.80370206267176914], [0.67909554499882152, 0.76584472131395898, 0.8019646617300199], [0.67314422559426212, 0.76268908733890484, 0.80027628545809526], [0.66721479803752815, 0.7595112803730375, 0.79863674654537764], [0.6613112930078745, 0.75631202708719025, 0.7970456043491897], [0.65543692326454717, 0.75309208756768431, 0.79550271129031047], [0.64959573004253479, 0.74985201221941766, 0.79400674021499107], [0.6437910831099849, 0.7465923800833657, 0.79255653201306053], [0.63802586828545982, 0.74331376714033193, 0.79115100459573173], [0.6323027138710603, 0.74001672160131404, 0.78978892762640429], [0.62662402022604591, 0.73670175403699445, 0.78846901316334561], [0.62099193064817548, 0.73336934798923203, 0.78718994624696581], [0.61540846411770478, 0.73001995232739691, 0.78595022706750484], [0.60987543176093062, 0.72665398759758293, 0.78474835732694714], [0.60439434200274855, 0.7232718614323369, 0.78358295593535587], [0.5989665814482068, 0.71987394892246725, 0.78245259899346642], [0.59359335696837223, 0.7164606049658685, 0.78135588237640097], [0.58827579780555495, 0.71303214646458135, 0.78029141405636515], [0.58301487036932409, 0.70958887676997473, 0.77925781820476592], [0.5778116438998202, 0.70613106157153982, 0.77825345121025524], [0.5726668948158774, 0.7026589535425779, 0.77727702680911992], [0.56758117853861967, 0.69917279302646274, 0.77632748534275298], [0.56255515357219343, 0.69567278381629649, 0.77540359142309845], [0.55758940419605174, 0.69215911458254054, 0.7745041337932782], [0.55268450589347129, 0.68863194515166382, 0.7736279426902245], [0.54784098153018634, 0.68509142218509878, 0.77277386473440868], [0.54305932424018233, 0.68153767253065878, 0.77194079697835083], [0.53834015575176275, 0.67797081129095405, 0.77112734439057717], [0.53368389147728401, 0.67439093705212727, 0.7703325054879735], [0.529090861832473, 0.67079812302806219, 0.76955552292313134], [0.52456151470593582, 0.66719242996142225, 0.76879541714230948], [0.52009627392235558, 0.66357391434030388, 0.76805119403344102], [0.5156955988596057, 0.65994260812897998, 0.76732191489596169], [0.51135992541601927, 0.65629853981831865, 0.76660663780645333], [0.50708969576451657, 0.65264172403146448, 0.76590445660835849], [0.5028853540415561, 0.64897216734095264, 0.76521446718174913], [0.49874733661356069, 0.6452898684900934, 0.76453578734180083], [0.4946761847863938, 0.64159484119504429, 0.76386719002130909], [0.49067224938561221, 0.63788704858847078, 0.76320812763163837], [0.4867359599430568, 0.63416646251100506, 0.76255780085924041], [0.4828677867260272, 0.6304330455306234, 0.76191537149895305], [0.47906816236197386, 0.62668676251860134, 0.76128000375662419], [0.47533752394906287, 0.62292757283835809, 0.76065085571817748], [0.47167629518877091, 0.61915543242884641, 0.76002709227883047], [0.46808490970531597, 0.61537028695790286, 0.75940789891092741], [0.46456376716303932, 0.61157208822864151, 0.75879242623025811], [0.46111326647023881, 0.607760777169989, 0.75817986436807139], [0.45773377230160567, 0.60393630046586455, 0.75756936901859162], [0.45442563977552913, 0.60009859503858665, 0.75696013660606487], [0.45118918687617743, 0.59624762051353541, 0.75635120643246645], [0.44802470933589172, 0.59238331452146575, 0.75574176474107924], [0.44493246854215379, 0.5885055998308617, 0.7551311041857901], [0.44191271766696399, 0.58461441100175571, 0.75451838884410671], [0.43896563958048396, 0.58070969241098491, 0.75390276208285945], [0.43609138958356369, 0.57679137998186081, 0.7532834105961016], [0.43329008867358393, 0.57285941625606673, 0.75265946532566674], [0.43056179073057571, 0.56891374572457176, 0.75203008099312696], [0.42790652284925834, 0.5649543060909209, 0.75139443521914839], [0.42532423665011354, 0.56098104959950301, 0.75075164989005116], [0.42281485675772662, 0.55699392126996583, 0.75010086988227642], [0.42037822361396326, 0.55299287158108168, 0.7494412559451894], [0.41801414079233629, 0.54897785421888889, 0.74877193167001121], [0.4157223260454232, 0.54494882715350401, 0.74809204459000522], [0.41350245743314729, 0.54090574771098476, 0.74740073297543086], [0.41135414697304568, 0.53684857765005933, 0.74669712855065784], [0.4092768899914751, 0.53277730177130322, 0.74598030635707824], [0.40727018694219069, 0.52869188011057411, 0.74524942637581271], [0.40533343789303178, 0.52459228174983119, 0.74450365836708132], [0.40346600333905397, 0.52047847653840029, 0.74374215223567086], [0.40166714010896104, 0.51635044969688759, 0.7429640345324835], [0.39993606933454834, 0.51220818143218516, 0.74216844571317986], [0.3982719152586337, 0.50805166539276136, 0.74135450918099721], [0.39667374905665609, 0.50388089053847973, 0.74052138580516735], [0.39514058808207631, 0.49969585326377758, 0.73966820211715711], [0.39367135736822567, 0.49549655777451179, 0.738794102296364], [0.39226494876209317, 0.49128300332899261, 0.73789824784475078], [0.39092017571994903, 0.48705520251223039, 0.73697977133881254], [0.38963580160340855, 0.48281316715123496, 0.73603782546932739], [0.38841053300842432, 0.47855691131792805, 0.73507157641157261], [0.38724301459330251, 0.47428645933635388, 0.73408016787854391], [0.38613184178892102, 0.4700018340988123, 0.7330627749243106], [0.38507556793651387, 0.46570306719930193, 0.73201854033690505], [0.38407269378943537, 0.46139018782416635, 0.73094665432902683], [0.38312168084402748, 0.45706323581407199, 0.72984626791353258], [0.38222094988570376, 0.45272225034283325, 0.72871656144003782], [0.38136887930454161, 0.44836727669277859, 0.72755671317141346], [0.38056380696565623, 0.44399837208633719, 0.72636587045135315], [0.37980403744848751, 0.43961558821222629, 0.72514323778761092], [0.37908789283110761, 0.43521897612544935, 0.72388798691323131], [0.378413635091359, 0.43080859411413064, 0.72259931993061044], [0.37777949753513729, 0.4263845142616835, 0.72127639993530235], [0.37718371844251231, 0.42194680223454828, 0.71991841524475775], [0.37662448930806297, 0.41749553747893614, 0.71852454736176108], [0.37610001286385814, 0.41303079952477062, 0.71709396919920232], [0.37560846919442398, 0.40855267638072096, 0.71562585091587549], [0.37514802505380473, 0.4040612609993941, 0.7141193695725726], [0.37471686019302231, 0.3995566498711684, 0.71257368516500463], [0.37431313199312338, 0.39503894828283309, 0.71098796522377461], [0.37393499330475782, 0.39050827529375831, 0.70936134293478448], [0.3735806215098284, 0.38596474386057539, 0.70769297607310577], [0.37324816143326384, 0.38140848555753937, 0.70598200974806036], [0.37293578646665032, 0.37683963835219841, 0.70422755780589941], [0.37264166757849604, 0.37225835004836849, 0.7024287314570723], [0.37236397858465387, 0.36766477862108266, 0.70058463496520773], [0.37210089702443822, 0.36305909736982378, 0.69869434615073722], [0.3718506155898596, 0.35844148285875221, 0.69675695810256544], [0.37161133234400479, 0.3538121372967869, 0.69477149919380887], [0.37138124223736607, 0.34917126878479027, 0.69273703471928827], [0.37115856636209105, 0.34451911410230168, 0.69065253586464992], [0.37094151551337329, 0.33985591488818123, 0.68851703379505125], [0.37072833279422668, 0.33518193808489577, 0.68632948169606767], [0.37051738634484427, 0.33049741244307851, 0.68408888788857214], [0.37030682071842685, 0.32580269697872455, 0.68179411684486679], [0.37009487130772695, 0.3210981375964933, 0.67944405399056851], [0.36987980329025361, 0.31638410101153364, 0.67703755438090574], [0.36965987626565955, 0.31166098762951971, 0.67457344743419545], [0.36943334591276228, 0.30692923551862339, 0.67205052849120617], [0.36919847837592484, 0.30218932176507068, 0.66946754331614522], [0.36895355306596778, 0.29744175492366276, 0.66682322089824264], [0.36869682231895268, 0.29268709856150099, 0.66411625298236909], [0.36842655638020444, 0.28792596437778462, 0.66134526910944602], [0.36814101479899719, 0.28315901221182987, 0.65850888806972308], [0.36783843696531082, 0.27838697181297761, 0.65560566838453704], [0.36751707094367697, 0.27361063317090978, 0.65263411711618635], [0.36717513650699446, 0.26883085667326956, 0.64959272297892245], [0.36681085540107988, 0.26404857724525643, 0.64647991652908243], [0.36642243251550632, 0.25926481158628106, 0.64329409140765537], [0.36600853966739794, 0.25448043878086224, 0.64003361803368586], [0.36556698373538982, 0.24969683475296395, 0.63669675187488584], [0.36509579845886808, 0.24491536803550484, 0.63328173520055586], [0.36459308890125008, 0.24013747024823828, 0.62978680155026101], [0.36405693022088509, 0.23536470386204195, 0.62621013451953023], [0.36348537610385145, 0.23059876218396419, 0.62254988622392882], [0.36287643560041027, 0.22584149293287031, 0.61880417410823019], [0.36222809558295926, 0.22109488427338303, 0.61497112346096128], [0.36153829010998356, 0.21636111429594002, 0.61104880679640927], [0.36080493826624654, 0.21164251793458128, 0.60703532172064711], [0.36002681809096376, 0.20694122817889948, 0.60292845431916875], [0.35920088560930186, 0.20226037920758122, 0.5987265295935138], [0.35832489966617809, 0.197602942459778, 0.59442768517501066], [0.35739663292915563, 0.19297208197842461, 0.59003011251063131], [0.35641381143126327, 0.18837119869242164, 0.5855320765920552], [0.35537415306906722, 0.18380392577704466, 0.58093191431832802], [0.35427534960663759, 0.17927413271618647, 0.57622809660668717], [0.35311574421123737, 0.17478570377561287, 0.57141871523555288], [0.35189248608873791, 0.17034320478524959, 0.56650284911216653], [0.35060304441931012, 0.16595129984720861, 0.56147964703993225], [0.34924513554955644, 0.16161477763045118, 0.55634837474163779], [0.34781653238777782, 0.15733863511152979, 0.55110853452703257], [0.34631507175793091, 0.15312802296627787, 0.5457599924248665], [0.34473901574536375, 0.14898820589826409, 0.54030245920406539], [0.34308600291572294, 0.14492465359918028, 0.53473704282067103], [0.34135411074506483, 0.1409427920655632, 0.52906500940336754], [0.33954168752669694, 0.13704801896718169, 0.52328797535085236], [0.33764732090671112, 0.13324562282438077, 0.51740807573979475], [0.33566978565015315, 0.12954074251271822, 0.51142807215168951], [0.33360804901486002, 0.12593818301005921, 0.50535164796654897], [0.33146154891145124, 0.12244245263391232, 0.49918274588431072], [0.32923005203231409, 0.11905764321981127, 0.49292595612342666], [0.3269137124539796, 0.1157873496841953, 0.48658646495697461], [0.32451307931207785, 0.11263459791730848, 0.48017007211645196], [0.32202882276069322, 0.10960114111258401, 0.47368494725726878], [0.31946262395497965, 0.10668879882392659, 0.46713728801395243], [0.31681648089023501, 0.10389861387653518, 0.46053414662739794], [0.31409278414755532, 0.10123077676403242, 0.45388335612058467], [0.31129434479712365, 0.098684771934052201, 0.44719313715161618], [0.30842444457210105, 0.096259385340577736, 0.44047194882050544], [0.30548675819945936, 0.093952764840823738, 0.43372849999361113], [0.30248536364574252, 0.091761187397303601, 0.42697404043749887], [0.29942483960214772, 0.089682253716750038, 0.42021619665853854], [0.29631000388905288, 0.087713250960463951, 0.41346259134143476], [0.29314593096985248, 0.085850656889620708, 0.40672178082365834], [0.28993792445176608, 0.08409078829085731, 0.40000214725256295], [0.28669151388283165, 0.082429873848480689, 0.39331182532243375], [0.28341239797185225, 0.080864153365499375, 0.38665868550105914], [0.28010638576975472, 0.079389994802261526, 0.38005028528138707], [0.27677939615815589, 0.078003941033788216, 0.37349382846504675], [0.27343739342450812, 0.076702800237496066, 0.36699616136347685], [0.27008637749114051, 0.075483675584275545, 0.36056376228111864], [0.26673233211995284, 0.074344018028546205, 0.35420276066240958], [0.26338121807151404, 0.073281657939897077, 0.34791888996380105], [0.26003895187439957, 0.072294781043362205, 0.3417175669546984], [0.25671191651083902, 0.071380106242082242, 0.33560648984600089], [0.25340685873736807, 0.070533582926851829, 0.3295945757321303], [0.25012845306199383, 0.069758206429106989, 0.32368100685760637], [0.24688226237958999, 0.069053639449204451, 0.31786993834254956], [0.24367372557466271, 0.068419855150922693, 0.31216524050888372], [0.24050813332295939, 0.067857103814855602, 0.30657054493678321], [0.23739062429054825, 0.067365888050555517, 0.30108922184065873], [0.23433055727563878, 0.066935599661639394, 0.29574009929867601], [0.23132955273021344, 0.066576186939090592, 0.29051361067988485], [0.2283917709422868, 0.06628997924139618, 0.28541074411068496], [0.22552164337737857, 0.066078173119395595, 0.28043398847505197], [0.22272706739121817, 0.065933790675651943, 0.27559714652053702], [0.22001251100779617, 0.065857918918907604, 0.27090279994325861], [0.21737845072382705, 0.065859661233562045, 0.26634209349669508], [0.21482843531473683, 0.065940385613778491, 0.26191675992376573], [0.21237411048541005, 0.066085024661758446, 0.25765165093569542], [0.21001214221188125, 0.066308573918947178, 0.2535289048041211], [0.2077442377448806, 0.06661453200418091, 0.24954644291943817], [0.20558051999470117, 0.066990462397868739, 0.24572497420147632], [0.20352007949514977, 0.067444179612424215, 0.24205576625191821], [0.20156133764129841, 0.067983271026200248, 0.23852974228695395], [0.19971571438603364, 0.068592710553704722, 0.23517094067076993], [0.19794834061899208, 0.069314066071660657, 0.23194647381302336], [0.1960826032659409, 0.070321227242423623, 0.22874673279569585], [0.19410351363791453, 0.071608304856891569, 0.22558727307410353], [0.19199449184606268, 0.073182830649273306, 0.22243385243433622], [0.18975853639094634, 0.075019861862143766, 0.2193005075652994], [0.18739228342697645, 0.077102096899588329, 0.21618875376309582], [0.18488035509396164, 0.079425730279723883, 0.21307651648984993], [0.18774482037046955, 0.077251588468039312, 0.21387448578597812], [0.19049578401722037, 0.075311278416787641, 0.2146562337112265], [0.1931548636579131, 0.073606819040117955, 0.21542362939081539], [0.19571853588267552, 0.072157781039602742, 0.21617499187076789], [0.19819343656336558, 0.070974625252738788, 0.21690975060032436], [0.20058760685133747, 0.070064576149984209, 0.21762721310371608], [0.20290365333558247, 0.069435248580458964, 0.21833167885096033], [0.20531725273301316, 0.068919592266397572, 0.21911516689288835], [0.20785704662965598, 0.068484398797025281, 0.22000133917653536], [0.21052882914958676, 0.06812195249816172, 0.22098759107715404], [0.2133313859647627, 0.067830148426026665, 0.22207043213024291], [0.21625279838647882, 0.067616330270516389, 0.22324568672294431], [0.21930503925136402, 0.067465786362940039, 0.22451023616807558], [0.22247308588973624, 0.067388214053092838, 0.22585960379408354], [0.2257539681670791, 0.067382132300147474, 0.22728984778098055], [0.22915620278592841, 0.067434730871152565, 0.22879681433956656], [0.23266299920501882, 0.067557104388479783, 0.23037617493752832], [0.23627495835774248, 0.06774359820987802, 0.23202360805926608], [0.23999586188690308, 0.067985029964779953, 0.23373434258507808], [0.24381149720247919, 0.068289851529011875, 0.23550427698321885], [0.24772092990501099, 0.068653337909486523, 0.2373288009471749], [0.25172899728289466, 0.069064630826035506, 0.23920260612763083], [0.25582135547481771, 0.06953231029187984, 0.24112190491594204], [0.25999463887892144, 0.070053855603861875, 0.24308218808684579], [0.26425512207060942, 0.070616595622995437, 0.24507758869355967], [0.26859095948172862, 0.071226716277922458, 0.24710443563450618], [0.27299701518897301, 0.071883555446163511, 0.24915847093232929], [0.27747150809142801, 0.072582969899254779, 0.25123493995942769], [0.28201746297366942, 0.073315693214040967, 0.25332800295084507], [0.28662309235899847, 0.074088460826808866, 0.25543478673717029], [0.29128515387578635, 0.074899049847466703, 0.25755101595750435], [0.2960004726065818, 0.075745336000958424, 0.25967245030364566], [0.30077276812918691, 0.076617824336164764, 0.26179294097819672], [0.30559226007249934, 0.077521963107537312, 0.26391006692119662], [0.31045520848595526, 0.078456871676182177, 0.2660200572779356], [0.31535870009205808, 0.079420997315243186, 0.26811904076941961], [0.32029986557994061, 0.080412994737554838, 0.27020322893039511], [0.32527888860401261, 0.081428390076546092, 0.27226772884656186], [0.33029174471181438, 0.08246763389003825, 0.27430929404579435], [0.33533353224455448, 0.083532434119003962, 0.27632534356790039], [0.34040164359597463, 0.084622236191702671, 0.27831254595259397], [0.34549355713871799, 0.085736654965126335, 0.28026769921081435], [0.35060678246032478, 0.08687555176033529, 0.28218770540182386], [0.35573889947341125, 0.088038974350243354, 0.2840695897279818], [0.36088752387578377, 0.089227194362745205, 0.28591050458531014], [0.36605031412464006, 0.090440685427697898, 0.2877077458811747], [0.37122508431309342, 0.091679997480262732, 0.28945865397633169], [0.3764103053221462, 0.092945198093777909, 0.29116024157313919], [0.38160247377467543, 0.094238731263712183, 0.29281107506269488], [0.38679939079544168, 0.09556181960083443, 0.29440901248173756], [0.39199887556812907, 0.09691583650296684, 0.29595212005509081], [0.39719876876325577, 0.098302320968278623, 0.29743856476285779], [0.40239692379737496, 0.099722930314950553, 0.29886674369733968], [0.40759120392688708, 0.10117945586419633, 0.30023519507728602], [0.41277985630360303, 0.1026734006932461, 0.30154226437468967], [0.41796105205173684, 0.10420644885760968, 0.30278652039631843], [0.42313214269556043, 0.10578120994917611, 0.3039675809469457], [0.42829101315789753, 0.1073997763055258, 0.30508479060294547], [0.4334355841041439, 0.1090642347484701, 0.30613767928289148], [0.43856378187931538, 0.11077667828375456, 0.30712600062348083], [0.44367358645071275, 0.11253912421257944, 0.30804973095465449], [0.44876299173174822, 0.11435355574622549, 0.30890905921943196], [0.45383005086999889, 0.11622183788331528, 0.30970441249844921], [0.45887288947308297, 0.11814571137706886, 0.31043636979038808], [0.46389102840284874, 0.12012561256850712, 0.31110343446582983], [0.46888111384598413, 0.12216445576414045, 0.31170911458932665], [0.473841437035254, 0.12426354237989065, 0.31225470169927194], [0.47877034239726296, 0.12642401401409453, 0.31274172735821959], [0.48366628618847957, 0.12864679022013889, 0.31317188565991266], [0.48852847371852987, 0.13093210934893723, 0.31354553695453014], [0.49335504375145617, 0.13328091630401023, 0.31386561956734976], [0.49814435462074153, 0.13569380302451714, 0.314135190862664], [0.50289524974970612, 0.13817086581280427, 0.31435662153833671], [0.50760681181053691, 0.14071192654913128, 0.31453200120082569], [0.51227835105321762, 0.14331656120063752, 0.3146630922831542], [0.51690848800544464, 0.14598463068714407, 0.31475407592280041], [0.52149652863229956, 0.14871544765633712, 0.31480767954534428], [0.52604189625477482, 0.15150818660835483, 0.31482653406646727], [0.53054420489856446, 0.15436183633886777, 0.31481299789187128], [0.5350027976174474, 0.15727540775107324, 0.31477085207396532], [0.53941736649199057, 0.16024769309971934, 0.31470295028655965], [0.54378771313608565, 0.16327738551419116, 0.31461204226295625], [0.54811370033467621, 0.1663630904279047, 0.31450102990914708], [0.55239521572711914, 0.16950338809328983, 0.31437291554615371], [0.55663229034969341, 0.17269677158182117, 0.31423043195101424], [0.56082499039117173, 0.17594170887918095, 0.31407639883970623], [0.56497343529017696, 0.17923664950367169, 0.3139136046337036], [0.56907784784011428, 0.18258004462335425, 0.31374440956796529], [0.57313845754107873, 0.18597036007065024, 0.31357126868520002], [0.57715550812992045, 0.18940601489760422, 0.31339704333572083], [0.58112932761586555, 0.19288548904692518, 0.31322399394183942], [0.58506024396466882, 0.19640737049066315, 0.31305401163732732], [0.58894861935544707, 0.19997020971775276, 0.31288922211590126], [0.59279480536520257, 0.20357251410079796, 0.31273234839304942], [0.59659918109122367, 0.207212956082026, 0.31258523031121233], [0.60036213010411577, 0.21089030138947745, 0.31244934410414688], [0.60408401696732739, 0.21460331490206347, 0.31232652641170694], [0.60776523994818654, 0.21835070166659282, 0.31221903291870201], [0.6114062072731884, 0.22213124697023234, 0.31212881396435238], [0.61500723236391375, 0.22594402043981826, 0.31205680685765741], [0.61856865258877192, 0.22978799249179921, 0.31200463838728931], [0.62209079821082613, 0.2336621873300741, 0.31197383273627388], [0.62557416500434959, 0.23756535071152696, 0.31196698314912269], [0.62901892016985872, 0.24149689191922535, 0.31198447195645718], [0.63242534854210275, 0.24545598775548677, 0.31202765974624452], [0.6357937104834237, 0.24944185818822678, 0.31209793953300591], [0.6391243387840212, 0.25345365461983138, 0.31219689612063978], [0.642417577481186, 0.257490519876798, 0.31232631707560987], [0.64567349382645434, 0.26155203161615281, 0.31248673753935263], [0.64889230169458245, 0.26563755336209077, 0.31267941819570189], [0.65207417290277303, 0.26974650525236699, 0.31290560605819168], [0.65521932609327127, 0.27387826652410152, 0.3131666792687211], [0.6583280801134499, 0.27803210957665631, 0.3134643447952643], [0.66140037532601781, 0.28220778870555907, 0.31379912926498488], [0.66443632469878844, 0.28640483614256179, 0.31417223403606975], [0.66743603766369131, 0.29062280081258873, 0.31458483752056837], [0.67039959547676198, 0.29486126309253047, 0.31503813956872212], [0.67332725564817331, 0.29911962764489264, 0.31553372323982209], [0.67621897924409746, 0.30339762792450425, 0.3160724937230589], [0.67907474028157344, 0.30769497879760166, 0.31665545668946665], [0.68189457150944521, 0.31201133280550686, 0.31728380489244951], [0.68467850942494535, 0.31634634821222207, 0.31795870784057567], [0.68742656435169625, 0.32069970535138104, 0.31868137622277692], [0.6901389321505248, 0.32507091815606004, 0.31945332332898302], [0.69281544846764931, 0.32945984647042675, 0.3202754315314667], [0.69545608346891119, 0.33386622163232865, 0.32114884306985791], [0.6980608153581771, 0.33828976326048621, 0.32207478855218091], [0.70062962477242097, 0.34273019305341756, 0.32305449047765694], [0.70316249458814151, 0.34718723719597999, 0.32408913679491225], [0.70565951122610093, 0.35166052978120937, 0.32518014084085567], [0.70812059568420482, 0.35614985523380299, 0.32632861885644465], [0.7105456546582587, 0.36065500290840113, 0.32753574162788762], [0.71293466839773467, 0.36517570519856757, 0.3288027427038317], [0.71528760614847287, 0.36971170225223449, 0.3301308728723546], [0.71760444908133847, 0.37426272710686193, 0.33152138620958932], [0.71988521490549851, 0.37882848839337313, 0.33297555200245399], [0.7221299918421461, 0.38340864508963057, 0.33449469983585844], [0.72433865647781592, 0.38800301593162145, 0.33607995965691828], [0.72651122900227549, 0.3926113126792577, 0.3377325942005665], [0.72864773856716547, 0.39723324476747235, 0.33945384341064017], [0.73074820754845171, 0.401868526884681, 0.3412449533046818], [0.73281270506268747, 0.4065168468778026, 0.34310715173410822], [0.73484133598564938, 0.41117787004519513, 0.34504169470809071], [0.73683422173585866, 0.41585125850290111, 0.34704978520758401], [0.73879140024599266, 0.42053672992315327, 0.34913260148542435], [0.74071301619506091, 0.4252339389526239, 0.35129130890802607], [0.7425992159973317, 0.42994254036133867, 0.35352709245374592], [0.74445018676570673, 0.43466217184617112, 0.35584108091122535], [0.74626615789163442, 0.43939245044973502, 0.35823439142300639], [0.74804739275559562, 0.44413297780351974, 0.36070813602540136], [0.74979420547170472, 0.44888333481548809, 0.36326337558360278], [0.75150685045891663, 0.45364314496866825, 0.36590112443835765], [0.75318566369046569, 0.45841199172949604, 0.36862236642234769], [0.75483105066959544, 0.46318942799460555, 0.3714280448394211], [0.75644341577140706, 0.46797501437948458, 0.37431909037543515], [0.75802325538455839, 0.4727682731566229, 0.37729635531096678], [0.75957111105340058, 0.47756871222057079, 0.380360657784311], [0.7610876378057071, 0.48237579130289127, 0.38351275723852291], [0.76257333554052609, 0.48718906673415824, 0.38675335037837993], [0.76402885609288662, 0.49200802533379656, 0.39008308392311997], [0.76545492593330511, 0.49683212909727231, 0.39350254000115381], [0.76685228950643891, 0.5016608471009063, 0.39701221751773474], [0.76822176599735303, 0.50649362371287909, 0.40061257089416885], [0.7695642334401418, 0.5113298901696085, 0.40430398069682483], [0.77088091962302474, 0.51616892643469103, 0.40808667584648967], [0.77217257229605551, 0.5210102658711383, 0.41196089987122869], [0.77344021829889886, 0.52585332093451564, 0.41592679539764366], [0.77468494746063199, 0.53069749384776732, 0.41998440356963762], [0.77590790730685699, 0.53554217882461186, 0.42413367909988375], [0.7771103295521099, 0.54038674910561235, 0.42837450371258479], [0.77829345807633121, 0.54523059488426595, 0.432706647838971], [0.77945862731506643, 0.55007308413977274, 0.43712979856444761], [0.78060774749483774, 0.55491335744890613, 0.44164332426364639], [0.78174180478981836, 0.55975098052594863, 0.44624687186865436], [0.78286225264440912, 0.56458533111166875, 0.45093985823706345], [0.78397060836414478, 0.56941578326710418, 0.45572154742892063], [0.78506845019606841, 0.5742417003617839, 0.46059116206904965], [0.78615737132332963, 0.5790624629815756, 0.46554778281918402], [0.78723904108188347, 0.58387743744557208, 0.47059039582133383], [0.78831514045623963, 0.58868600173562435, 0.47571791879076081], [0.78938737766251943, 0.5934875421745599, 0.48092913815357724], [0.79045776847727878, 0.59828134277062461, 0.48622257801969754], [0.79152832843475607, 0.60306670593147205, 0.49159667021646397], [0.79260034304237448, 0.60784322087037024, 0.49705020621532009], [0.79367559698664958, 0.61261029334072192, 0.50258161291269432], [0.79475585972654039, 0.61736734400220705, 0.50818921213102985], [0.79584292379583765, 0.62211378808451145, 0.51387124091909786], [0.79693854719951607, 0.62684905679296699, 0.5196258425240281], [0.79804447815136637, 0.63157258225089552, 0.52545108144834785], [0.7991624518501963, 0.63628379372029187, 0.53134495942561433], [0.80029415389753977, 0.64098213306749863, 0.53730535185141037], [0.80144124292560048, 0.64566703459218766, 0.5433300863249918], [0.80260531146112946, 0.65033793748103852, 0.54941691584603647], [0.80378792531077625, 0.65499426549472628, 0.55556350867083815], [0.80499054790810298, 0.65963545027564163, 0.56176745110546977], [0.80621460526927058, 0.66426089585282289, 0.56802629178649788], [0.8074614045096935, 0.6688700095398864, 0.57433746373459582], [0.80873219170089694, 0.67346216702194517, 0.58069834805576737], [0.81002809466520687, 0.67803672673971815, 0.58710626908082753], [0.81135014011763329, 0.68259301546243389, 0.59355848909050757], [0.81269922039881493, 0.68713033714618876, 0.60005214820435104], [0.81407611046993344, 0.69164794791482131, 0.6065843782630862], [0.81548146627279483, 0.69614505508308089, 0.61315221209322646], [0.81691575775055891, 0.70062083014783982, 0.61975260637257923], [0.81837931164498223, 0.70507438189635097, 0.62638245478933297], [0.81987230650455289, 0.70950474978787481, 0.63303857040067113], [0.8213947205565636, 0.7139109141951604, 0.63971766697672761], [0.82294635110428427, 0.71829177331290062, 0.6464164243818421], [0.8245268129450285, 0.72264614312088882, 0.65313137915422603], [0.82613549710580259, 0.72697275518238258, 0.65985900156216504], [0.8277716072353446, 0.73127023324078089, 0.66659570204682972], [0.82943407816481474, 0.7355371221572935, 0.67333772009301907], [0.83112163529096306, 0.73977184647638616, 0.68008125203631464], [0.83283277185777982, 0.74397271817459876, 0.68682235874648545], [0.8345656905566583, 0.7481379479992134, 0.69355697649863846], [0.83631898844737929, 0.75226548952875261, 0.70027999028864962], [0.83809123476131964, 0.75635314860808633, 0.70698561390212977], [0.83987839884120874, 0.76039907199779677, 0.71367147811129228], [0.84167750766845151, 0.76440101200982946, 0.72033299387284622], [0.84348529222933699, 0.76835660399870176, 0.72696536998972039], [0.84529810731955113, 0.77226338601044719, 0.73356368240541492], [0.84711195507965098, 0.77611880236047159, 0.74012275762807056], [0.84892245563117641, 0.77992021407650147, 0.74663719293664366], [0.85072697023178789, 0.78366457342383888, 0.7530974636118285], [0.85251907207708444, 0.78734936133548439, 0.7594994148789691], [0.85429219611470464, 0.79097196777091994, 0.76583801477914104], [0.85604022314725403, 0.79452963601550608, 0.77210610037674143], [0.85775662943504905, 0.79801963142713928, 0.77829571667247499], [0.8594346370300241, 0.8014392309950078, 0.78439788751383921], [0.86107117027565516, 0.80478517909812231, 0.79039529663736285], [0.86265601051127572, 0.80805523804261525, 0.796282666437655], [0.86418343723941027, 0.81124644224653542, 0.80204612696863953], [0.86564934325605325, 0.81435544067514909, 0.80766972324164554], [0.86705314907048503, 0.81737804041911244, 0.81313419626911398], [0.86839954695818633, 0.82030875512181523, 0.81841638963128993], [0.86969131502613806, 0.82314158859569164, 0.82350476683173168], [0.87093846717297507, 0.82586857889438514, 0.82838497261149613], [0.87215331978454325, 0.82848052823709672, 0.8330486712880828], [0.87335171360916275, 0.83096715251272624, 0.83748851001197089], [0.87453793320260187, 0.83331972948645461, 0.84171925358069011], [0.87571458709961403, 0.8355302318472394, 0.84575537519027078], [0.87687848451614692, 0.83759238071186537, 0.84961373549150254], [0.87802298436649007, 0.83950165618540074, 0.85330645352458923], [0.87913244240792765, 0.84125554884475906, 0.85685572291039636], [0.88019293315695812, 0.84285224824778615, 0.86027399927156634], [0.88119169871341951, 0.84429066717717349, 0.86356595168669881], [0.88211542489401606, 0.84557007254559347, 0.86673765046233331], [0.88295168595448525, 0.84668970275699273, 0.86979617048190971], [0.88369127145898041, 0.84764891761519268, 0.87274147101441557], [0.88432713054113543, 0.84844741572055415, 0.87556785228242973], [0.88485138159908572, 0.84908426422893801, 0.87828235285372469], [0.88525897972630474, 0.84955892810989209, 0.88088414794024839], [0.88554714811952384, 0.84987174283631584, 0.88336206121170946], [0.88571155122845646, 0.85002186115856315, 0.88572538990087124], ] _turbo_data = [ [0.18995, 0.07176, 0.23217], [0.19483, 0.08339, 0.26149], [0.19956, 0.09498, 0.29024], [0.20415, 0.10652, 0.31844], [0.20860, 0.11802, 0.34607], [0.21291, 0.12947, 0.37314], [0.21708, 0.14087, 0.39964], [0.22111, 0.15223, 0.42558], [0.22500, 0.16354, 0.45096], [0.22875, 0.17481, 0.47578], [0.23236, 0.18603, 0.50004], [0.23582, 0.19720, 0.52373], [0.23915, 0.20833, 0.54686], [0.24234, 0.21941, 0.56942], [0.24539, 0.23044, 0.59142], [0.24830, 0.24143, 0.61286], [0.25107, 0.25237, 0.63374], [0.25369, 0.26327, 0.65406], [0.25618, 0.27412, 0.67381], [0.25853, 0.28492, 0.69300], [0.26074, 0.29568, 0.71162], [0.26280, 0.30639, 0.72968], [0.26473, 0.31706, 0.74718], [0.26652, 0.32768, 0.76412], [0.26816, 0.33825, 0.78050], [0.26967, 0.34878, 0.79631], [0.27103, 0.35926, 0.81156], [0.27226, 0.36970, 0.82624], [0.27334, 0.38008, 0.84037], [0.27429, 0.39043, 0.85393], [0.27509, 0.40072, 0.86692], [0.27576, 0.41097, 0.87936], [0.27628, 0.42118, 0.89123], [0.27667, 0.43134, 0.90254], [0.27691, 0.44145, 0.91328], [0.27701, 0.45152, 0.92347], [0.27698, 0.46153, 0.93309], [0.27680, 0.47151, 0.94214], [0.27648, 0.48144, 0.95064], [0.27603, 0.49132, 0.95857], [0.27543, 0.50115, 0.96594], [0.27469, 0.51094, 0.97275], [0.27381, 0.52069, 0.97899], [0.27273, 0.53040, 0.98461], [0.27106, 0.54015, 0.98930], [0.26878, 0.54995, 0.99303], [0.26592, 0.55979, 0.99583], [0.26252, 0.56967, 0.99773], [0.25862, 0.57958, 0.99876], [0.25425, 0.58950, 0.99896], [0.24946, 0.59943, 0.99835], [0.24427, 0.60937, 0.99697], [0.23874, 0.61931, 0.99485], [0.23288, 0.62923, 0.99202], [0.22676, 0.63913, 0.98851], [0.22039, 0.64901, 0.98436], [0.21382, 0.65886, 0.97959], [0.20708, 0.66866, 0.97423], [0.20021, 0.67842, 0.96833], [0.19326, 0.68812, 0.96190], [0.18625, 0.69775, 0.95498], [0.17923, 0.70732, 0.94761], [0.17223, 0.71680, 0.93981], [0.16529, 0.72620, 0.93161], [0.15844, 0.73551, 0.92305], [0.15173, 0.74472, 0.91416], [0.14519, 0.75381, 0.90496], [0.13886, 0.76279, 0.89550], [0.13278, 0.77165, 0.88580], [0.12698, 0.78037, 0.87590], [0.12151, 0.78896, 0.86581], [0.11639, 0.79740, 0.85559], [0.11167, 0.80569, 0.84525], [0.10738, 0.81381, 0.83484], [0.10357, 0.82177, 0.82437], [0.10026, 0.82955, 0.81389], [0.09750, 0.83714, 0.80342], [0.09532, 0.84455, 0.79299], [0.09377, 0.85175, 0.78264], [0.09287, 0.85875, 0.77240], [0.09267, 0.86554, 0.76230], [0.09320, 0.87211, 0.75237], [0.09451, 0.87844, 0.74265], [0.09662, 0.88454, 0.73316], [0.09958, 0.89040, 0.72393], [0.10342, 0.89600, 0.71500], [0.10815, 0.90142, 0.70599], [0.11374, 0.90673, 0.69651], [0.12014, 0.91193, 0.68660], [0.12733, 0.91701, 0.67627], [0.13526, 0.92197, 0.66556], [0.14391, 0.92680, 0.65448], [0.15323, 0.93151, 0.64308], [0.16319, 0.93609, 0.63137], [0.17377, 0.94053, 0.61938], [0.18491, 0.94484, 0.60713], [0.19659, 0.94901, 0.59466], [0.20877, 0.95304, 0.58199], [0.22142, 0.95692, 0.56914], [0.23449, 0.96065, 0.55614], [0.24797, 0.96423, 0.54303], [0.26180, 0.96765, 0.52981], [0.27597, 0.97092, 0.51653], [0.29042, 0.97403, 0.50321], [0.30513, 0.97697, 0.48987], [0.32006, 0.97974, 0.47654], [0.33517, 0.98234, 0.46325], [0.35043, 0.98477, 0.45002], [0.36581, 0.98702, 0.43688], [0.38127, 0.98909, 0.42386], [0.39678, 0.99098, 0.41098], [0.41229, 0.99268, 0.39826], [0.42778, 0.99419, 0.38575], [0.44321, 0.99551, 0.37345], [0.45854, 0.99663, 0.36140], [0.47375, 0.99755, 0.34963], [0.48879, 0.99828, 0.33816], [0.50362, 0.99879, 0.32701], [0.51822, 0.99910, 0.31622], [0.53255, 0.99919, 0.30581], [0.54658, 0.99907, 0.29581], [0.56026, 0.99873, 0.28623], [0.57357, 0.99817, 0.27712], [0.58646, 0.99739, 0.26849], [0.59891, 0.99638, 0.26038], [0.61088, 0.99514, 0.25280], [0.62233, 0.99366, 0.24579], [0.63323, 0.99195, 0.23937], [0.64362, 0.98999, 0.23356], [0.65394, 0.98775, 0.22835], [0.66428, 0.98524, 0.22370], [0.67462, 0.98246, 0.21960], [0.68494, 0.97941, 0.21602], [0.69525, 0.97610, 0.21294], [0.70553, 0.97255, 0.21032], [0.71577, 0.96875, 0.20815], [0.72596, 0.96470, 0.20640], [0.73610, 0.96043, 0.20504], [0.74617, 0.95593, 0.20406], [0.75617, 0.95121, 0.20343], [0.76608, 0.94627, 0.20311], [0.77591, 0.94113, 0.20310], [0.78563, 0.93579, 0.20336], [0.79524, 0.93025, 0.20386], [0.80473, 0.92452, 0.20459], [0.81410, 0.91861, 0.20552], [0.82333, 0.91253, 0.20663], [0.83241, 0.90627, 0.20788], [0.84133, 0.89986, 0.20926], [0.85010, 0.89328, 0.21074], [0.85868, 0.88655, 0.21230], [0.86709, 0.87968, 0.21391], [0.87530, 0.87267, 0.21555], [0.88331, 0.86553, 0.21719], [0.89112, 0.85826, 0.21880], [0.89870, 0.85087, 0.22038], [0.90605, 0.84337, 0.22188], [0.91317, 0.83576, 0.22328], [0.92004, 0.82806, 0.22456], [0.92666, 0.82025, 0.22570], [0.93301, 0.81236, 0.22667], [0.93909, 0.80439, 0.22744], [0.94489, 0.79634, 0.22800], [0.95039, 0.78823, 0.22831], [0.95560, 0.78005, 0.22836], [0.96049, 0.77181, 0.22811], [0.96507, 0.76352, 0.22754], [0.96931, 0.75519, 0.22663], [0.97323, 0.74682, 0.22536], [0.97679, 0.73842, 0.22369], [0.98000, 0.73000, 0.22161], [0.98289, 0.72140, 0.21918], [0.98549, 0.71250, 0.21650], [0.98781, 0.70330, 0.21358], [0.98986, 0.69382, 0.21043], [0.99163, 0.68408, 0.20706], [0.99314, 0.67408, 0.20348], [0.99438, 0.66386, 0.19971], [0.99535, 0.65341, 0.19577], [0.99607, 0.64277, 0.19165], [0.99654, 0.63193, 0.18738], [0.99675, 0.62093, 0.18297], [0.99672, 0.60977, 0.17842], [0.99644, 0.59846, 0.17376], [0.99593, 0.58703, 0.16899], [0.99517, 0.57549, 0.16412], [0.99419, 0.56386, 0.15918], [0.99297, 0.55214, 0.15417], [0.99153, 0.54036, 0.14910], [0.98987, 0.52854, 0.14398], [0.98799, 0.51667, 0.13883], [0.98590, 0.50479, 0.13367], [0.98360, 0.49291, 0.12849], [0.98108, 0.48104, 0.12332], [0.97837, 0.46920, 0.11817], [0.97545, 0.45740, 0.11305], [0.97234, 0.44565, 0.10797], [0.96904, 0.43399, 0.10294], [0.96555, 0.42241, 0.09798], [0.96187, 0.41093, 0.09310], [0.95801, 0.39958, 0.08831], [0.95398, 0.38836, 0.08362], [0.94977, 0.37729, 0.07905], [0.94538, 0.36638, 0.07461], [0.94084, 0.35566, 0.07031], [0.93612, 0.34513, 0.06616], [0.93125, 0.33482, 0.06218], [0.92623, 0.32473, 0.05837], [0.92105, 0.31489, 0.05475], [0.91572, 0.30530, 0.05134], [0.91024, 0.29599, 0.04814], [0.90463, 0.28696, 0.04516], [0.89888, 0.27824, 0.04243], [0.89298, 0.26981, 0.03993], [0.88691, 0.26152, 0.03753], [0.88066, 0.25334, 0.03521], [0.87422, 0.24526, 0.03297], [0.86760, 0.23730, 0.03082], [0.86079, 0.22945, 0.02875], [0.85380, 0.22170, 0.02677], [0.84662, 0.21407, 0.02487], [0.83926, 0.20654, 0.02305], [0.83172, 0.19912, 0.02131], [0.82399, 0.19182, 0.01966], [0.81608, 0.18462, 0.01809], [0.80799, 0.17753, 0.01660], [0.79971, 0.17055, 0.01520], [0.79125, 0.16368, 0.01387], [0.78260, 0.15693, 0.01264], [0.77377, 0.15028, 0.01148], [0.76476, 0.14374, 0.01041], [0.75556, 0.13731, 0.00942], [0.74617, 0.13098, 0.00851], [0.73661, 0.12477, 0.00769], [0.72686, 0.11867, 0.00695], [0.71692, 0.11268, 0.00629], [0.70680, 0.10680, 0.00571], [0.69650, 0.10102, 0.00522], [0.68602, 0.09536, 0.00481], [0.67535, 0.08980, 0.00449], [0.66449, 0.08436, 0.00424], [0.65345, 0.07902, 0.00408], [0.64223, 0.07380, 0.00401], [0.63082, 0.06868, 0.00401], [0.61923, 0.06367, 0.00410], [0.60746, 0.05878, 0.00427], [0.59550, 0.05399, 0.00453], [0.58336, 0.04931, 0.00486], [0.57103, 0.04474, 0.00529], [0.55852, 0.04028, 0.00579], [0.54583, 0.03593, 0.00638], [0.53295, 0.03169, 0.00705], [0.51989, 0.02756, 0.00780], [0.50664, 0.02354, 0.00863], [0.49321, 0.01963, 0.00955], [0.47960, 0.01583, 0.01055], ] _twilight_shifted_data = ( _twilight_data[len(_twilight_data) // 2 :] + _twilight_data[: len(_twilight_data) // 2] ) _twilight_shifted_data.reverse() cmaps = {} for (name, data) in ( ('magma', _magma_data), ('inferno', _inferno_data), ('plasma', _plasma_data), ('viridis', _viridis_data), ('cividis', _cividis_data), ('twilight', _twilight_data), ('twilight_shifted', _twilight_shifted_data), ('turbo', _turbo_data), ): cmaps[name] = ListedColormap(data, name=name) # generate reversed colormap name = name + '_r' cmaps[name] = ListedColormap(list(reversed(data)), name=name) napari-0.5.0a1/napari/utils/colormaps/vendored/_color_data.py000066400000000000000000001042701437041365600242640ustar00rootroot00000000000000from collections import OrderedDict BASE_COLORS = { 'b': '#0000ff', 'g': '#008000', 'r': '#ff0000', 'c': '#00bfbf', 'm': '#bf00bf', 'y': '#bfbf00', 'k': '#000000', 'w': '#ffffff', } NTH_COLORS = { 'C0': '#1f77b4', 'C1': '#ff7f0e', 'C2': '#2ca02c', 'C3': '#d62728', 'C4': '#9467bd', 'C5': '#8c564b', 'C6': '#e377c2', 'C7': '#7f7f7f', 'C8': '#bcbd22', 'C9': '#17becf', } # These colors are from Tableau TABLEAU_COLOR_TUPLES = ( ('blue', '#1f77b4'), ('orange', '#ff7f0e'), ('green', '#2ca02c'), ('red', '#d62728'), ('purple', '#9467bd'), ('brown', '#8c564b'), ('pink', '#e377c2'), ('gray', '#7f7f7f'), ('olive', '#bcbd22'), ('cyan', '#17becf'), ) # Normalize name to "tab:" to avoid name collisions. TABLEAU_COLORS = OrderedDict( ('tab:' + name, value) for name, value in TABLEAU_COLOR_TUPLES) # This mapping of color names -> hex values is taken from # a survey run by Randall Munroe see: # http://blog.xkcd.com/2010/05/03/color-survey-results/ # for more details. The results are hosted at # https://xkcd.com/color/rgb.txt # # License: http://creativecommons.org/publicdomain/zero/1.0/ XKCD_COLORS = { 'cloudy blue': '#acc2d9', 'dark pastel green': '#56ae57', 'dust': '#b2996e', 'electric lime': '#a8ff04', 'fresh green': '#69d84f', 'light eggplant': '#894585', 'nasty green': '#70b23f', 'really light blue': '#d4ffff', 'tea': '#65ab7c', 'warm purple': '#952e8f', 'yellowish tan': '#fcfc81', 'cement': '#a5a391', 'dark grass green': '#388004', 'dusty teal': '#4c9085', 'grey teal': '#5e9b8a', 'macaroni and cheese': '#efb435', 'pinkish tan': '#d99b82', 'spruce': '#0a5f38', 'strong blue': '#0c06f7', 'toxic green': '#61de2a', 'windows blue': '#3778bf', 'blue blue': '#2242c7', 'blue with a hint of purple': '#533cc6', 'booger': '#9bb53c', 'bright sea green': '#05ffa6', 'dark green blue': '#1f6357', 'deep turquoise': '#017374', 'green teal': '#0cb577', 'strong pink': '#ff0789', 'bland': '#afa88b', 'deep aqua': '#08787f', 'lavender pink': '#dd85d7', 'light moss green': '#a6c875', 'light seafoam green': '#a7ffb5', 'olive yellow': '#c2b709', 'pig pink': '#e78ea5', 'deep lilac': '#966ebd', 'desert': '#ccad60', 'dusty lavender': '#ac86a8', 'purpley grey': '#947e94', 'purply': '#983fb2', 'candy pink': '#ff63e9', 'light pastel green': '#b2fba5', 'boring green': '#63b365', 'kiwi green': '#8ee53f', 'light grey green': '#b7e1a1', 'orange pink': '#ff6f52', 'tea green': '#bdf8a3', 'very light brown': '#d3b683', 'egg shell': '#fffcc4', 'eggplant purple': '#430541', 'powder pink': '#ffb2d0', 'reddish grey': '#997570', 'baby shit brown': '#ad900d', 'liliac': '#c48efd', 'stormy blue': '#507b9c', 'ugly brown': '#7d7103', 'custard': '#fffd78', 'darkish pink': '#da467d', 'deep brown': '#410200', 'greenish beige': '#c9d179', 'manilla': '#fffa86', 'off blue': '#5684ae', 'battleship grey': '#6b7c85', 'browny green': '#6f6c0a', 'bruise': '#7e4071', 'kelley green': '#009337', 'sickly yellow': '#d0e429', 'sunny yellow': '#fff917', 'azul': '#1d5dec', 'darkgreen': '#054907', 'green/yellow': '#b5ce08', 'lichen': '#8fb67b', 'light light green': '#c8ffb0', 'pale gold': '#fdde6c', 'sun yellow': '#ffdf22', 'tan green': '#a9be70', 'burple': '#6832e3', 'butterscotch': '#fdb147', 'toupe': '#c7ac7d', 'dark cream': '#fff39a', 'indian red': '#850e04', 'light lavendar': '#efc0fe', 'poison green': '#40fd14', 'baby puke green': '#b6c406', 'bright yellow green': '#9dff00', 'charcoal grey': '#3c4142', 'squash': '#f2ab15', 'cinnamon': '#ac4f06', 'light pea green': '#c4fe82', 'radioactive green': '#2cfa1f', 'raw sienna': '#9a6200', 'baby purple': '#ca9bf7', 'cocoa': '#875f42', 'light royal blue': '#3a2efe', 'orangeish': '#fd8d49', 'rust brown': '#8b3103', 'sand brown': '#cba560', 'swamp': '#698339', 'tealish green': '#0cdc73', 'burnt siena': '#b75203', 'camo': '#7f8f4e', 'dusk blue': '#26538d', 'fern': '#63a950', 'old rose': '#c87f89', 'pale light green': '#b1fc99', 'peachy pink': '#ff9a8a', 'rosy pink': '#f6688e', 'light bluish green': '#76fda8', 'light bright green': '#53fe5c', 'light neon green': '#4efd54', 'light seafoam': '#a0febf', 'tiffany blue': '#7bf2da', 'washed out green': '#bcf5a6', 'browny orange': '#ca6b02', 'nice blue': '#107ab0', 'sapphire': '#2138ab', 'greyish teal': '#719f91', 'orangey yellow': '#fdb915', 'parchment': '#fefcaf', 'straw': '#fcf679', 'very dark brown': '#1d0200', 'terracota': '#cb6843', 'ugly blue': '#31668a', 'clear blue': '#247afd', 'creme': '#ffffb6', 'foam green': '#90fda9', 'grey/green': '#86a17d', 'light gold': '#fddc5c', 'seafoam blue': '#78d1b6', 'topaz': '#13bbaf', 'violet pink': '#fb5ffc', 'wintergreen': '#20f986', 'yellow tan': '#ffe36e', 'dark fuchsia': '#9d0759', 'indigo blue': '#3a18b1', 'light yellowish green': '#c2ff89', 'pale magenta': '#d767ad', 'rich purple': '#720058', 'sunflower yellow': '#ffda03', 'green/blue': '#01c08d', 'leather': '#ac7434', 'racing green': '#014600', 'vivid purple': '#9900fa', 'dark royal blue': '#02066f', 'hazel': '#8e7618', 'muted pink': '#d1768f', 'booger green': '#96b403', 'canary': '#fdff63', 'cool grey': '#95a3a6', 'dark taupe': '#7f684e', 'darkish purple': '#751973', 'true green': '#089404', 'coral pink': '#ff6163', 'dark sage': '#598556', 'dark slate blue': '#214761', 'flat blue': '#3c73a8', 'mushroom': '#ba9e88', 'rich blue': '#021bf9', 'dirty purple': '#734a65', 'greenblue': '#23c48b', 'icky green': '#8fae22', 'light khaki': '#e6f2a2', 'warm blue': '#4b57db', 'dark hot pink': '#d90166', 'deep sea blue': '#015482', 'carmine': '#9d0216', 'dark yellow green': '#728f02', 'pale peach': '#ffe5ad', 'plum purple': '#4e0550', 'golden rod': '#f9bc08', 'neon red': '#ff073a', 'old pink': '#c77986', 'very pale blue': '#d6fffe', 'blood orange': '#fe4b03', 'grapefruit': '#fd5956', 'sand yellow': '#fce166', 'clay brown': '#b2713d', 'dark blue grey': '#1f3b4d', 'flat green': '#699d4c', 'light green blue': '#56fca2', 'warm pink': '#fb5581', 'dodger blue': '#3e82fc', 'gross green': '#a0bf16', 'ice': '#d6fffa', 'metallic blue': '#4f738e', 'pale salmon': '#ffb19a', 'sap green': '#5c8b15', 'algae': '#54ac68', 'bluey grey': '#89a0b0', 'greeny grey': '#7ea07a', 'highlighter green': '#1bfc06', 'light light blue': '#cafffb', 'light mint': '#b6ffbb', 'raw umber': '#a75e09', 'vivid blue': '#152eff', 'deep lavender': '#8d5eb7', 'dull teal': '#5f9e8f', 'light greenish blue': '#63f7b4', 'mud green': '#606602', 'pinky': '#fc86aa', 'red wine': '#8c0034', 'shit green': '#758000', 'tan brown': '#ab7e4c', 'darkblue': '#030764', 'rosa': '#fe86a4', 'lipstick': '#d5174e', 'pale mauve': '#fed0fc', 'claret': '#680018', 'dandelion': '#fedf08', 'orangered': '#fe420f', 'poop green': '#6f7c00', 'ruby': '#ca0147', 'dark': '#1b2431', 'greenish turquoise': '#00fbb0', 'pastel red': '#db5856', 'piss yellow': '#ddd618', 'bright cyan': '#41fdfe', 'dark coral': '#cf524e', 'algae green': '#21c36f', 'darkish red': '#a90308', 'reddy brown': '#6e1005', 'blush pink': '#fe828c', 'camouflage green': '#4b6113', 'lawn green': '#4da409', 'putty': '#beae8a', 'vibrant blue': '#0339f8', 'dark sand': '#a88f59', 'purple/blue': '#5d21d0', 'saffron': '#feb209', 'twilight': '#4e518b', 'warm brown': '#964e02', 'bluegrey': '#85a3b2', 'bubble gum pink': '#ff69af', 'duck egg blue': '#c3fbf4', 'greenish cyan': '#2afeb7', 'petrol': '#005f6a', 'royal': '#0c1793', 'butter': '#ffff81', 'dusty orange': '#f0833a', 'off yellow': '#f1f33f', 'pale olive green': '#b1d27b', 'orangish': '#fc824a', 'leaf': '#71aa34', 'light blue grey': '#b7c9e2', 'dried blood': '#4b0101', 'lightish purple': '#a552e6', 'rusty red': '#af2f0d', 'lavender blue': '#8b88f8', 'light grass green': '#9af764', 'light mint green': '#a6fbb2', 'sunflower': '#ffc512', 'velvet': '#750851', 'brick orange': '#c14a09', 'lightish red': '#fe2f4a', 'pure blue': '#0203e2', 'twilight blue': '#0a437a', 'violet red': '#a50055', 'yellowy brown': '#ae8b0c', 'carnation': '#fd798f', 'muddy yellow': '#bfac05', 'dark seafoam green': '#3eaf76', 'deep rose': '#c74767', 'dusty red': '#b9484e', 'grey/blue': '#647d8e', 'lemon lime': '#bffe28', 'purple/pink': '#d725de', 'brown yellow': '#b29705', 'purple brown': '#673a3f', 'wisteria': '#a87dc2', 'banana yellow': '#fafe4b', 'lipstick red': '#c0022f', 'water blue': '#0e87cc', 'brown grey': '#8d8468', 'vibrant purple': '#ad03de', 'baby green': '#8cff9e', 'barf green': '#94ac02', 'eggshell blue': '#c4fff7', 'sandy yellow': '#fdee73', 'cool green': '#33b864', 'pale': '#fff9d0', 'blue/grey': '#758da3', 'hot magenta': '#f504c9', 'greyblue': '#77a1b5', 'purpley': '#8756e4', 'baby shit green': '#889717', 'brownish pink': '#c27e79', 'dark aquamarine': '#017371', 'diarrhea': '#9f8303', 'light mustard': '#f7d560', 'pale sky blue': '#bdf6fe', 'turtle green': '#75b84f', 'bright olive': '#9cbb04', 'dark grey blue': '#29465b', 'greeny brown': '#696006', 'lemon green': '#adf802', 'light periwinkle': '#c1c6fc', 'seaweed green': '#35ad6b', 'sunshine yellow': '#fffd37', 'ugly purple': '#a442a0', 'medium pink': '#f36196', 'puke brown': '#947706', 'very light pink': '#fff4f2', 'viridian': '#1e9167', 'bile': '#b5c306', 'faded yellow': '#feff7f', 'very pale green': '#cffdbc', 'vibrant green': '#0add08', 'bright lime': '#87fd05', 'spearmint': '#1ef876', 'light aquamarine': '#7bfdc7', 'light sage': '#bcecac', 'yellowgreen': '#bbf90f', 'baby poo': '#ab9004', 'dark seafoam': '#1fb57a', 'deep teal': '#00555a', 'heather': '#a484ac', 'rust orange': '#c45508', 'dirty blue': '#3f829d', 'fern green': '#548d44', 'bright lilac': '#c95efb', 'weird green': '#3ae57f', 'peacock blue': '#016795', 'avocado green': '#87a922', 'faded orange': '#f0944d', 'grape purple': '#5d1451', 'hot green': '#25ff29', 'lime yellow': '#d0fe1d', 'mango': '#ffa62b', 'shamrock': '#01b44c', 'bubblegum': '#ff6cb5', 'purplish brown': '#6b4247', 'vomit yellow': '#c7c10c', 'pale cyan': '#b7fffa', 'key lime': '#aeff6e', 'tomato red': '#ec2d01', 'lightgreen': '#76ff7b', 'merlot': '#730039', 'night blue': '#040348', 'purpleish pink': '#df4ec8', 'apple': '#6ecb3c', 'baby poop green': '#8f9805', 'green apple': '#5edc1f', 'heliotrope': '#d94ff5', 'yellow/green': '#c8fd3d', 'almost black': '#070d0d', 'cool blue': '#4984b8', 'leafy green': '#51b73b', 'mustard brown': '#ac7e04', 'dusk': '#4e5481', 'dull brown': '#876e4b', 'frog green': '#58bc08', 'vivid green': '#2fef10', 'bright light green': '#2dfe54', 'fluro green': '#0aff02', 'kiwi': '#9cef43', 'seaweed': '#18d17b', 'navy green': '#35530a', 'ultramarine blue': '#1805db', 'iris': '#6258c4', 'pastel orange': '#ff964f', 'yellowish orange': '#ffab0f', 'perrywinkle': '#8f8ce7', 'tealish': '#24bca8', 'dark plum': '#3f012c', 'pear': '#cbf85f', 'pinkish orange': '#ff724c', 'midnight purple': '#280137', 'light urple': '#b36ff6', 'dark mint': '#48c072', 'greenish tan': '#bccb7a', 'light burgundy': '#a8415b', 'turquoise blue': '#06b1c4', 'ugly pink': '#cd7584', 'sandy': '#f1da7a', 'electric pink': '#ff0490', 'muted purple': '#805b87', 'mid green': '#50a747', 'greyish': '#a8a495', 'neon yellow': '#cfff04', 'banana': '#ffff7e', 'carnation pink': '#ff7fa7', 'tomato': '#ef4026', 'sea': '#3c9992', 'muddy brown': '#886806', 'turquoise green': '#04f489', 'buff': '#fef69e', 'fawn': '#cfaf7b', 'muted blue': '#3b719f', 'pale rose': '#fdc1c5', 'dark mint green': '#20c073', 'amethyst': '#9b5fc0', 'blue/green': '#0f9b8e', 'chestnut': '#742802', 'sick green': '#9db92c', 'pea': '#a4bf20', 'rusty orange': '#cd5909', 'stone': '#ada587', 'rose red': '#be013c', 'pale aqua': '#b8ffeb', 'deep orange': '#dc4d01', 'earth': '#a2653e', 'mossy green': '#638b27', 'grassy green': '#419c03', 'pale lime green': '#b1ff65', 'light grey blue': '#9dbcd4', 'pale grey': '#fdfdfe', 'asparagus': '#77ab56', 'blueberry': '#464196', 'purple red': '#990147', 'pale lime': '#befd73', 'greenish teal': '#32bf84', 'caramel': '#af6f09', 'deep magenta': '#a0025c', 'light peach': '#ffd8b1', 'milk chocolate': '#7f4e1e', 'ocher': '#bf9b0c', 'off green': '#6ba353', 'purply pink': '#f075e6', 'lightblue': '#7bc8f6', 'dusky blue': '#475f94', 'golden': '#f5bf03', 'light beige': '#fffeb6', 'butter yellow': '#fffd74', 'dusky purple': '#895b7b', 'french blue': '#436bad', 'ugly yellow': '#d0c101', 'greeny yellow': '#c6f808', 'orangish red': '#f43605', 'shamrock green': '#02c14d', 'orangish brown': '#b25f03', 'tree green': '#2a7e19', 'deep violet': '#490648', 'gunmetal': '#536267', 'blue/purple': '#5a06ef', 'cherry': '#cf0234', 'sandy brown': '#c4a661', 'warm grey': '#978a84', 'dark indigo': '#1f0954', 'midnight': '#03012d', 'bluey green': '#2bb179', 'grey pink': '#c3909b', 'soft purple': '#a66fb5', 'blood': '#770001', 'brown red': '#922b05', 'medium grey': '#7d7f7c', 'berry': '#990f4b', 'poo': '#8f7303', 'purpley pink': '#c83cb9', 'light salmon': '#fea993', 'snot': '#acbb0d', 'easter purple': '#c071fe', 'light yellow green': '#ccfd7f', 'dark navy blue': '#00022e', 'drab': '#828344', 'light rose': '#ffc5cb', 'rouge': '#ab1239', 'purplish red': '#b0054b', 'slime green': '#99cc04', 'baby poop': '#937c00', 'irish green': '#019529', 'pink/purple': '#ef1de7', 'dark navy': '#000435', 'greeny blue': '#42b395', 'light plum': '#9d5783', 'pinkish grey': '#c8aca9', 'dirty orange': '#c87606', 'rust red': '#aa2704', 'pale lilac': '#e4cbff', 'orangey red': '#fa4224', 'primary blue': '#0804f9', 'kermit green': '#5cb200', 'brownish purple': '#76424e', 'murky green': '#6c7a0e', 'wheat': '#fbdd7e', 'very dark purple': '#2a0134', 'bottle green': '#044a05', 'watermelon': '#fd4659', 'deep sky blue': '#0d75f8', 'fire engine red': '#fe0002', 'yellow ochre': '#cb9d06', 'pumpkin orange': '#fb7d07', 'pale olive': '#b9cc81', 'light lilac': '#edc8ff', 'lightish green': '#61e160', 'carolina blue': '#8ab8fe', 'mulberry': '#920a4e', 'shocking pink': '#fe02a2', 'auburn': '#9a3001', 'bright lime green': '#65fe08', 'celadon': '#befdb7', 'pinkish brown': '#b17261', 'poo brown': '#885f01', 'bright sky blue': '#02ccfe', 'celery': '#c1fd95', 'dirt brown': '#836539', 'strawberry': '#fb2943', 'dark lime': '#84b701', 'copper': '#b66325', 'medium brown': '#7f5112', 'muted green': '#5fa052', "robin's egg": '#6dedfd', 'bright aqua': '#0bf9ea', 'bright lavender': '#c760ff', 'ivory': '#ffffcb', 'very light purple': '#f6cefc', 'light navy': '#155084', 'pink red': '#f5054f', 'olive brown': '#645403', 'poop brown': '#7a5901', 'mustard green': '#a8b504', 'ocean green': '#3d9973', 'very dark blue': '#000133', 'dusty green': '#76a973', 'light navy blue': '#2e5a88', 'minty green': '#0bf77d', 'adobe': '#bd6c48', 'barney': '#ac1db8', 'jade green': '#2baf6a', 'bright light blue': '#26f7fd', 'light lime': '#aefd6c', 'dark khaki': '#9b8f55', 'orange yellow': '#ffad01', 'ocre': '#c69c04', 'maize': '#f4d054', 'faded pink': '#de9dac', 'british racing green': '#05480d', 'sandstone': '#c9ae74', 'mud brown': '#60460f', 'light sea green': '#98f6b0', 'robin egg blue': '#8af1fe', 'aqua marine': '#2ee8bb', 'dark sea green': '#11875d', 'soft pink': '#fdb0c0', 'orangey brown': '#b16002', 'cherry red': '#f7022a', 'burnt yellow': '#d5ab09', 'brownish grey': '#86775f', 'camel': '#c69f59', 'purplish grey': '#7a687f', 'marine': '#042e60', 'greyish pink': '#c88d94', 'pale turquoise': '#a5fbd5', 'pastel yellow': '#fffe71', 'bluey purple': '#6241c7', 'canary yellow': '#fffe40', 'faded red': '#d3494e', 'sepia': '#985e2b', 'coffee': '#a6814c', 'bright magenta': '#ff08e8', 'mocha': '#9d7651', 'ecru': '#feffca', 'purpleish': '#98568d', 'cranberry': '#9e003a', 'darkish green': '#287c37', 'brown orange': '#b96902', 'dusky rose': '#ba6873', 'melon': '#ff7855', 'sickly green': '#94b21c', 'silver': '#c5c9c7', 'purply blue': '#661aee', 'purpleish blue': '#6140ef', 'hospital green': '#9be5aa', 'shit brown': '#7b5804', 'mid blue': '#276ab3', 'amber': '#feb308', 'easter green': '#8cfd7e', 'soft blue': '#6488ea', 'cerulean blue': '#056eee', 'golden brown': '#b27a01', 'bright turquoise': '#0ffef9', 'red pink': '#fa2a55', 'red purple': '#820747', 'greyish brown': '#7a6a4f', 'vermillion': '#f4320c', 'russet': '#a13905', 'steel grey': '#6f828a', 'lighter purple': '#a55af4', 'bright violet': '#ad0afd', 'prussian blue': '#004577', 'slate green': '#658d6d', 'dirty pink': '#ca7b80', 'dark blue green': '#005249', 'pine': '#2b5d34', 'yellowy green': '#bff128', 'dark gold': '#b59410', 'bluish': '#2976bb', 'darkish blue': '#014182', 'dull red': '#bb3f3f', 'pinky red': '#fc2647', 'bronze': '#a87900', 'pale teal': '#82cbb2', 'military green': '#667c3e', 'barbie pink': '#fe46a5', 'bubblegum pink': '#fe83cc', 'pea soup green': '#94a617', 'dark mustard': '#a88905', 'shit': '#7f5f00', 'medium purple': '#9e43a2', 'very dark green': '#062e03', 'dirt': '#8a6e45', 'dusky pink': '#cc7a8b', 'red violet': '#9e0168', 'lemon yellow': '#fdff38', 'pistachio': '#c0fa8b', 'dull yellow': '#eedc5b', 'dark lime green': '#7ebd01', 'denim blue': '#3b5b92', 'teal blue': '#01889f', 'lightish blue': '#3d7afd', 'purpley blue': '#5f34e7', 'light indigo': '#6d5acf', 'swamp green': '#748500', 'brown green': '#706c11', 'dark maroon': '#3c0008', 'hot purple': '#cb00f5', 'dark forest green': '#002d04', 'faded blue': '#658cbb', 'drab green': '#749551', 'light lime green': '#b9ff66', 'snot green': '#9dc100', 'yellowish': '#faee66', 'light blue green': '#7efbb3', 'bordeaux': '#7b002c', 'light mauve': '#c292a1', 'ocean': '#017b92', 'marigold': '#fcc006', 'muddy green': '#657432', 'dull orange': '#d8863b', 'steel': '#738595', 'electric purple': '#aa23ff', 'fluorescent green': '#08ff08', 'yellowish brown': '#9b7a01', 'blush': '#f29e8e', 'soft green': '#6fc276', 'bright orange': '#ff5b00', 'lemon': '#fdff52', 'purple grey': '#866f85', 'acid green': '#8ffe09', 'pale lavender': '#eecffe', 'violet blue': '#510ac9', 'light forest green': '#4f9153', 'burnt red': '#9f2305', 'khaki green': '#728639', 'cerise': '#de0c62', 'faded purple': '#916e99', 'apricot': '#ffb16d', 'dark olive green': '#3c4d03', 'grey brown': '#7f7053', 'green grey': '#77926f', 'true blue': '#010fcc', 'pale violet': '#ceaefa', 'periwinkle blue': '#8f99fb', 'light sky blue': '#c6fcff', 'blurple': '#5539cc', 'green brown': '#544e03', 'bluegreen': '#017a79', 'bright teal': '#01f9c6', 'brownish yellow': '#c9b003', 'pea soup': '#929901', 'forest': '#0b5509', 'barney purple': '#a00498', 'ultramarine': '#2000b1', 'purplish': '#94568c', 'puke yellow': '#c2be0e', 'bluish grey': '#748b97', 'dark periwinkle': '#665fd1', 'dark lilac': '#9c6da5', 'reddish': '#c44240', 'light maroon': '#a24857', 'dusty purple': '#825f87', 'terra cotta': '#c9643b', 'avocado': '#90b134', 'marine blue': '#01386a', 'teal green': '#25a36f', 'slate grey': '#59656d', 'lighter green': '#75fd63', 'electric green': '#21fc0d', 'dusty blue': '#5a86ad', 'golden yellow': '#fec615', 'bright yellow': '#fffd01', 'light lavender': '#dfc5fe', 'umber': '#b26400', 'poop': '#7f5e00', 'dark peach': '#de7e5d', 'jungle green': '#048243', 'eggshell': '#ffffd4', 'denim': '#3b638c', 'yellow brown': '#b79400', 'dull purple': '#84597e', 'chocolate brown': '#411900', 'wine red': '#7b0323', 'neon blue': '#04d9ff', 'dirty green': '#667e2c', 'light tan': '#fbeeac', 'ice blue': '#d7fffe', 'cadet blue': '#4e7496', 'dark mauve': '#874c62', 'very light blue': '#d5ffff', 'grey purple': '#826d8c', 'pastel pink': '#ffbacd', 'very light green': '#d1ffbd', 'dark sky blue': '#448ee4', 'evergreen': '#05472a', 'dull pink': '#d5869d', 'aubergine': '#3d0734', 'mahogany': '#4a0100', 'reddish orange': '#f8481c', 'deep green': '#02590f', 'vomit green': '#89a203', 'purple pink': '#e03fd8', 'dusty pink': '#d58a94', 'faded green': '#7bb274', 'camo green': '#526525', 'pinky purple': '#c94cbe', 'pink purple': '#db4bda', 'brownish red': '#9e3623', 'dark rose': '#b5485d', 'mud': '#735c12', 'brownish': '#9c6d57', 'emerald green': '#028f1e', 'pale brown': '#b1916e', 'dull blue': '#49759c', 'burnt umber': '#a0450e', 'medium green': '#39ad48', 'clay': '#b66a50', 'light aqua': '#8cffdb', 'light olive green': '#a4be5c', 'brownish orange': '#cb7723', 'dark aqua': '#05696b', 'purplish pink': '#ce5dae', 'dark salmon': '#c85a53', 'greenish grey': '#96ae8d', 'jade': '#1fa774', 'ugly green': '#7a9703', 'dark beige': '#ac9362', 'emerald': '#01a049', 'pale red': '#d9544d', 'light magenta': '#fa5ff7', 'sky': '#82cafc', 'light cyan': '#acfffc', 'yellow orange': '#fcb001', 'reddish purple': '#910951', 'reddish pink': '#fe2c54', 'orchid': '#c875c4', 'dirty yellow': '#cdc50a', 'orange red': '#fd411e', 'deep red': '#9a0200', 'orange brown': '#be6400', 'cobalt blue': '#030aa7', 'neon pink': '#fe019a', 'rose pink': '#f7879a', 'greyish purple': '#887191', 'raspberry': '#b00149', 'aqua green': '#12e193', 'salmon pink': '#fe7b7c', 'tangerine': '#ff9408', 'brownish green': '#6a6e09', 'red brown': '#8b2e16', 'greenish brown': '#696112', 'pumpkin': '#e17701', 'pine green': '#0a481e', 'charcoal': '#343837', 'baby pink': '#ffb7ce', 'cornflower': '#6a79f7', 'blue violet': '#5d06e9', 'chocolate': '#3d1c02', 'greyish green': '#82a67d', 'scarlet': '#be0119', 'green yellow': '#c9ff27', 'dark olive': '#373e02', 'sienna': '#a9561e', 'pastel purple': '#caa0ff', 'terracotta': '#ca6641', 'aqua blue': '#02d8e9', 'sage green': '#88b378', 'blood red': '#980002', 'deep pink': '#cb0162', 'grass': '#5cac2d', 'moss': '#769958', 'pastel blue': '#a2bffe', 'bluish green': '#10a674', 'green blue': '#06b48b', 'dark tan': '#af884a', 'greenish blue': '#0b8b87', 'pale orange': '#ffa756', 'vomit': '#a2a415', 'forrest green': '#154406', 'dark lavender': '#856798', 'dark violet': '#34013f', 'purple blue': '#632de9', 'dark cyan': '#0a888a', 'olive drab': '#6f7632', 'pinkish': '#d46a7e', 'cobalt': '#1e488f', 'neon purple': '#bc13fe', 'light turquoise': '#7ef4cc', 'apple green': '#76cd26', 'dull green': '#74a662', 'wine': '#80013f', 'powder blue': '#b1d1fc', 'off white': '#ffffe4', 'electric blue': '#0652ff', 'dark turquoise': '#045c5a', 'blue purple': '#5729ce', 'azure': '#069af3', 'bright red': '#ff000d', 'pinkish red': '#f10c45', 'cornflower blue': '#5170d7', 'light olive': '#acbf69', 'grape': '#6c3461', 'greyish blue': '#5e819d', 'purplish blue': '#601ef9', 'yellowish green': '#b0dd16', 'greenish yellow': '#cdfd02', 'medium blue': '#2c6fbb', 'dusty rose': '#c0737a', 'light violet': '#d6b4fc', 'midnight blue': '#020035', 'bluish purple': '#703be7', 'red orange': '#fd3c06', 'dark magenta': '#960056', 'greenish': '#40a368', 'ocean blue': '#03719c', 'coral': '#fc5a50', 'cream': '#ffffc2', 'reddish brown': '#7f2b0a', 'burnt sienna': '#b04e0f', 'brick': '#a03623', 'sage': '#87ae73', 'grey green': '#789b73', 'white': '#ffffff', "robin's egg blue": '#98eff9', 'moss green': '#658b38', 'steel blue': '#5a7d9a', 'eggplant': '#380835', 'light yellow': '#fffe7a', 'leaf green': '#5ca904', 'light grey': '#d8dcd6', 'puke': '#a5a502', 'pinkish purple': '#d648d7', 'sea blue': '#047495', 'pale purple': '#b790d4', 'slate blue': '#5b7c99', 'blue grey': '#607c8e', 'hunter green': '#0b4008', 'fuchsia': '#ed0dd9', 'crimson': '#8c000f', 'pale yellow': '#ffff84', 'ochre': '#bf9005', 'mustard yellow': '#d2bd0a', 'light red': '#ff474c', 'cerulean': '#0485d1', 'pale pink': '#ffcfdc', 'deep blue': '#040273', 'rust': '#a83c09', 'light teal': '#90e4c1', 'slate': '#516572', 'goldenrod': '#fac205', 'dark yellow': '#d5b60a', 'dark grey': '#363737', 'army green': '#4b5d16', 'grey blue': '#6b8ba4', 'seafoam': '#80f9ad', 'puce': '#a57e52', 'spring green': '#a9f971', 'dark orange': '#c65102', 'sand': '#e2ca76', 'pastel green': '#b0ff9d', 'mint': '#9ffeb0', 'light orange': '#fdaa48', 'bright pink': '#fe01b1', 'chartreuse': '#c1f80a', 'deep purple': '#36013f', 'dark brown': '#341c02', 'taupe': '#b9a281', 'pea green': '#8eab12', 'puke green': '#9aae07', 'kelly green': '#02ab2e', 'seafoam green': '#7af9ab', 'blue green': '#137e6d', 'khaki': '#aaa662', 'burgundy': '#610023', 'dark teal': '#014d4e', 'brick red': '#8f1402', 'royal purple': '#4b006e', 'plum': '#580f41', 'mint green': '#8fff9f', 'gold': '#dbb40c', 'baby blue': '#a2cffe', 'yellow green': '#c0fb2d', 'bright purple': '#be03fd', 'dark red': '#840000', 'pale blue': '#d0fefe', 'grass green': '#3f9b0b', 'navy': '#01153e', 'aquamarine': '#04d8b2', 'burnt orange': '#c04e01', 'neon green': '#0cff0c', 'bright blue': '#0165fc', 'rose': '#cf6275', 'light pink': '#ffd1df', 'mustard': '#ceb301', 'indigo': '#380282', 'lime': '#aaff32', 'sea green': '#53fca1', 'periwinkle': '#8e82fe', 'dark pink': '#cb416b', 'olive green': '#677a04', 'peach': '#ffb07c', 'pale green': '#c7fdb5', 'light brown': '#ad8150', 'hot pink': '#ff028d', 'black': '#000000', 'lilac': '#cea2fd', 'navy blue': '#001146', 'royal blue': '#0504aa', 'beige': '#e6daa6', 'salmon': '#ff796c', 'olive': '#6e750e', 'maroon': '#650021', 'bright green': '#01ff07', 'dark purple': '#35063e', 'mauve': '#ae7181', 'forest green': '#06470c', 'aqua': '#13eac9', 'cyan': '#00ffff', 'tan': '#d1b26f', 'dark blue': '#00035b', 'lavender': '#c79fef', 'turquoise': '#06c2ac', 'dark green': '#033500', 'violet': '#9a0eea', 'light purple': '#bf77f6', 'lime green': '#89fe05', 'grey': '#929591', 'sky blue': '#75bbfd', 'yellow': '#ffff14', 'magenta': '#c20078', 'light green': '#96f97b', 'orange': '#f97306', 'teal': '#029386', 'light blue': '#95d0fc', 'red': '#e50000', 'brown': '#653700', 'pink': '#ff81c0', 'blue': '#0343df', 'green': '#15b01a', 'purple': '#7e1e9c'} # Normalize name to "xkcd:" to avoid name collisions. XKCD_COLORS = {'xkcd:' + name: value for name, value in XKCD_COLORS.items()} # https://drafts.csswg.org/css-color-4/#named-colors CSS4_COLORS = { 'aliceblue': '#F0F8FF', 'antiquewhite': '#FAEBD7', 'aqua': '#00FFFF', 'aquamarine': '#7FFFD4', 'azure': '#F0FFFF', 'beige': '#F5F5DC', 'bisque': '#FFE4C4', 'black': '#000000', 'blanchedalmond': '#FFEBCD', 'blue': '#0000FF', 'blueviolet': '#8A2BE2', 'brown': '#A52A2A', 'burlywood': '#DEB887', 'cadetblue': '#5F9EA0', 'chartreuse': '#7FFF00', 'chocolate': '#D2691E', 'coral': '#FF7F50', 'cornflowerblue': '#6495ED', 'cornsilk': '#FFF8DC', 'crimson': '#DC143C', 'cyan': '#00FFFF', 'darkblue': '#00008B', 'darkcyan': '#008B8B', 'darkgoldenrod': '#B8860B', 'darkgray': '#A9A9A9', 'darkgreen': '#006400', 'darkgrey': '#A9A9A9', 'darkkhaki': '#BDB76B', 'darkmagenta': '#8B008B', 'darkolivegreen': '#556B2F', 'darkorange': '#FF8C00', 'darkorchid': '#9932CC', 'darkred': '#8B0000', 'darksalmon': '#E9967A', 'darkseagreen': '#8FBC8F', 'darkslateblue': '#483D8B', 'darkslategray': '#2F4F4F', 'darkslategrey': '#2F4F4F', 'darkturquoise': '#00CED1', 'darkviolet': '#9400D3', 'deeppink': '#FF1493', 'deepskyblue': '#00BFFF', 'dimgray': '#696969', 'dimgrey': '#696969', 'dodgerblue': '#1E90FF', 'firebrick': '#B22222', 'floralwhite': '#FFFAF0', 'forestgreen': '#228B22', 'fuchsia': '#FF00FF', 'gainsboro': '#DCDCDC', 'ghostwhite': '#F8F8FF', 'gold': '#FFD700', 'goldenrod': '#DAA520', 'gray': '#808080', 'green': '#008000', 'greenyellow': '#ADFF2F', 'grey': '#808080', 'honeydew': '#F0FFF0', 'hotpink': '#FF69B4', 'indianred': '#CD5C5C', 'indigo': '#4B0082', 'ivory': '#FFFFF0', 'khaki': '#F0E68C', 'lavender': '#E6E6FA', 'lavenderblush': '#FFF0F5', 'lawngreen': '#7CFC00', 'lemonchiffon': '#FFFACD', 'lightblue': '#ADD8E6', 'lightcoral': '#F08080', 'lightcyan': '#E0FFFF', 'lightgoldenrodyellow': '#FAFAD2', 'lightgray': '#D3D3D3', 'lightgreen': '#90EE90', 'lightgrey': '#D3D3D3', 'lightpink': '#FFB6C1', 'lightsalmon': '#FFA07A', 'lightseagreen': '#20B2AA', 'lightskyblue': '#87CEFA', 'lightslategray': '#778899', 'lightslategrey': '#778899', 'lightsteelblue': '#B0C4DE', 'lightyellow': '#FFFFE0', 'lime': '#00FF00', 'limegreen': '#32CD32', 'linen': '#FAF0E6', 'magenta': '#FF00FF', 'maroon': '#800000', 'mediumaquamarine': '#66CDAA', 'mediumblue': '#0000CD', 'mediumorchid': '#BA55D3', 'mediumpurple': '#9370DB', 'mediumseagreen': '#3CB371', 'mediumslateblue': '#7B68EE', 'mediumspringgreen': '#00FA9A', 'mediumturquoise': '#48D1CC', 'mediumvioletred': '#C71585', 'midnightblue': '#191970', 'mintcream': '#F5FFFA', 'mistyrose': '#FFE4E1', 'moccasin': '#FFE4B5', 'navajowhite': '#FFDEAD', 'navy': '#000080', 'oldlace': '#FDF5E6', 'olive': '#808000', 'olivedrab': '#6B8E23', 'orange': '#FFA500', 'orangered': '#FF4500', 'orchid': '#DA70D6', 'palegoldenrod': '#EEE8AA', 'palegreen': '#98FB98', 'paleturquoise': '#AFEEEE', 'palevioletred': '#DB7093', 'papayawhip': '#FFEFD5', 'peachpuff': '#FFDAB9', 'peru': '#CD853F', 'pink': '#FFC0CB', 'plum': '#DDA0DD', 'powderblue': '#B0E0E6', 'purple': '#800080', 'rebeccapurple': '#663399', 'red': '#FF0000', 'rosybrown': '#BC8F8F', 'royalblue': '#4169E1', 'saddlebrown': '#8B4513', 'salmon': '#FA8072', 'sandybrown': '#F4A460', 'seagreen': '#2E8B57', 'seashell': '#FFF5EE', 'sienna': '#A0522D', 'silver': '#C0C0C0', 'skyblue': '#87CEEB', 'slateblue': '#6A5ACD', 'slategray': '#708090', 'slategrey': '#708090', 'snow': '#FFFAFA', 'springgreen': '#00FF7F', 'steelblue': '#4682B4', 'tan': '#D2B48C', 'teal': '#008080', 'thistle': '#D8BFD8', 'tomato': '#FF6347', 'turquoise': '#40E0D0', 'violet': '#EE82EE', 'wheat': '#F5DEB3', 'white': '#FFFFFF', 'whitesmoke': '#F5F5F5', 'yellow': '#FFFF00', 'yellowgreen': '#9ACD32'} napari-0.5.0a1/napari/utils/colormaps/vendored/cm.py000066400000000000000000000303641437041365600224170ustar00rootroot00000000000000""" Builtin colormaps, colormap handling utilities, and the `ScalarMappable` mixin. .. seealso:: :doc:`/gallery/color/colormap_reference` for a list of builtin colormaps. :doc:`/tutorials/colors/colormap-manipulation` for examples of how to make colormaps and :doc:`/tutorials/colors/colormaps` an in-depth discussion of choosing colormaps. :doc:`/tutorials/colors/colormapnorms` for more details about data normalization """ import functools import numpy as np from numpy import ma from napari.utils.colormaps.vendored import colors from napari.utils.colormaps.vendored._cm import datad from napari.utils.colormaps.vendored._cm_listed import cmaps as cmaps_listed cmap_d = {} # reverse all the colormaps. # reversed colormaps have '_r' appended to the name. def _reverser(f, x=None): """Helper such that ``_reverser(f)(x) == f(1 - x)``.""" if x is None: # Returning a partial object keeps it picklable. return functools.partial(_reverser, f) return f(1 - x) def revcmap(data): """Can only handle specification *data* in dictionary format.""" data_r = {} for key, val in data.items(): if callable(val): valnew = _reverser(val) # This doesn't work: lambda x: val(1-x) # The same "val" (the first one) is used # each time, so the colors are identical # and the result is shades of gray. else: # Flip x and exchange the y values facing x = 0 and x = 1. valnew = [(1.0 - x, y1, y0) for x, y0, y1 in reversed(val)] data_r[key] = valnew return data_r def _reverse_cmap_spec(spec): """Reverses cmap specification *spec*, can handle both dict and tuple type specs.""" if 'listed' in spec: return {'listed': spec['listed'][::-1]} if 'red' in spec: return revcmap(spec) else: revspec = list(reversed(spec)) if len(revspec[0]) == 2: # e.g., (1, (1.0, 0.0, 1.0)) revspec = [(1.0 - a, b) for a, b in revspec] return revspec def _generate_cmap(name, lutsize): """Generates the requested cmap from its *name*. The lut size is *lutsize*.""" spec = datad[name] # Generate the colormap object. if 'red' in spec: return colors.LinearSegmentedColormap(name, spec, lutsize) elif 'listed' in spec: return colors.ListedColormap(spec['listed'], name) else: return colors.LinearSegmentedColormap.from_list(name, spec, lutsize) LUTSIZE = 256 # Generate the reversed specifications (all at once, to avoid # modify-when-iterating). datad.update({cmapname + '_r': _reverse_cmap_spec(spec) for cmapname, spec in datad.items()}) # Precache the cmaps with ``lutsize = LUTSIZE``. # Also add the reversed ones added in the section above: for cmapname in datad: cmap_d[cmapname] = _generate_cmap(cmapname, LUTSIZE) cmap_d.update(cmaps_listed) locals().update(cmap_d) # Continue with definitions ... def register_cmap(name=None, cmap=None, data=None, lut=None): """ Add a colormap to the set recognized by :func:`get_cmap`. It can be used in two ways:: register_cmap(name='swirly', cmap=swirly_cmap) register_cmap(name='choppy', data=choppydata, lut=128) In the first case, *cmap* must be a :class:`matplotlib.colors.Colormap` instance. The *name* is optional; if absent, the name will be the :attr:`~matplotlib.colors.Colormap.name` attribute of the *cmap*. In the second case, the three arguments are passed to the :class:`~matplotlib.colors.LinearSegmentedColormap` initializer, and the resulting colormap is registered. """ if name is None: try: name = cmap.name except AttributeError: raise ValueError("Arguments must include a name or a Colormap") if not isinstance(name, str): raise ValueError("Colormap name must be a string") if isinstance(cmap, colors.Colormap): cmap_d[name] = cmap return # For the remainder, let exceptions propagate. if lut is None: lut = LUTSIZE cmap = colors.LinearSegmentedColormap(name, data, lut) cmap_d[name] = cmap def get_cmap(name=None, lut=None): """ Get a colormap instance, defaulting to rc values if *name* is None. Colormaps added with :func:`register_cmap` take precedence over built-in colormaps. If *name* is a :class:`matplotlib.colors.Colormap` instance, it will be returned. If *lut* is not None it must be an integer giving the number of entries desired in the lookup table, and *name* must be a standard mpl colormap name. """ if name is None: name = 'magma' if isinstance(name, colors.Colormap): return name if name in cmap_d: if lut is None: return cmap_d[name] else: return cmap_d[name]._resample(lut) else: raise ValueError( "Colormap %s is not recognized. Possible values are: %s" % (name, ', '.join(sorted(cmap_d)))) class ScalarMappable(object): """ This is a mixin class to support scalar data to RGBA mapping. The ScalarMappable makes use of data normalization before returning RGBA colors from the given colormap. """ def __init__(self, norm=None, cmap=None): r""" Parameters ---------- norm : :class:`matplotlib.colors.Normalize` instance The normalizing object which scales data, typically into the interval ``[0, 1]``. If *None*, *norm* defaults to a *colors.Normalize* object which initializes its scaling based on the first data processed. cmap : str or :class:`~matplotlib.colors.Colormap` instance The colormap used to map normalized data values to RGBA colors. """ if cmap is None: cmap = get_cmap() if norm is None: norm = colors.Normalize() self._A = None #: The Normalization instance of this ScalarMappable. self.norm = norm #: The Colormap instance of this ScalarMappable. self.cmap = get_cmap(cmap) #: The last colorbar associated with this ScalarMappable. May be None. self.colorbar = None self.update_dict = {'array': False} def to_rgba(self, x, alpha=None, bytes=False, norm=True): """ Return a normalized rgba array corresponding to *x*. In the normal case, *x* is a 1-D or 2-D sequence of scalars, and the corresponding ndarray of rgba values will be returned, based on the norm and colormap set for this ScalarMappable. There is one special case, for handling images that are already rgb or rgba, such as might have been read from an image file. If *x* is an ndarray with 3 dimensions, and the last dimension is either 3 or 4, then it will be treated as an rgb or rgba array, and no mapping will be done. The array can be uint8, or it can be floating point with values in the 0-1 range; otherwise a ValueError will be raised. If it is a masked array, the mask will be ignored. If the last dimension is 3, the *alpha* kwarg (defaulting to 1) will be used to fill in the transparency. If the last dimension is 4, the *alpha* kwarg is ignored; it does not replace the pre-existing alpha. A ValueError will be raised if the third dimension is other than 3 or 4. In either case, if *bytes* is *False* (default), the rgba array will be floats in the 0-1 range; if it is *True*, the returned rgba array will be uint8 in the 0 to 255 range. If norm is False, no normalization of the input data is performed, and it is assumed to be in the range (0-1). """ # First check for special case, image input: try: if x.ndim == 3: if x.shape[2] == 3: if alpha is None: alpha = 1 if x.dtype == np.uint8: alpha = np.uint8(alpha * 255) m, n = x.shape[:2] xx = np.empty(shape=(m, n, 4), dtype=x.dtype) xx[:, :, :3] = x xx[:, :, 3] = alpha elif x.shape[2] == 4: xx = x else: raise ValueError("third dimension must be 3 or 4") if xx.dtype.kind == 'f': if norm and (xx.max() > 1 or xx.min() < 0): raise ValueError("Floating point image RGB values " "must be in the 0..1 range.") if bytes: xx = (xx * 255).astype(np.uint8) elif xx.dtype == np.uint8: if not bytes: xx = xx.astype(np.float32) / 255 else: raise ValueError("Image RGB array must be uint8 or " "floating point; found %s" % xx.dtype) return xx except AttributeError: # e.g., x is not an ndarray; so try mapping it pass # This is the normal case, mapping a scalar array: x = ma.asarray(x) if norm: x = self.norm(x) rgba = self.cmap(x, alpha=alpha, bytes=bytes) return rgba def set_array(self, A): """Set the image array from numpy array *A*. Parameters ---------- A : ndarray """ self._A = A self.update_dict['array'] = True def get_array(self): 'Return the array' return self._A def get_cmap(self): 'return the colormap' return self.cmap def get_clim(self): 'return the min, max of the color limits for image scaling' return self.norm.vmin, self.norm.vmax def set_clim(self, vmin=None, vmax=None): """ set the norm limits for image scaling; if *vmin* is a length2 sequence, interpret it as ``(vmin, vmax)`` which is used to support setp ACCEPTS: a length 2 sequence of floats; may be overridden in methods that have ``vmin`` and ``vmax`` kwargs. """ if vmax is None: try: vmin, vmax = vmin except (TypeError, ValueError): pass if vmin is not None: self.norm.vmin = colors._sanitize_extrema(vmin) if vmax is not None: self.norm.vmax = colors._sanitize_extrema(vmax) self.changed() def set_cmap(self, cmap): """ set the colormap for luminance data Parameters ---------- cmap : colormap or registered colormap name """ cmap = get_cmap(cmap) self.cmap = cmap self.changed() def set_norm(self, norm): """Set the normalization instance. Parameters ---------- norm : `.Normalize` """ if norm is None: norm = colors.Normalize() self.norm = norm self.changed() def autoscale(self): """ Autoscale the scalar limits on the norm instance using the current array """ if self._A is None: raise TypeError('You must first set_array for mappable') self.norm.autoscale(self._A) self.changed() def autoscale_None(self): """ Autoscale the scalar limits on the norm instance using the current array, changing only limits that are None """ if self._A is None: raise TypeError('You must first set_array for mappable') self.norm.autoscale_None(self._A) self.changed() def add_checker(self, checker): """ Add an entry to a dictionary of boolean flags that are set to True when the mappable is changed. """ self.update_dict[checker] = False def check_update(self, checker): """ If mappable has changed since the last check, return True; else return False """ if self.update_dict[checker]: self.update_dict[checker] = False return True return False def changed(self): for key in self.update_dict: self.update_dict[key] = True self.stale = True napari-0.5.0a1/napari/utils/colormaps/vendored/colors.py000066400000000000000000002036551437041365600233260ustar00rootroot00000000000000""" A module for converting numbers or color arguments to *RGB* or *RGBA* *RGB* and *RGBA* are sequences of, respectively, 3 or 4 floats in the range 0-1. This module includes functions and classes for color specification conversions, and for mapping numbers to colors in a 1-D array of colors called a colormap. Mapping data onto colors using a colormap typically involves two steps: a data array is first mapped onto the range 0-1 using a subclass of :class:`Normalize`, then this number is mapped to a color using a subclass of :class:`Colormap`. Two are provided here: :class:`LinearSegmentedColormap`, which uses piecewise-linear interpolation to define colormaps, and :class:`ListedColormap`, which makes a colormap from a list of colors. .. seealso:: :doc:`/tutorials/colors/colormap-manipulation` for examples of how to make colormaps and :doc:`/tutorials/colors/colormaps` for a list of built-in colormaps. :doc:`/tutorials/colors/colormapnorms` for more details about data normalization More colormaps are available at palettable_ The module also provides functions for checking whether an object can be interpreted as a color (:func:`is_color_like`), for converting such an object to an RGBA tuple (:func:`to_rgba`) or to an HTML-like hex string in the `#rrggbb` format (:func:`to_hex`), and a sequence of colors to an `(n, 4)` RGBA array (:func:`to_rgba_array`). Caching is used for efficiency. Matplotlib recognizes the following formats to specify a color: * an RGB or RGBA tuple of float values in ``[0, 1]`` (e.g., ``(0.1, 0.2, 0.5)`` or ``(0.1, 0.2, 0.5, 0.3)``); * a hex RGB or RGBA string (e.g., ``'#0F0F0F'`` or ``'#0F0F0F0F'``); * a string representation of a float value in ``[0, 1]`` inclusive for gray level (e.g., ``'0.5'``); * one of ``{'b', 'g', 'r', 'c', 'm', 'y', 'k', 'w'}``; * a X11/CSS4 color name; * a name from the `xkcd color survey `__; prefixed with ``'xkcd:'`` (e.g., ``'xkcd:sky blue'``); * one of ``{'tab:blue', 'tab:orange', 'tab:green', 'tab:red', 'tab:purple', 'tab:brown', 'tab:pink', 'tab:gray', 'tab:olive', 'tab:cyan'}`` which are the Tableau Colors from the 'T10' categorical palette (which is the default color cycle); * a "CN" color spec, i.e. `'C'` followed by a number, which is an index into the default property cycle (``matplotlib.rcParams['axes.prop_cycle']``); the indexing is intended to occur at rendering time, and defaults to black if the cycle does not include color. All string specifications of color, other than "CN", are case-insensitive. .. _palettable: https://jiffyclub.github.io/palettable/ """ from collections.abc import Sized import itertools import re import numpy as np from napari.utils.colormaps.vendored._color_data import (BASE_COLORS, TABLEAU_COLORS, CSS4_COLORS, XKCD_COLORS, NTH_COLORS) class _ColorMapping(dict): def __init__(self, mapping): super().__init__(mapping) self.cache = {} def __setitem__(self, key, value): super().__setitem__(key, value) self.cache.clear() def __delitem__(self, key): super().__delitem__(key) self.cache.clear() _colors_full_map = {} # Set by reverse priority order. _colors_full_map.update(XKCD_COLORS) _colors_full_map.update({k.replace('grey', 'gray'): v for k, v in XKCD_COLORS.items() if 'grey' in k}) _colors_full_map.update(CSS4_COLORS) _colors_full_map.update(TABLEAU_COLORS) _colors_full_map.update({k.replace('gray', 'grey'): v for k, v in TABLEAU_COLORS.items() if 'gray' in k}) _colors_full_map.update(BASE_COLORS) _colors_full_map.update(NTH_COLORS) _colors_full_map = _ColorMapping(_colors_full_map) def get_named_colors_mapping(): """Return the global mapping of names to named colors.""" return _colors_full_map def _sanitize_extrema(ex): if ex is None: return ex try: ret = ex.item() except AttributeError: ret = float(ex) return ret def is_color_like(c): """Return whether *c* can be interpreted as an RGB(A) color.""" try: to_rgba(c) except ValueError: return False else: return True def same_color(c1, c2): """ Compare two colors to see if they are the same. Parameters ---------- c1, c2 : Matplotlib colors Returns ------- bool ``True`` if *c1* and *c2* are the same color, otherwise ``False``. """ return (to_rgba_array(c1) == to_rgba_array(c2)).all() def to_rgba(c, alpha=None): """ Convert *c* to an RGBA color. Parameters ---------- c : Matplotlib color alpha : scalar, optional If *alpha* is not ``None``, it forces the alpha value, except if *c* is ``"none"`` (case-insensitive), which always maps to ``(0, 0, 0, 0)``. Returns ------- tuple Tuple of ``(r, g, b, a)`` scalars. """ try: rgba = _colors_full_map.cache[c, alpha] except (KeyError, TypeError): # Not in cache, or unhashable. rgba = _to_rgba_no_colorcycle(c, alpha) try: _colors_full_map.cache[c, alpha] = rgba except TypeError: pass return rgba def _to_rgba_no_colorcycle(c, alpha=None): """Convert *c* to an RGBA color, with no support for color-cycle syntax. If *alpha* is not ``None``, it forces the alpha value, except if *c* is ``"none"`` (case-insensitive), which always maps to ``(0, 0, 0, 0)``. """ orig_c = c if isinstance(c, str): if c.lower() == "none": return (0., 0., 0., 0.) # Named color. try: # This may turn c into a non-string, so we check again below. c = _colors_full_map[c.lower()] except KeyError: pass if isinstance(c, str): # hex color with no alpha. match = re.match(r"\A#[a-fA-F0-9]{6}\Z", c) if match: return (tuple(int(n, 16) / 255 for n in [c[1:3], c[3:5], c[5:7]]) + (alpha if alpha is not None else 1.,)) # hex color with alpha. match = re.match(r"\A#[a-fA-F0-9]{8}\Z", c) if match: color = [int(n, 16) / 255 for n in [c[1:3], c[3:5], c[5:7], c[7:9]]] if alpha is not None: color[-1] = alpha return tuple(color) # string gray. try: return (float(c),) * 3 + (alpha if alpha is not None else 1.,) except ValueError: pass raise ValueError("Invalid RGBA argument: {!r}".format(orig_c)) # tuple color. c = np.array(c) if not np.can_cast(c.dtype, float, "same_kind") or c.ndim != 1: # Test the dtype explicitly as `map(float, ...)`, `np.array(..., # float)` and `np.array(...).astype(float)` all convert "0.5" to 0.5. # Test dimensionality to reject single floats. raise ValueError("Invalid RGBA argument: {!r}".format(orig_c)) # Return a tuple to prevent the cached value from being modified. c = tuple(c.astype(float)) if len(c) not in [3, 4]: raise ValueError("RGBA sequence should have length 3 or 4") if len(c) == 3 and alpha is None: alpha = 1 if alpha is not None: c = c[:3] + (alpha,) if any(elem < 0 or elem > 1 for elem in c): raise ValueError("RGBA values should be within 0-1 range") return c def to_rgba_array(c, alpha=None): """Convert *c* to a (n, 4) array of RGBA colors. If *alpha* is not ``None``, it forces the alpha value. If *c* is ``"none"`` (case-insensitive) or an empty list, an empty array is returned. """ # Special-case inputs that are already arrays, for performance. (If the # array has the wrong kind or shape, raise the error during one-at-a-time # conversion.) if (isinstance(c, np.ndarray) and c.dtype.kind in "if" and c.ndim == 2 and c.shape[1] in [3, 4]): if c.shape[1] == 3: result = np.column_stack([c, np.zeros(len(c))]) result[:, -1] = alpha if alpha is not None else 1. elif c.shape[1] == 4: result = c.copy() if alpha is not None: result[:, -1] = alpha if np.any((result < 0) | (result > 1)): raise ValueError("RGBA values should be within 0-1 range") return result # Handle single values. # Note that this occurs *after* handling inputs that are already arrays, as # `to_rgba(c, alpha)` (below) is expensive for such inputs, due to the need # to format the array in the ValueError message(!). if isinstance(c, str) and c.lower() == "none": return np.zeros((0, 4), float) try: return np.array([to_rgba(c, alpha)], float) except (ValueError, TypeError): pass # Convert one at a time. result = np.empty((len(c), 4), float) for i, cc in enumerate(c): result[i] = to_rgba(cc, alpha) return result def to_rgb(c): """Convert *c* to an RGB color, silently dropping the alpha channel.""" return to_rgba(c)[:3] def to_hex(c, keep_alpha=False): """Convert *c* to a hex color. Uses the ``#rrggbb`` format if *keep_alpha* is False (the default), ``#rrggbbaa`` otherwise. """ c = to_rgba(c) if not keep_alpha: c = c[:3] return "#" + "".join(format(int(np.round(val * 255)), "02x") for val in c) def makeMappingArray(N, data, gamma=1.0): """Create an *N* -element 1-d lookup table *data* represented by a list of x,y0,y1 mapping correspondences. Each element in this list represents how a value between 0 and 1 (inclusive) represented by x is mapped to a corresponding value between 0 and 1 (inclusive). The two values of y are to allow for discontinuous mapping functions (say as might be found in a sawtooth) where y0 represents the value of y for values of x <= to that given, and y1 is the value to be used for x > than that given). The list must start with x=0, end with x=1, and all values of x must be in increasing order. Values between the given mapping points are determined by simple linear interpolation. Alternatively, data can be a function mapping values between 0 - 1 to 0 - 1. The function returns an array "result" where ``result[x*(N-1)]`` gives the closest value for values of x between 0 and 1. """ if callable(data): xind = np.linspace(0, 1, N) ** gamma lut = np.clip(np.array(data(xind), dtype=float), 0, 1) return lut try: adata = np.array(data) except Exception: raise TypeError("data must be convertible to an array") shape = adata.shape if len(shape) != 2 or shape[1] != 3: raise ValueError("data must be nx3 format") x = adata[:, 0] y0 = adata[:, 1] y1 = adata[:, 2] if x[0] != 0. or x[-1] != 1.0: raise ValueError( "data mapping points must start with x=0 and end with x=1") if (np.diff(x) < 0).any(): raise ValueError("data mapping points must have x in increasing order") # begin generation of lookup table x = x * (N - 1) xind = (N - 1) * np.linspace(0, 1, N) ** gamma ind = np.searchsorted(x, xind)[1:-1] distance = (xind[1:-1] - x[ind - 1]) / (x[ind] - x[ind - 1]) lut = np.concatenate([ [y1[0]], distance * (y0[ind] - y1[ind - 1]) + y1[ind - 1], [y0[-1]], ]) # ensure that the lut is confined to values between 0 and 1 by clipping it return np.clip(lut, 0.0, 1.0) class Colormap(object): """ Baseclass for all scalar to RGBA mappings. Typically Colormap instances are used to convert data values (floats) from the interval ``[0, 1]`` to the RGBA color that the respective Colormap represents. For scaling of data into the ``[0, 1]`` interval see :class:`matplotlib.colors.Normalize`. It is worth noting that :class:`matplotlib.cm.ScalarMappable` subclasses make heavy use of this ``data->normalize->map-to-color`` processing chain. """ def __init__(self, name, N=256): """ Parameters ---------- name : str The name of the colormap. N : int The number of rgb quantization levels. """ self.name = name self.N = int(N) # ensure that N is always int self._rgba_bad = (0.0, 0.0, 0.0, 0.0) # If bad, don't paint anything. self._rgba_under = None self._rgba_over = None self._i_under = self.N self._i_over = self.N + 1 self._i_bad = self.N + 2 self._isinit = False #: When this colormap exists on a scalar mappable and colorbar_extend #: is not False, colorbar creation will pick up ``colorbar_extend`` as #: the default value for the ``extend`` keyword in the #: :class:`matplotlib.colorbar.Colorbar` constructor. self.colorbar_extend = False def __call__(self, X, alpha=None, bytes=False): """ Parameters ---------- X : scalar, ndarray The data value(s) to convert to RGBA. For floats, X should be in the interval ``[0.0, 1.0]`` to return the RGBA values ``X*100`` percent along the Colormap line. For integers, X should be in the interval ``[0, Colormap.N)`` to return RGBA values *indexed* from the Colormap with index ``X``. alpha : float, None Alpha must be a scalar between 0 and 1, or None. bytes : bool If False (default), the returned RGBA values will be floats in the interval ``[0, 1]`` otherwise they will be uint8s in the interval ``[0, 255]``. Returns ------- Tuple of RGBA values if X is scalar, otherwise an array of RGBA values with a shape of ``X.shape + (4, )``. """ # See class docstring for arg/kwarg documentation. if not self._isinit: self._init() mask_bad = None if not np.iterable(X): vtype = 'scalar' xa = np.array([X]) else: vtype = 'array' xma = np.ma.array(X, copy=True) # Copy here to avoid side effects. mask_bad = xma.mask # Mask will be used below. xa = xma.filled() # Fill to avoid infs, etc. del xma # Calculations with native byteorder are faster, and avoid a # bug that otherwise can occur with putmask when the last # argument is a numpy scalar. if not xa.dtype.isnative: xa = xa.byteswap().newbyteorder() if xa.dtype.kind == "f": xa *= self.N # Negative values are out of range, but astype(int) would truncate # them towards zero. xa[xa < 0] = -1 # xa == 1 (== N after multiplication) is not out of range. xa[xa == self.N] = self.N - 1 # Avoid converting large positive values to negative integers. np.clip(xa, -1, self.N, out=xa) xa = xa.astype(int) # Set the over-range indices before the under-range; # otherwise the under-range values get converted to over-range. xa[xa > self.N - 1] = self._i_over xa[xa < 0] = self._i_under if mask_bad is not None: if mask_bad.shape == xa.shape: np.copyto(xa, self._i_bad, where=mask_bad) elif mask_bad: xa.fill(self._i_bad) if bytes: lut = (self._lut * 255).astype(np.uint8) else: lut = self._lut.copy() # Don't let alpha modify original _lut. if alpha is not None: alpha = np.clip(alpha, 0, 1) if bytes: alpha = int(alpha * 255) if (lut[-1] == 0).all(): lut[:-1, -1] = alpha # All zeros is taken as a flag for the default bad # color, which is no color--fully transparent. We # don't want to override this. else: lut[:, -1] = alpha # If the bad value is set to have a color, then we # override its alpha just as for any other value. rgba = lut.take(xa, axis=0, mode='clip') if vtype == 'scalar': rgba = tuple(rgba[0, :]) return rgba def __copy__(self): """Create new object with the same class, update attributes """ cls = self.__class__ cmapobject = cls.__new__(cls) cmapobject.__dict__.update(self.__dict__) if self._isinit: cmapobject._lut = np.copy(self._lut) return cmapobject def set_bad(self, color='k', alpha=None): """Set color to be used for masked values. """ self._rgba_bad = to_rgba(color, alpha) if self._isinit: self._set_extremes() def set_under(self, color='k', alpha=None): """Set color to be used for low out-of-range values. Requires norm.clip = False """ self._rgba_under = to_rgba(color, alpha) if self._isinit: self._set_extremes() def set_over(self, color='k', alpha=None): """Set color to be used for high out-of-range values. Requires norm.clip = False """ self._rgba_over = to_rgba(color, alpha) if self._isinit: self._set_extremes() def _set_extremes(self): if self._rgba_under: self._lut[self._i_under] = self._rgba_under else: self._lut[self._i_under] = self._lut[0] if self._rgba_over: self._lut[self._i_over] = self._rgba_over else: self._lut[self._i_over] = self._lut[self.N - 1] self._lut[self._i_bad] = self._rgba_bad def _init(self): """Generate the lookup table, self._lut""" raise NotImplementedError("Abstract class only") def is_gray(self): if not self._isinit: self._init() return (np.all(self._lut[:, 0] == self._lut[:, 1]) and np.all(self._lut[:, 0] == self._lut[:, 2])) def _resample(self, lutsize): """ Return a new color map with *lutsize* entries. """ raise NotImplementedError() def reversed(self, name=None): """ Make a reversed instance of the Colormap. .. note :: Function not implemented for base class. Parameters ---------- name : str, optional The name for the reversed colormap. If it's None the name will be the name of the parent colormap + "_r". Notes ----- See :meth:`LinearSegmentedColormap.reversed` and :meth:`ListedColormap.reversed` """ raise NotImplementedError() class LinearSegmentedColormap(Colormap): """Colormap objects based on lookup tables using linear segments. The lookup table is generated using linear interpolation for each primary color, with the 0-1 domain divided into any number of segments. """ def __init__(self, name, segmentdata, N=256, gamma=1.0): """Create color map from linear mapping segments segmentdata argument is a dictionary with a red, green and blue entries. Each entry should be a list of *x*, *y0*, *y1* tuples, forming rows in a table. Entries for alpha are optional. Example: suppose you want red to increase from 0 to 1 over the bottom half, green to do the same over the middle half, and blue over the top half. Then you would use:: cdict = {'red': [(0.0, 0.0, 0.0), (0.5, 1.0, 1.0), (1.0, 1.0, 1.0)], 'green': [(0.0, 0.0, 0.0), (0.25, 0.0, 0.0), (0.75, 1.0, 1.0), (1.0, 1.0, 1.0)], 'blue': [(0.0, 0.0, 0.0), (0.5, 0.0, 0.0), (1.0, 1.0, 1.0)]} Each row in the table for a given color is a sequence of *x*, *y0*, *y1* tuples. In each sequence, *x* must increase monotonically from 0 to 1. For any input value *z* falling between *x[i]* and *x[i+1]*, the output value of a given color will be linearly interpolated between *y1[i]* and *y0[i+1]*:: row i: x y0 y1 / / row i+1: x y0 y1 Hence y0 in the first row and y1 in the last row are never used. .. seealso:: :meth:`LinearSegmentedColormap.from_list` Static method; factory function for generating a smoothly-varying LinearSegmentedColormap. :func:`makeMappingArray` For information about making a mapping array. """ # True only if all colors in map are identical; needed for contouring. self.monochrome = False Colormap.__init__(self, name, N) self._segmentdata = segmentdata self._gamma = gamma def _init(self): self._lut = np.ones((self.N + 3, 4), float) self._lut[:-3, 0] = makeMappingArray( self.N, self._segmentdata['red'], self._gamma) self._lut[:-3, 1] = makeMappingArray( self.N, self._segmentdata['green'], self._gamma) self._lut[:-3, 2] = makeMappingArray( self.N, self._segmentdata['blue'], self._gamma) if 'alpha' in self._segmentdata: self._lut[:-3, 3] = makeMappingArray( self.N, self._segmentdata['alpha'], 1) self._isinit = True self._set_extremes() def set_gamma(self, gamma): """ Set a new gamma value and regenerate color map. """ self._gamma = gamma self._init() @staticmethod def from_list(name, colors, N=256, gamma=1.0): """ Make a linear segmented colormap with *name* from a sequence of *colors* which evenly transitions from colors[0] at val=0 to colors[-1] at val=1. *N* is the number of rgb quantization levels. Alternatively, a list of (value, color) tuples can be given to divide the range unevenly. """ if not np.iterable(colors): raise ValueError('colors must be iterable') if (isinstance(colors[0], Sized) and len(colors[0]) == 2 and not isinstance(colors[0], str)): # List of value, color pairs vals, colors = zip(*colors) else: vals = np.linspace(0, 1, len(colors)) cdict = dict(red=[], green=[], blue=[], alpha=[]) for val, color in zip(vals, colors): r, g, b, a = to_rgba(color) cdict['red'].append((val, r, r)) cdict['green'].append((val, g, g)) cdict['blue'].append((val, b, b)) cdict['alpha'].append((val, a, a)) return LinearSegmentedColormap(name, cdict, N, gamma) def _resample(self, lutsize): """ Return a new color map with *lutsize* entries. """ return LinearSegmentedColormap(self.name, self._segmentdata, lutsize) def reversed(self, name=None): """ Make a reversed instance of the Colormap. Parameters ---------- name : str, optional The name for the reversed colormap. If it's None the name will be the name of the parent colormap + "_r". Returns ------- LinearSegmentedColormap The reversed colormap. """ if name is None: name = self.name + "_r" # Function factory needed to deal with 'late binding' issue. def factory(dat): def func_r(x): return dat(1.0 - x) return func_r data_r = {key: (factory(data) if callable(data) else [(1.0 - x, y1, y0) for x, y0, y1 in reversed(data)]) for key, data in self._segmentdata.items()} return LinearSegmentedColormap(name, data_r, self.N, self._gamma) class ListedColormap(Colormap): """Colormap object generated from a list of colors. This may be most useful when indexing directly into a colormap, but it can also be used to generate special colormaps for ordinary mapping. """ def __init__(self, colors, name='from_list', N=None): """ Make a colormap from a list of colors. *colors* a list of matplotlib color specifications, or an equivalent Nx3 or Nx4 floating point array (*N* rgb or rgba values) *name* a string to identify the colormap *N* the number of entries in the map. The default is *None*, in which case there is one colormap entry for each element in the list of colors. If:: N < len(colors) the list will be truncated at *N*. If:: N > len(colors) the list will be extended by repetition. """ self.monochrome = False # True only if all colors in map are # identical; needed for contouring. if N is None: self.colors = colors N = len(colors) else: if isinstance(colors, str): self.colors = [colors] * N self.monochrome = True elif np.iterable(colors): if len(colors) == 1: self.monochrome = True self.colors = list( itertools.islice(itertools.cycle(colors), N)) else: try: gray = float(colors) except TypeError: pass else: self.colors = [gray] * N self.monochrome = True Colormap.__init__(self, name, N) def _init(self): self._lut = np.zeros((self.N + 3, 4), float) self._lut[:-3] = to_rgba_array(self.colors) self._isinit = True self._set_extremes() def _resample(self, lutsize): """ Return a new color map with *lutsize* entries. """ colors = self(np.linspace(0, 1, lutsize)) return ListedColormap(colors, name=self.name) def reversed(self, name=None): """ Make a reversed instance of the Colormap. Parameters ---------- name : str, optional The name for the reversed colormap. If it's None the name will be the name of the parent colormap + "_r". Returns ------- ListedColormap A reversed instance of the colormap. """ if name is None: name = self.name + "_r" colors_r = list(reversed(self.colors)) return ListedColormap(colors_r, name=name, N=self.N) class Normalize(object): """ A class which, when called, can normalize data into the ``[0.0, 1.0]`` interval. """ def __init__(self, vmin=None, vmax=None, clip=False): """ If *vmin* or *vmax* is not given, they are initialized from the minimum and maximum value respectively of the first input processed. That is, *__call__(A)* calls *autoscale_None(A)*. If *clip* is *True* and the given value falls outside the range, the returned value will be 0 or 1, whichever is closer. Returns 0 if:: vmin==vmax Works with scalars or arrays, including masked arrays. If *clip* is *True*, masked values are set to 1; otherwise they remain masked. Clipping silently defeats the purpose of setting the over, under, and masked colors in the colormap, so it is likely to lead to surprises; therefore the default is *clip* = *False*. """ self.vmin = _sanitize_extrema(vmin) self.vmax = _sanitize_extrema(vmax) self.clip = clip @staticmethod def process_value(value): """ Homogenize the input *value* for easy and efficient normalization. *value* can be a scalar or sequence. Returns *result*, *is_scalar*, where *result* is a masked array matching *value*. Float dtypes are preserved; integer types with two bytes or smaller are converted to np.float32, and larger types are converted to np.float64. Preserving float32 when possible, and using in-place operations, can greatly improve speed for large arrays. Experimental; we may want to add an option to force the use of float32. """ is_scalar = not np.iterable(value) if is_scalar: value = [value] dtype = np.min_scalar_type(value) if np.issubdtype(dtype, np.integer) or dtype.type is np.bool_: # bool_/int8/int16 -> float32; int32/int64 -> float64 dtype = np.promote_types(dtype, np.float32) # ensure data passed in as an ndarray subclass are interpreted as # an ndarray. See issue #6622. mask = np.ma.getmask(value) data = np.asarray(np.ma.getdata(value)) result = np.ma.array(data, mask=mask, dtype=dtype, copy=True) return result, is_scalar def __call__(self, value, clip=None): """ Normalize *value* data in the ``[vmin, vmax]`` interval into the ``[0.0, 1.0]`` interval and return it. *clip* defaults to *self.clip* (which defaults to *False*). If not already initialized, *vmin* and *vmax* are initialized using *autoscale_None(value)*. """ if clip is None: clip = self.clip result, is_scalar = self.process_value(value) self.autoscale_None(result) # Convert at least to float, without losing precision. (vmin,), _ = self.process_value(self.vmin) (vmax,), _ = self.process_value(self.vmax) if vmin == vmax: result.fill(0) # Or should it be all masked? Or 0.5? elif vmin > vmax: raise ValueError("minvalue must be less than or equal to maxvalue") else: if clip: mask = np.ma.getmask(result) result = np.ma.array(np.clip(result.filled(vmax), vmin, vmax), mask=mask) # ma division is very slow; we can take a shortcut resdat = result.data resdat -= vmin resdat /= (vmax - vmin) result = np.ma.array(resdat, mask=result.mask, copy=False) if is_scalar: result = result[0] return result def inverse(self, value): if not self.scaled(): raise ValueError("Not invertible until scaled") (vmin,), _ = self.process_value(self.vmin) (vmax,), _ = self.process_value(self.vmax) if np.iterable(value): val = np.ma.asarray(value) return vmin + val * (vmax - vmin) else: return vmin + value * (vmax - vmin) def autoscale(self, A): """Set *vmin*, *vmax* to min, max of *A*.""" A = np.asanyarray(A) self.vmin = A.min() self.vmax = A.max() def autoscale_None(self, A): """Autoscale only None-valued vmin or vmax.""" A = np.asanyarray(A) if self.vmin is None and A.size: self.vmin = A.min() if self.vmax is None and A.size: self.vmax = A.max() def scaled(self): """Return whether vmin and vmax are set.""" return self.vmin is not None and self.vmax is not None class LogNorm(Normalize): """Normalize a given value to the 0-1 range on a log scale.""" def __call__(self, value, clip=None): if clip is None: clip = self.clip result, is_scalar = self.process_value(value) result = np.ma.masked_less_equal(result, 0, copy=False) self.autoscale_None(result) vmin, vmax = self.vmin, self.vmax if vmin > vmax: raise ValueError("minvalue must be less than or equal to maxvalue") elif vmin <= 0: raise ValueError("values must all be positive") elif vmin == vmax: result.fill(0) else: if clip: mask = np.ma.getmask(result) result = np.ma.array(np.clip(result.filled(vmax), vmin, vmax), mask=mask) # in-place equivalent of above can be much faster resdat = result.data mask = result.mask if mask is np.ma.nomask: mask = (resdat <= 0) else: mask |= resdat <= 0 np.copyto(resdat, 1, where=mask) np.log(resdat, resdat) resdat -= np.log(vmin) resdat /= (np.log(vmax) - np.log(vmin)) result = np.ma.array(resdat, mask=mask, copy=False) if is_scalar: result = result[0] return result def inverse(self, value): if not self.scaled(): raise ValueError("Not invertible until scaled") vmin, vmax = self.vmin, self.vmax if np.iterable(value): val = np.ma.asarray(value) return vmin * np.ma.power((vmax / vmin), val) else: return vmin * pow((vmax / vmin), value) def autoscale(self, A): # docstring inherited. super().autoscale(np.ma.masked_less_equal(A, 0, copy=False)) def autoscale_None(self, A): # docstring inherited. super().autoscale_None(np.ma.masked_less_equal(A, 0, copy=False)) class SymLogNorm(Normalize): """ The symmetrical logarithmic scale is logarithmic in both the positive and negative directions from the origin. Since the values close to zero tend toward infinity, there is a need to have a range around zero that is linear. The parameter *linthresh* allows the user to specify the size of this range (-*linthresh*, *linthresh*). """ def __init__(self, linthresh, linscale=1.0, vmin=None, vmax=None, clip=False): """ *linthresh*: The range within which the plot is linear (to avoid having the plot go to infinity around zero). *linscale*: This allows the linear range (-*linthresh* to *linthresh*) to be stretched relative to the logarithmic range. Its value is the number of decades to use for each half of the linear range. For example, when *linscale* == 1.0 (the default), the space used for the positive and negative halves of the linear range will be equal to one decade in the logarithmic range. Defaults to 1. """ Normalize.__init__(self, vmin, vmax, clip) self.linthresh = float(linthresh) self._linscale_adj = (linscale / (1.0 - np.e ** -1)) if vmin is not None and vmax is not None: self._transform_vmin_vmax() def __call__(self, value, clip=None): if clip is None: clip = self.clip result, is_scalar = self.process_value(value) self.autoscale_None(result) vmin, vmax = self.vmin, self.vmax if vmin > vmax: raise ValueError("minvalue must be less than or equal to maxvalue") elif vmin == vmax: result.fill(0) else: if clip: mask = np.ma.getmask(result) result = np.ma.array(np.clip(result.filled(vmax), vmin, vmax), mask=mask) # in-place equivalent of above can be much faster resdat = self._transform(result.data) resdat -= self._lower resdat /= (self._upper - self._lower) if is_scalar: result = result[0] return result def _transform(self, a): """Inplace transformation.""" with np.errstate(invalid="ignore"): masked = np.abs(a) > self.linthresh sign = np.sign(a[masked]) log = (self._linscale_adj + np.log(np.abs(a[masked]) / self.linthresh)) log *= sign * self.linthresh a[masked] = log a[~masked] *= self._linscale_adj return a def _inv_transform(self, a): """Inverse inplace Transformation.""" masked = np.abs(a) > (self.linthresh * self._linscale_adj) sign = np.sign(a[masked]) exp = np.exp(sign * a[masked] / self.linthresh - self._linscale_adj) exp *= sign * self.linthresh a[masked] = exp a[~masked] /= self._linscale_adj return a def _transform_vmin_vmax(self): """Calculates vmin and vmax in the transformed system.""" vmin, vmax = self.vmin, self.vmax arr = np.array([vmax, vmin]).astype(float) self._upper, self._lower = self._transform(arr) def inverse(self, value): if not self.scaled(): raise ValueError("Not invertible until scaled") val = np.ma.asarray(value) val = val * (self._upper - self._lower) + self._lower return self._inv_transform(val) def autoscale(self, A): # docstring inherited. super().autoscale(A) self._transform_vmin_vmax() def autoscale_None(self, A): # docstring inherited. super().autoscale_None(A) self._transform_vmin_vmax() class PowerNorm(Normalize): """ Linearly map a given value to the 0-1 range and then apply a power-law normalization over that range. """ def __init__(self, gamma, vmin=None, vmax=None, clip=False): Normalize.__init__(self, vmin, vmax, clip) self.gamma = gamma def __call__(self, value, clip=None): if clip is None: clip = self.clip result, is_scalar = self.process_value(value) self.autoscale_None(result) gamma = self.gamma vmin, vmax = self.vmin, self.vmax if vmin > vmax: raise ValueError("minvalue must be less than or equal to maxvalue") elif vmin == vmax: result.fill(0) else: if clip: mask = np.ma.getmask(result) result = np.ma.array(np.clip(result.filled(vmax), vmin, vmax), mask=mask) resdat = result.data resdat -= vmin resdat[resdat < 0] = 0 np.power(resdat, gamma, resdat) resdat /= (vmax - vmin) ** gamma result = np.ma.array(resdat, mask=result.mask, copy=False) if is_scalar: result = result[0] return result def inverse(self, value): if not self.scaled(): raise ValueError("Not invertible until scaled") gamma = self.gamma vmin, vmax = self.vmin, self.vmax if np.iterable(value): val = np.ma.asarray(value) return np.ma.power(val, 1. / gamma) * (vmax - vmin) + vmin else: return pow(value, 1. / gamma) * (vmax - vmin) + vmin class BoundaryNorm(Normalize): """ Generate a colormap index based on discrete intervals. Unlike `Normalize` or `LogNorm`, `BoundaryNorm` maps values to integers instead of to the interval 0-1. Mapping to the 0-1 interval could have been done via piece-wise linear interpolation, but using integers seems simpler, and reduces the number of conversions back and forth between integer and floating point. """ def __init__(self, boundaries, ncolors, clip=False): """ Parameters ---------- boundaries : array-like Monotonically increasing sequence of boundaries ncolors : int Number of colors in the colormap to be used clip : bool, optional If clip is ``True``, out of range values are mapped to 0 if they are below ``boundaries[0]`` or mapped to ncolors - 1 if they are above ``boundaries[-1]``. If clip is ``False``, out of range values are mapped to -1 if they are below ``boundaries[0]`` or mapped to ncolors if they are above ``boundaries[-1]``. These are then converted to valid indices by :meth:`Colormap.__call__`. Notes ----- *boundaries* defines the edges of bins, and data falling within a bin is mapped to the color with the same index. If the number of bins doesn't equal *ncolors*, the color is chosen by linear interpolation of the bin number onto color numbers. """ self.clip = clip self.vmin = boundaries[0] self.vmax = boundaries[-1] self.boundaries = np.asarray(boundaries) self.N = len(self.boundaries) self.Ncmap = ncolors if self.N - 1 == self.Ncmap: self._interp = False else: self._interp = True def __call__(self, value, clip=None): if clip is None: clip = self.clip xx, is_scalar = self.process_value(value) mask = np.ma.getmaskarray(xx) xx = np.atleast_1d(xx.filled(self.vmax + 1)) if clip: np.clip(xx, self.vmin, self.vmax, out=xx) max_col = self.Ncmap - 1 else: max_col = self.Ncmap iret = np.zeros(xx.shape, dtype=np.int16) for i, b in enumerate(self.boundaries): iret[xx >= b] = i if self._interp: scalefac = (self.Ncmap - 1) / (self.N - 2) iret = (iret * scalefac).astype(np.int16) iret[xx < self.vmin] = -1 iret[xx >= self.vmax] = max_col ret = np.ma.array(iret, mask=mask) if is_scalar: ret = int(ret[0]) # assume python scalar return ret def inverse(self, value): """ Raises ------ ValueError BoundaryNorm is not invertible, so calling this method will always raise an error """ return ValueError("BoundaryNorm is not invertible") class NoNorm(Normalize): """ Dummy replacement for `Normalize`, for the case where we want to use indices directly in a `~matplotlib.cm.ScalarMappable`. """ def __call__(self, value, clip=None): return value def inverse(self, value): return value def rgb_to_hsv(arr): """ Convert float rgb values (in the range [0, 1]), in a numpy array to hsv values. Parameters ---------- arr : (..., 3) array-like All values must be in the range [0, 1] Returns ------- hsv : (..., 3) ndarray Colors converted to hsv values in range [0, 1] """ arr = np.asarray(arr) # check length of the last dimension, should be _some_ sort of rgb if arr.shape[-1] != 3: raise ValueError("Last dimension of input array must be 3; " "shape {} was found.".format(arr.shape)) in_shape = arr.shape arr = np.array( arr, copy=False, dtype=np.promote_types(arr.dtype, np.float32), # Don't work on ints. ndmin=2, # In case input was 1D. ) out = np.zeros_like(arr) arr_max = arr.max(-1) ipos = arr_max > 0 delta = arr.ptp(-1) s = np.zeros_like(delta) s[ipos] = delta[ipos] / arr_max[ipos] ipos = delta > 0 # red is max idx = (arr[..., 0] == arr_max) & ipos out[idx, 0] = (arr[idx, 1] - arr[idx, 2]) / delta[idx] # green is max idx = (arr[..., 1] == arr_max) & ipos out[idx, 0] = 2. + (arr[idx, 2] - arr[idx, 0]) / delta[idx] # blue is max idx = (arr[..., 2] == arr_max) & ipos out[idx, 0] = 4. + (arr[idx, 0] - arr[idx, 1]) / delta[idx] out[..., 0] = (out[..., 0] / 6.0) % 1.0 out[..., 1] = s out[..., 2] = arr_max return out.reshape(in_shape) def hsv_to_rgb(hsv): """ Convert hsv values to rgb. Parameters ---------- hsv : (..., 3) array-like All values assumed to be in range [0, 1] Returns ------- rgb : (..., 3) ndarray Colors converted to RGB values in range [0, 1] """ hsv = np.asarray(hsv) # check length of the last dimension, should be _some_ sort of rgb if hsv.shape[-1] != 3: raise ValueError("Last dimension of input array must be 3; " "shape {shp} was found.".format(shp=hsv.shape)) in_shape = hsv.shape hsv = np.array( hsv, copy=False, dtype=np.promote_types(hsv.dtype, np.float32), # Don't work on ints. ndmin=2, # In case input was 1D. ) h = hsv[..., 0] s = hsv[..., 1] v = hsv[..., 2] r = np.empty_like(h) g = np.empty_like(h) b = np.empty_like(h) i = (h * 6.0).astype(int) f = (h * 6.0) - i p = v * (1.0 - s) q = v * (1.0 - s * f) t = v * (1.0 - s * (1.0 - f)) idx = i % 6 == 0 r[idx] = v[idx] g[idx] = t[idx] b[idx] = p[idx] idx = i == 1 r[idx] = q[idx] g[idx] = v[idx] b[idx] = p[idx] idx = i == 2 r[idx] = p[idx] g[idx] = v[idx] b[idx] = t[idx] idx = i == 3 r[idx] = p[idx] g[idx] = q[idx] b[idx] = v[idx] idx = i == 4 r[idx] = t[idx] g[idx] = p[idx] b[idx] = v[idx] idx = i == 5 r[idx] = v[idx] g[idx] = p[idx] b[idx] = q[idx] idx = s == 0 r[idx] = v[idx] g[idx] = v[idx] b[idx] = v[idx] rgb = np.stack([r, g, b], axis=-1) return rgb.reshape(in_shape) def _vector_magnitude(arr): # things that don't work here: # * np.linalg.norm # - doesn't broadcast in numpy 1.7 # - drops the mask from ma.array # * using keepdims - broken on ma.array until 1.11.2 # * using sum - discards mask on ma.array unless entire vector is masked sum_sq = 0 for i in range(arr.shape[-1]): sum_sq += np.square(arr[..., i, np.newaxis]) return np.sqrt(sum_sq) class LightSource(object): """ Create a light source coming from the specified azimuth and elevation. Angles are in degrees, with the azimuth measured clockwise from north and elevation up from the zero plane of the surface. The :meth:`shade` is used to produce "shaded" rgb values for a data array. :meth:`shade_rgb` can be used to combine an rgb image with The :meth:`shade_rgb` The :meth:`hillshade` produces an illumination map of a surface. """ def __init__(self, azdeg=315, altdeg=45, hsv_min_val=0, hsv_max_val=1, hsv_min_sat=1, hsv_max_sat=0): """ Specify the azimuth (measured clockwise from south) and altitude (measured up from the plane of the surface) of the light source in degrees. Parameters ---------- azdeg : number, optional The azimuth (0-360, degrees clockwise from North) of the light source. Defaults to 315 degrees (from the northwest). altdeg : number, optional The altitude (0-90, degrees up from horizontal) of the light source. Defaults to 45 degrees from horizontal. Notes ----- For backwards compatibility, the parameters *hsv_min_val*, *hsv_max_val*, *hsv_min_sat*, and *hsv_max_sat* may be supplied at initialization as well. However, these parameters will only be used if "blend_mode='hsv'" is passed into :meth:`shade` or :meth:`shade_rgb`. See the documentation for :meth:`blend_hsv` for more details. """ self.azdeg = azdeg self.altdeg = altdeg self.hsv_min_val = hsv_min_val self.hsv_max_val = hsv_max_val self.hsv_min_sat = hsv_min_sat self.hsv_max_sat = hsv_max_sat @property def direction(self): """ The unit vector direction towards the light source """ # Azimuth is in degrees clockwise from North. Convert to radians # counterclockwise from East (mathematical notation). az = np.radians(90 - self.azdeg) alt = np.radians(self.altdeg) return np.array([ np.cos(az) * np.cos(alt), np.sin(az) * np.cos(alt), np.sin(alt) ]) def hillshade(self, elevation, vert_exag=1, dx=1, dy=1, fraction=1.): """ Calculates the illumination intensity for a surface using the defined azimuth and elevation for the light source. This computes the normal vectors for the surface, and then passes them on to `shade_normals` Parameters ---------- elevation : array-like A 2d array (or equivalent) of the height values used to generate an illumination map vert_exag : number, optional The amount to exaggerate the elevation values by when calculating illumination. This can be used either to correct for differences in units between the x-y coordinate system and the elevation coordinate system (e.g. decimal degrees vs meters) or to exaggerate or de-emphasize topographic effects. dx : number, optional The x-spacing (columns) of the input *elevation* grid. dy : number, optional The y-spacing (rows) of the input *elevation* grid. fraction : number, optional Increases or decreases the contrast of the hillshade. Values greater than one will cause intermediate values to move closer to full illumination or shadow (and clipping any values that move beyond 0 or 1). Note that this is not visually or mathematically the same as vertical exaggeration. Returns ------- intensity : ndarray A 2d array of illumination values between 0-1, where 0 is completely in shadow and 1 is completely illuminated. """ # Because most image and raster GIS data has the first row in the array # as the "top" of the image, dy is implicitly negative. This is # consistent to what `imshow` assumes, as well. dy = -dy # compute the normal vectors from the partial derivatives e_dy, e_dx = np.gradient(vert_exag * elevation, dy, dx) # .view is to keep subclasses normal = np.empty(elevation.shape + (3,)).view(type(elevation)) normal[..., 0] = -e_dx normal[..., 1] = -e_dy normal[..., 2] = 1 normal /= _vector_magnitude(normal) return self.shade_normals(normal, fraction) def shade_normals(self, normals, fraction=1.): """ Calculates the illumination intensity for the normal vectors of a surface using the defined azimuth and elevation for the light source. Imagine an artificial sun placed at infinity in some azimuth and elevation position illuminating our surface. The parts of the surface that slope toward the sun should brighten while those sides facing away should become darker. Parameters ---------- fraction : number, optional Increases or decreases the contrast of the hillshade. Values greater than one will cause intermediate values to move closer to full illumination or shadow (and clipping any values that move beyond 0 or 1). Note that this is not visually or mathematically the same as vertical exaggeration. Returns ------- intensity : ndarray A 2d array of illumination values between 0-1, where 0 is completely in shadow and 1 is completely illuminated. """ intensity = normals.dot(self.direction) # Apply contrast stretch imin, imax = intensity.min(), intensity.max() intensity *= fraction # Rescale to 0-1, keeping range before contrast stretch # If constant slope, keep relative scaling (i.e. flat should be 0.5, # fully occluded 0, etc.) if (imax - imin) > 1e-6: # Strictly speaking, this is incorrect. Negative values should be # clipped to 0 because they're fully occluded. However, rescaling # in this manner is consistent with the previous implementation and # visually appears better than a "hard" clip. intensity -= imin intensity /= (imax - imin) intensity = np.clip(intensity, 0, 1, intensity) return intensity def shade(self, data, cmap, norm=None, blend_mode='overlay', vmin=None, vmax=None, vert_exag=1, dx=1, dy=1, fraction=1, **kwargs): """ Combine colormapped data values with an illumination intensity map (a.k.a. "hillshade") of the values. Parameters ---------- data : array-like A 2d array (or equivalent) of the height values used to generate a shaded map. cmap : `~matplotlib.colors.Colormap` instance The colormap used to color the *data* array. Note that this must be a `~matplotlib.colors.Colormap` instance. For example, rather than passing in `cmap='gist_earth'`, use `cmap=plt.get_cmap('gist_earth')` instead. norm : `~matplotlib.colors.Normalize` instance, optional The normalization used to scale values before colormapping. If None, the input will be linearly scaled between its min and max. blend_mode : {'hsv', 'overlay', 'soft'} or callable, optional The type of blending used to combine the colormapped data values with the illumination intensity. Default is "overlay". Note that for most topographic surfaces, "overlay" or "soft" appear more visually realistic. If a user-defined function is supplied, it is expected to combine an MxNx3 RGB array of floats (ranging 0 to 1) with an MxNx1 hillshade array (also 0 to 1). (Call signature `func(rgb, illum, **kwargs)`) Additional kwargs supplied to this function will be passed on to the *blend_mode* function. vmin : scalar or None, optional The minimum value used in colormapping *data*. If *None* the minimum value in *data* is used. If *norm* is specified, then this argument will be ignored. vmax : scalar or None, optional The maximum value used in colormapping *data*. If *None* the maximum value in *data* is used. If *norm* is specified, then this argument will be ignored. vert_exag : number, optional The amount to exaggerate the elevation values by when calculating illumination. This can be used either to correct for differences in units between the x-y coordinate system and the elevation coordinate system (e.g. decimal degrees vs meters) or to exaggerate or de-emphasize topography. dx : number, optional The x-spacing (columns) of the input *elevation* grid. dy : number, optional The y-spacing (rows) of the input *elevation* grid. fraction : number, optional Increases or decreases the contrast of the hillshade. Values greater than one will cause intermediate values to move closer to full illumination or shadow (and clipping any values that move beyond 0 or 1). Note that this is not visually or mathematically the same as vertical exaggeration. Additional kwargs are passed on to the *blend_mode* function. Returns ------- rgba : ndarray An MxNx4 array of floats ranging between 0-1. """ if vmin is None: vmin = data.min() if vmax is None: vmax = data.max() if norm is None: norm = Normalize(vmin=vmin, vmax=vmax) rgb0 = cmap(norm(data)) rgb1 = self.shade_rgb(rgb0, elevation=data, blend_mode=blend_mode, vert_exag=vert_exag, dx=dx, dy=dy, fraction=fraction, **kwargs) # Don't overwrite the alpha channel, if present. rgb0[..., :3] = rgb1[..., :3] return rgb0 def shade_rgb(self, rgb, elevation, fraction=1., blend_mode='hsv', vert_exag=1, dx=1, dy=1, **kwargs): """ Use this light source to adjust the colors of the *rgb* input array to give the impression of a shaded relief map with the given `elevation`. Parameters ---------- rgb : array-like An (M, N, 3) RGB array, assumed to be in the range of 0 to 1. elevation : array-like An (M, N) array of the height values used to generate a shaded map. fraction : number Increases or decreases the contrast of the hillshade. Values greater than one will cause intermediate values to move closer to full illumination or shadow (and clipping any values that move beyond 0 or 1). Note that this is not visually or mathematically the same as vertical exaggeration. blend_mode : {'hsv', 'overlay', 'soft'} or callable, optional The type of blending used to combine the colormapped data values with the illumination intensity. For backwards compatibility, this defaults to "hsv". Note that for most topographic surfaces, "overlay" or "soft" appear more visually realistic. If a user-defined function is supplied, it is expected to combine an MxNx3 RGB array of floats (ranging 0 to 1) with an MxNx1 hillshade array (also 0 to 1). (Call signature `func(rgb, illum, **kwargs)`) Additional kwargs supplied to this function will be passed on to the *blend_mode* function. vert_exag : number, optional The amount to exaggerate the elevation values by when calculating illumination. This can be used either to correct for differences in units between the x-y coordinate system and the elevation coordinate system (e.g. decimal degrees vs meters) or to exaggerate or de-emphasize topography. dx : number, optional The x-spacing (columns) of the input *elevation* grid. dy : number, optional The y-spacing (rows) of the input *elevation* grid. Additional kwargs are passed on to the *blend_mode* function. Returns ------- shaded_rgb : ndarray An (m, n, 3) array of floats ranging between 0-1. """ # Calculate the "hillshade" intensity. intensity = self.hillshade(elevation, vert_exag, dx, dy, fraction) intensity = intensity[..., np.newaxis] # Blend the hillshade and rgb data using the specified mode lookup = { 'hsv': self.blend_hsv, 'soft': self.blend_soft_light, 'overlay': self.blend_overlay, } if blend_mode in lookup: blend = lookup[blend_mode](rgb, intensity, **kwargs) else: try: blend = blend_mode(rgb, intensity, **kwargs) except TypeError: raise ValueError('"blend_mode" must be callable or one of {}' .format(lookup.keys)) # Only apply result where hillshade intensity isn't masked if hasattr(intensity, 'mask'): mask = intensity.mask[..., 0] for i in range(3): blend[..., i][mask] = rgb[..., i][mask] return blend def blend_hsv(self, rgb, intensity, hsv_max_sat=None, hsv_max_val=None, hsv_min_val=None, hsv_min_sat=None): """ Take the input data array, convert to HSV values in the given colormap, then adjust those color values to give the impression of a shaded relief map with a specified light source. RGBA values are returned, which can then be used to plot the shaded image with imshow. The color of the resulting image will be darkened by moving the (s,v) values (in hsv colorspace) toward (hsv_min_sat, hsv_min_val) in the shaded regions, or lightened by sliding (s,v) toward (hsv_max_sat hsv_max_val) in regions that are illuminated. The default extremes are chose so that completely shaded points are nearly black (s = 1, v = 0) and completely illuminated points are nearly white (s = 0, v = 1). Parameters ---------- rgb : ndarray An MxNx3 RGB array of floats ranging from 0 to 1 (color image). intensity : ndarray An MxNx1 array of floats ranging from 0 to 1 (grayscale image). hsv_max_sat : number, optional The maximum saturation value that the *intensity* map can shift the output image to. Defaults to 1. hsv_min_sat : number, optional The minimum saturation value that the *intensity* map can shift the output image to. Defaults to 0. hsv_max_val : number, optional The maximum value ("v" in "hsv") that the *intensity* map can shift the output image to. Defaults to 1. hsv_min_val : number, optional The minimum value ("v" in "hsv") that the *intensity* map can shift the output image to. Defaults to 0. Returns ------- rgb : ndarray An MxNx3 RGB array representing the combined images. """ # Backward compatibility... if hsv_max_sat is None: hsv_max_sat = self.hsv_max_sat if hsv_max_val is None: hsv_max_val = self.hsv_max_val if hsv_min_sat is None: hsv_min_sat = self.hsv_min_sat if hsv_min_val is None: hsv_min_val = self.hsv_min_val # Expects a 2D intensity array scaled between -1 to 1... intensity = intensity[..., 0] intensity = 2 * intensity - 1 # convert to rgb, then rgb to hsv hsv = rgb_to_hsv(rgb[:, :, 0:3]) # modify hsv values to simulate illumination. hsv[:, :, 1] = np.where(np.logical_and(np.abs(hsv[:, :, 1]) > 1.e-10, intensity > 0), ((1. - intensity) * hsv[:, :, 1] + intensity * hsv_max_sat), hsv[:, :, 1]) hsv[:, :, 2] = np.where(intensity > 0, ((1. - intensity) * hsv[:, :, 2] + intensity * hsv_max_val), hsv[:, :, 2]) hsv[:, :, 1] = np.where(np.logical_and(np.abs(hsv[:, :, 1]) > 1.e-10, intensity < 0), ((1. + intensity) * hsv[:, :, 1] - intensity * hsv_min_sat), hsv[:, :, 1]) hsv[:, :, 2] = np.where(intensity < 0, ((1. + intensity) * hsv[:, :, 2] - intensity * hsv_min_val), hsv[:, :, 2]) hsv[:, :, 1:] = np.where(hsv[:, :, 1:] < 0., 0, hsv[:, :, 1:]) hsv[:, :, 1:] = np.where(hsv[:, :, 1:] > 1., 1, hsv[:, :, 1:]) # convert modified hsv back to rgb. return hsv_to_rgb(hsv) def blend_soft_light(self, rgb, intensity): """ Combines an rgb image with an intensity map using "soft light" blending. Uses the "pegtop" formula. Parameters ---------- rgb : ndarray An MxNx3 RGB array of floats ranging from 0 to 1 (color image). intensity : ndarray An MxNx1 array of floats ranging from 0 to 1 (grayscale image). Returns ------- rgb : ndarray An MxNx3 RGB array representing the combined images. """ return 2 * intensity * rgb + (1 - 2 * intensity) * rgb**2 def blend_overlay(self, rgb, intensity): """ Combines an rgb image with an intensity map using "overlay" blending. Parameters ---------- rgb : ndarray An MxNx3 RGB array of floats ranging from 0 to 1 (color image). intensity : ndarray An MxNx1 array of floats ranging from 0 to 1 (grayscale image). Returns ------- rgb : ndarray An MxNx3 RGB array representing the combined images. """ low = 2 * intensity * rgb high = 1 - 2 * (1 - intensity) * (1 - rgb) return np.where(rgb <= 0.5, low, high) def from_levels_and_colors(levels, colors, extend='neither'): """ A helper routine to generate a cmap and a norm instance which behave similar to contourf's levels and colors arguments. Parameters ---------- levels : sequence of numbers The quantization levels used to construct the :class:`BoundaryNorm`. Values ``v`` are quantizized to level ``i`` if ``lev[i] <= v < lev[i+1]``. colors : sequence of colors The fill color to use for each level. If `extend` is "neither" there must be ``n_level - 1`` colors. For an `extend` of "min" or "max" add one extra color, and for an `extend` of "both" add two colors. extend : {'neither', 'min', 'max', 'both'}, optional The behaviour when a value falls out of range of the given levels. See :func:`~matplotlib.pyplot.contourf` for details. Returns ------- (cmap, norm) : tuple containing a :class:`Colormap` and a \ :class:`Normalize` instance """ colors_i0 = 0 colors_i1 = None if extend == 'both': colors_i0 = 1 colors_i1 = -1 extra_colors = 2 elif extend == 'min': colors_i0 = 1 extra_colors = 1 elif extend == 'max': colors_i1 = -1 extra_colors = 1 elif extend == 'neither': extra_colors = 0 else: raise ValueError('Unexpected value for extend: {0!r}'.format(extend)) n_data_colors = len(levels) - 1 n_expected_colors = n_data_colors + extra_colors if len(colors) != n_expected_colors: raise ValueError('With extend == {0!r} and n_levels == {1!r} expected' ' n_colors == {2!r}. Got {3!r}.' ''.format(extend, len(levels), n_expected_colors, len(colors))) cmap = ListedColormap(colors[colors_i0:colors_i1], N=n_data_colors) if extend in ['min', 'both']: cmap.set_under(colors[0]) else: cmap.set_under('none') if extend in ['max', 'both']: cmap.set_over(colors[-1]) else: cmap.set_over('none') cmap.colorbar_extend = extend norm = BoundaryNorm(levels, ncolors=n_data_colors) return cmap, norm napari-0.5.0a1/napari/utils/config.py000066400000000000000000000037621437041365600174620ustar00rootroot00000000000000"""Napari Configuration. """ import os from napari.utils._octree import get_octree_config def _set(env_var: str) -> bool: """Return True if the env variable is set and non-zero. Returns ------- bool True if the env var was set to a non-zero value. """ return os.getenv(env_var) not in [None, "0"] """ Experimental Features Async Loading ------------- Image layers will use the ChunkLoader to load data instead of loading the data directly. Image layers will not call np.asarray() in the GUI thread. The ChunkLoader will call np.asarray() in a worker thread. That means any IO or computation done as part of the load will not block the GUI thread. Set NAPARI_ASYNC=1 to turn on async loading with default settings. Octree Rendering ---------------- Image layers use an octree for rendering. The octree organizes the image into chunks/tiles. Only a subset of those chunks/tiles are loaded and drawn at a time. Octree rendering is a work in progress. Enabled one of two ways: 1) Set NAPARI_OCTREE=1 to enabled octree rendering with defaults. 2) Set NAPARI_OCTREE=/tmp/config.json use a config file. See napari/utils/_octree.py for the config file format. Shared Memory Server -------------------- Experimental shared memory service. Only enabled if NAPARI_MON is set to the path of a config file. See this PR for more info: https://github.com/napari/napari/pull/1909. """ # Config for async/octree. If octree_config['octree']['enabled'] is False # only async is enabled, not the octree. octree_config = get_octree_config() # Shorthand for async loading with or without an octree. async_loading = octree_config is not None # Shorthand for async with an octree. async_octree = octree_config and octree_config['octree']['enabled'] # Shared Memory Server monitor = _set("NAPARI_MON") """ Other Config Options """ # Added this temporarily for octree debugging. The welcome visual causes # breakpoints to hit in image visual code. It's easier if we don't show it. allow_welcome_visual = True napari-0.5.0a1/napari/utils/events/000077500000000000000000000000001437041365600171375ustar00rootroot00000000000000napari-0.5.0a1/napari/utils/events/__init__.py000066400000000000000000000021541437041365600212520ustar00rootroot00000000000000from napari.utils.events.event import ( # isort:skip EmitterGroup, Event, EventEmitter, set_event_tracing_enabled, ) from napari.utils.events.containers._evented_dict import EventedDict from napari.utils.events.containers._evented_list import EventedList from napari.utils.events.containers._nested_list import NestableEventedList from napari.utils.events.containers._selectable_list import ( SelectableEventedList, ) from napari.utils.events.containers._selection import Selection from napari.utils.events.containers._set import EventedSet from napari.utils.events.containers._typed import TypedMutableSequence from napari.utils.events.event_utils import disconnect_events from napari.utils.events.evented_model import EventedModel from napari.utils.events.types import SupportsEvents __all__ = [ 'disconnect_events', 'EmitterGroup', 'Event', 'EventedDict', 'EventedList', 'EventedModel', 'EventedSet', 'EventEmitter', 'NestableEventedList', 'SelectableEventedList', 'Selection', 'SupportsEvents', 'TypedMutableSequence', 'set_event_tracing_enabled', ] napari-0.5.0a1/napari/utils/events/_tests/000077500000000000000000000000001437041365600204405ustar00rootroot00000000000000napari-0.5.0a1/napari/utils/events/_tests/test_event_emitter.py000066400000000000000000000155501437041365600247310ustar00rootroot00000000000000import weakref from functools import partial import pytest from napari.utils.events import EventEmitter def test_event_blocker_count_none(): """Test event emitter block counter with no emission.""" e = EventEmitter(type="test") with e.blocker() as block: pass assert block.count == 0 def test_event_blocker_count(): """Test event emitter block counter with emission.""" e = EventEmitter(type="test") with e.blocker() as block: e() e() e() assert block.count == 3 def test_weakref_event_emitter(): """ We are testing that an event blocker does not keep hard reference to the object we are blocking, especially if it's a bound method. The reason it used to keep references is to get the count of how many time a callback was blocked, but if the object does not exists, then the bound method does not and thus there is no way to ask for it's count. so we can keep only weak refs. """ e = EventEmitter(type='test_weak') class Obj: def cb(self): pass o = Obj() ref_o = weakref.ref(o) e.connect(o.cb) # with e.blocker(o.cb): e() del o assert ref_o() is None @pytest.mark.parametrize('disconnect_and_should_be_none', [True, False]) def test_weakref_event_emitter_cb(disconnect_and_should_be_none): """ Note that as above but with pure callback, We keep a reference to it, the reason is that unlike with bound method, the callback may be a closure and may not stick around. We thus expect the wekref to be None only if explicitely disconnected """ e = EventEmitter(type='test_weak') def cb(self): pass ref_cb = weakref.ref(cb) e.connect(cb) with e.blocker(cb): e() if disconnect_and_should_be_none: e.disconnect(cb) del cb assert ref_cb() is None else: del cb assert ref_cb() is not None def test_error_on_connect(): """Check that connections happen correctly even on decorated methods. Some decorators will alter method.__name__, so that obj.method will not be equal to getattr(obj, obj.method.__name__). We check here that event binding will be correct even in these situations. """ def rename(newname): def decorator(f): f.__name__ = newname return f return decorator class Test: def __init__(self) -> None: self.m1, self.m2, self.m4 = 0, 0, 0 @rename("nonexist") def meth1(self, _event): self.m1 += 1 @rename("meth1") def meth2(self, _event): self.m2 += 1 def meth3(self): pass def meth4(self, _event): self.m4 += 1 t = Test() e = EventEmitter(type="test") e.connect(t.meth1) e() assert (t.m1, t.m2) == (1, 0) e.connect(t.meth2) e() assert (t.m1, t.m2) == (2, 1) meth = t.meth3 t.meth3 = "aaaa" with pytest.raises(RuntimeError): e.connect(meth) e.connect(t.meth4) assert t.m4 == 0 e() assert t.m4 == 1 t.meth4 = None with pytest.warns(RuntimeWarning, match="Problem with function"): e() assert t.m4 == 1 def test_event_order_func(): res_li = [] def fun1(): res_li.append(1) def fun2(val): res_li.append(val) def fun3(): res_li.append(3) def fun4(): res_li.append(4) def fun5(val): res_li.append(val) def fun6(val): res_li.append(val) fun1.__module__ = "napari.test.sample" fun3.__module__ = "napari.test.sample" fun5.__module__ = "napari.test.sample" e = EventEmitter(type="test") e.connect(fun1) e.connect(partial(fun2, val=2)) e() assert res_li == [1, 2] res_li = [] e.connect(fun3) e() assert res_li == [3, 1, 2] res_li = [] e.connect(fun4) e() assert res_li == [3, 1, 4, 2] res_li = [] e.connect(partial(fun5, val=5), position="last") e() assert res_li == [3, 1, 5, 4, 2] res_li = [] e.connect(partial(fun6, val=6), position="last") e() assert res_li == [3, 1, 5, 4, 2, 6] def test_event_order_methods(): res_li = [] class Test: def fun1(self): res_li.append(1) def fun2(self): res_li.append(2) class Test2: def fun3(self): res_li.append(3) def fun4(self): res_li.append(4) Test.__module__ = "napari.test.sample" t1 = Test() t2 = Test2() e = EventEmitter(type="test") e.connect(t1.fun1) e.connect(t2.fun3) e() assert res_li == [1, 3] res_li = [] e.connect(t1.fun2) e.connect(t2.fun4) e() assert res_li == [2, 1, 4, 3] def test_no_event_arg(): class TestOb: def __init__(self) -> None: self.count = 0 def fun(self): self.count += 1 count = [0] def simple_fun(): count[0] += 1 t = TestOb() e = EventEmitter(type="test") e.connect(t.fun) e.connect(simple_fun) e() assert t.count == 1 assert count[0] == 1 def test_to_many_positional(): class TestOb: def fun(self, a, b, c=1): pass def simple_fun(a, b): pass t = TestOb() e = EventEmitter(type="test") with pytest.raises(RuntimeError): e.connect(t.fun) with pytest.raises(RuntimeError): e.connect(simple_fun) def test_disconnect_object(): count_list = [] def fun1(): count_list.append(1) class TestOb: call_list_1 = [] call_list_2 = [] def fun1(self): self.call_list_1.append(1) def fun2(self): self.call_list_2.append(1) t = TestOb() e = EventEmitter(type="test") e.connect(t.fun1) e.connect(t.fun2) e.connect(fun1) e() assert t.call_list_1 == [1] assert t.call_list_2 == [1] assert count_list == [1] e.disconnect(t) e() assert t.call_list_1 == [1] assert t.call_list_2 == [1] assert count_list == [1, 1] def test_weakref_disconnect(): class TestOb: call_list_1 = [] def fun1(self): self.call_list_1.append(1) def fun2(self, event): self.call_list_1.append(2) t = TestOb() e = EventEmitter(type="test") e.connect(t.fun1) e() assert t.call_list_1 == [1] e.disconnect((weakref.ref(t), "fun1")) e() assert t.call_list_1 == [1] e.connect(t.fun2) e() assert t.call_list_1 == [1, 2] def test_none_disconnect(): count_list = [] def fun1(): count_list.append(1) def fun2(event): count_list.append(2) e = EventEmitter(type="test") e.connect(fun1) e() assert count_list == [1] e.disconnect(None) e() assert count_list == [1] e.connect(fun2) e() assert count_list == [1, 2] napari-0.5.0a1/napari/utils/events/_tests/test_evented_dict.py000066400000000000000000000057141437041365600245150ustar00rootroot00000000000000from unittest.mock import Mock import pytest from napari.utils.events import EmitterGroup from napari.utils.events.containers import EventedDict @pytest.fixture def regular_dict(): return {"A": 0, "B": 1, "C": 2} @pytest.fixture(params=[EventedDict]) def test_dict(request, regular_dict): test_dict = request.param(regular_dict) test_dict.events = Mock(wraps=test_dict.events) return test_dict @pytest.mark.parametrize( 'meth', [ # METHOD, ARGS, EXPECTED EVENTS # primary interface ('__getitem__', ("A",), ()), # read ('__setitem__', ("A", 3), ('changed',)), # update ('__setitem__', ("D", 3), ('adding', 'added')), # add new entry ('__delitem__', ("A",), ('removing', 'removed')), # delete # inherited interface ('key', (3,), ()), ('clear', (), ('removing', 'removed') * 3), ('pop', ("B",), ('removing', 'removed')), ], ids=lambda x: x[0], ) def test_dict_interface_parity(test_dict, regular_dict, meth): method_name, args, expected = meth test_dict_method = getattr(test_dict, method_name) assert test_dict == regular_dict if hasattr(regular_dict, method_name): regular_dict_method = getattr(regular_dict, method_name) assert test_dict_method(*args) == regular_dict_method(*args) assert test_dict == regular_dict else: test_dict_method(*args) # smoke test for c, expect in zip(test_dict.events.call_args_list, expected): event = c.args[0] assert event.type == expect def test_copy(test_dict, regular_dict): """Copying an evented dict should return a same-class evented dict.""" new_test = test_dict.copy() new_reg = regular_dict.copy() assert id(new_test) != id(test_dict) assert new_test == test_dict assert tuple(new_test) == tuple(test_dict) == tuple(new_reg) test_dict.events.assert_not_called() class E: def __init__(self) -> None: self.events = EmitterGroup(test=None) def test_child_events(): """Test that evented dicts bubble child events.""" # create a random object that emits events e_obj = E() root = EventedDict() observed = [] root.events.connect(lambda e: observed.append(e)) root["A"] = e_obj e_obj.events.test(value="hi") obs = [(e.type, e.key, getattr(e, 'value', None)) for e in observed] expected = [ ('adding', "A", None), # before we adding b into root ('added', "A", e_obj), # after b was added into root ('test', "A", 'hi'), # when e_obj emitted an event called "test" ] for o, e in zip(obs, expected): assert o == e def test_evented_dict_subclass(): """Test that multiple inheritance maintains events from superclass.""" class A: events = EmitterGroup(boom=None) class B(A, EventedDict): pass dct = B({"A": 1, "B": 2}) assert hasattr(dct, 'events') assert 'boom' in dct.events.emitters assert dct == {"A": 1, "B": 2} napari-0.5.0a1/napari/utils/events/_tests/test_evented_list.py000066400000000000000000000375321437041365600245500ustar00rootroot00000000000000from collections.abc import MutableSequence from unittest.mock import Mock, call import numpy as np import pytest from napari.utils.events import EmitterGroup, EventedList, NestableEventedList @pytest.fixture def regular_list(): return list(range(5)) @pytest.fixture(params=[EventedList, NestableEventedList]) def test_list(request, regular_list): test_list = request.param(regular_list) test_list.events = Mock(wraps=test_list.events) return test_list @pytest.mark.parametrize( 'meth', [ # METHOD, ARGS, EXPECTED EVENTS # primary interface ('insert', (2, 10), ('inserting', 'inserted')), # create ('__getitem__', (2,), ()), # read ('__setitem__', (2, 3), ('changed',)), # update ('__setitem__', (slice(2), [1, 2]), ('changed',)), # update slice ('__setitem__', (slice(2, 2), [1, 2]), ('changed',)), # update slice ('__delitem__', (2,), ('removing', 'removed')), # delete ( '__delitem__', (slice(2),), ('removing', 'removed') * 2, ), ('__delitem__', (slice(0, 0),), ('removing', 'removed')), ( '__delitem__', (slice(-3),), ('removing', 'removed') * 2, ), ( '__delitem__', (slice(-2, None),), ('removing', 'removed') * 2, ), # inherited interface ('append', (3,), ('inserting', 'inserted')), ('clear', (), ('removing', 'removed') * 5), ('count', (3,), ()), ('extend', ([7, 8, 9],), ('inserting', 'inserted') * 3), ('index', (3,), ()), ('pop', (-2,), ('removing', 'removed')), ('remove', (3,), ('removing', 'removed')), ('reverse', (), ('reordered',)), ('__add__', ([7, 8, 9],), ()), ('__iadd__', ([7, 9],), ('inserting', 'inserted') * 2), ('__radd__', ([7, 9],), ('inserting', 'inserted') * 2), # sort? ], ids=lambda x: x[0], ) def test_list_interface_parity(test_list, regular_list, meth): method_name, args, expected = meth test_list_method = getattr(test_list, method_name) assert tuple(test_list) == tuple(regular_list) if hasattr(regular_list, method_name): regular_list_method = getattr(regular_list, method_name) assert test_list_method(*args) == regular_list_method(*args) assert tuple(test_list) == tuple(regular_list) else: test_list_method(*args) # smoke test for c, expect in zip(test_list.events.call_args_list, expected): event = c.args[0] assert event.type == expect def test_hash(test_list): assert id(test_list) == hash(test_list) def test_list_interface_exceptions(test_list): bad_index = {'a': 'dict'} with pytest.raises(TypeError): test_list[bad_index] with pytest.raises(TypeError): test_list[bad_index] = 1 with pytest.raises(TypeError): del test_list[bad_index] with pytest.raises(TypeError): test_list.insert([bad_index], 0) def test_copy(test_list, regular_list): """Copying an evented list should return a same-class evented list.""" new_test = test_list.copy() new_reg = regular_list.copy() assert id(new_test) != id(test_list) assert new_test == test_list assert tuple(new_test) == tuple(test_list) == tuple(new_reg) test_list.events.assert_not_called() def test_move(test_list): """Test the that we can move objects with the move method""" test_list.events = Mock(wraps=test_list.events) def _fail(): raise AssertionError("unexpected event called") test_list.events.removing.connect(_fail) test_list.events.removed.connect(_fail) test_list.events.inserting.connect(_fail) test_list.events.inserted.connect(_fail) before = list(test_list) assert before == [0, 1, 2, 3, 4] # from fixture # pop the object at 0 and insert at current position 3 test_list.move(0, 3) expectation = [1, 2, 0, 3, 4] assert test_list != before assert test_list == expectation test_list.events.moving.assert_called_once() test_list.events.moved.assert_called_once() test_list.events.reordered.assert_called_with(value=expectation) # move the other way # pop the object at 3 and insert at current position 0 assert test_list == [1, 2, 0, 3, 4] test_list.move(3, 0) assert test_list == [3, 1, 2, 0, 4] # negative index destination test_list.move(1, -2) assert test_list == [3, 2, 0, 1, 4] BASIC_INDICES = [ ((2,), 0, [2, 0, 1, 3, 4, 5, 6, 7]), # move single item ([0, 2, 3], 6, [1, 4, 5, 0, 2, 3, 6, 7]), # move back ([4, 7], 1, [0, 4, 7, 1, 2, 3, 5, 6]), # move forward ([0, 5, 6], 3, [1, 2, 0, 5, 6, 3, 4, 7]), # move in between ([1, 3, 5, 7], 3, [0, 2, 1, 3, 5, 7, 4, 6]), # same as above ([0, 2, 3, 2, 3], 6, [1, 4, 5, 0, 2, 3, 6, 7]), # strip dupe indices ] OTHER_INDICES = [ ([7, 4], 1, [0, 7, 4, 1, 2, 3, 5, 6]), # move forward reorder ([3, 0, 2], 6, [1, 4, 5, 3, 0, 2, 6, 7]), # move back reorder ((2, 4), -2, [0, 1, 3, 5, 6, 2, 4, 7]), # negative indexing ([slice(None, 3)], 6, [3, 4, 5, 0, 1, 2, 6, 7]), # move slice back ([slice(5, 8)], 2, [0, 1, 5, 6, 7, 2, 3, 4]), # move slice forward ([slice(1, 8, 2)], 3, [0, 2, 1, 3, 5, 7, 4, 6]), # move slice between ([slice(None, 8, 3)], 4, [1, 2, 0, 3, 6, 4, 5, 7]), ([slice(None, 8, 3), 0, 3, 6], 4, [1, 2, 0, 3, 6, 4, 5, 7]), ] MOVING_INDICES = BASIC_INDICES + OTHER_INDICES @pytest.mark.parametrize('sources,dest,expectation', MOVING_INDICES) def test_move_multiple(sources, dest, expectation): """Test the that we can move objects with the move method""" el = EventedList(range(8)) el.events = Mock(wraps=el.events) assert el == [0, 1, 2, 3, 4, 5, 6, 7] def _fail(): raise AssertionError("unexpected event called") el.events.removing.connect(_fail) el.events.removed.connect(_fail) el.events.inserting.connect(_fail) el.events.inserted.connect(_fail) el.move_multiple(sources, dest) assert el == expectation el.events.moving.assert_called() el.events.moved.assert_called() el.events.reordered.assert_called_with(value=expectation) def test_move_multiple_mimics_slice_reorder(): """Test the that move_multiple provides the same result as slice insertion.""" data = list(range(8)) el = EventedList(data) el.events = Mock(wraps=el.events) assert el == data new_order = [1, 5, 3, 4, 6, 7, 2, 0] # this syntax el.move_multiple(new_order, 0) # is the same as this syntax data[:] = [data[i] for i in new_order] assert el == new_order assert el == data assert el.events.moving.call_args_list == [ call(index=1, new_index=0), call(index=5, new_index=1), call(index=4, new_index=2), call(index=5, new_index=3), call(index=6, new_index=4), call(index=7, new_index=5), call(index=7, new_index=6), ] assert el.events.moved.call_args_list == [ call(index=1, new_index=0, value=1), call(index=5, new_index=1, value=5), call(index=4, new_index=2, value=3), call(index=5, new_index=3, value=4), call(index=6, new_index=4, value=6), call(index=7, new_index=5, value=7), call(index=7, new_index=6, value=2), ] el.events.reordered.assert_called_with(value=new_order) # move_multiple also works omitting the insertion index el[:] = list(range(8)) el.move_multiple(new_order) assert el == new_order def test_slice(test_list, regular_list): """Slicing an evented list should return a same-class evented list.""" test_slice = test_list[1:3] regular_slice = regular_list[1:3] assert tuple(test_slice) == tuple(regular_slice) assert isinstance(test_slice, test_list.__class__) NEST = [0, [10, [110, [1110, 1111, 1112], 112], 12], 2] def flatten(container): """Flatten arbitrarily nested list. Examples -------- >>> a = [1, [2, [3], 4], 5] >>> list(flatten(a)) [1, 2, 3, 4, 5] """ for i in container: if isinstance(i, MutableSequence): yield from flatten(i) else: yield i def test_nested_indexing(): """test that we can index a nested list with nl[1, 2, 3] syntax.""" ne_list = NestableEventedList(NEST) # 110 -> '110' -> (1, 1, 0) indices = [tuple(int(x) for x in str(n)) for n in flatten(NEST)] for index in indices: assert ne_list[index] == int("".join(map(str, index))) assert ne_list.has_index(1) assert ne_list.has_index((1,)) assert ne_list.has_index((1, 2)) assert ne_list.has_index((1, 1, 2)) assert not ne_list.has_index((1, 1, 3)) assert not ne_list.has_index((1, 1, 2, 3, 4)) assert not ne_list.has_index(100) # indices in NEST that are themselves lists @pytest.mark.parametrize( 'group_index', [(), (1,), (1, 1), (1, 1, 1)], ids=lambda x: str(x) ) @pytest.mark.parametrize( 'meth', [ # METHOD, ARGS, EXPECTED EVENTS # primary interface ('insert', (0, 10), ('inserting', 'inserted')), ('__getitem__', (2,), ()), # read ('__setitem__', (2, 3), ('changed',)), # update ('__delitem__', ((),), ('removing', 'removed')), # delete ('__delitem__', ((1,),), ('removing', 'removed')), # delete ('__delitem__', (2,), ('removing', 'removed')), # delete ( '__delitem__', (slice(2),), ('removing', 'removed') * 2, ), ( '__delitem__', (slice(-1),), ('removing', 'removed') * 2, ), ( '__delitem__', (slice(-2, None),), ('removing', 'removed') * 2, ), # inherited interface ('append', (3,), ('inserting', 'inserted')), ('clear', (), ('removing', 'removed') * 3), ('count', (110,), ()), ('extend', ([7, 8, 9],), ('inserting', 'inserted') * 3), ('index', (110,), ()), ('pop', (-1,), ('removing', 'removed')), ('__add__', ([7, 8, 9],), ()), ('__iadd__', ([7, 9],), ('inserting', 'inserted') * 2), ], ids=lambda x: x[0], ) def test_nested_events(meth, group_index): ne_list = NestableEventedList(NEST) ne_list.events = Mock(wraps=ne_list.events) method_name, args, expected_events = meth method = getattr(ne_list[group_index], method_name) if method_name == 'index' and group_index == (1, 1, 1): # the expected value of '110' (in the pytest parameters) # is not present in any child of ne_list[1, 1, 1] with pytest.raises(ValueError): method(*args) else: # make sure we can call the method without error method(*args) # make sure the correct event type and number was emitted for c, expected in zip(ne_list.events.call_args_list, expected_events): event = c.args[0] assert event.type == expected if group_index == (): # in the root group, the index will be an int relative to root assert isinstance(event.index, int) else: assert event.index[:-1] == group_index def test_setting_nested_slice(): ne_list = NestableEventedList(NEST) ne_list[(1, 1, 1, slice(2))] = [9, 10] assert tuple(ne_list[1, 1, 1]) == (9, 10, 1112) NESTED_POS_INDICES = [ # indices 2 (2, 1) # original = [0, 1, [(2,0), [(2,1,0), (2,1,1)], (2,2)], 3, 4] [(), (), [0, 1, [20, [210, 211], 22], 3, 4]], # no-op [((2, 0), (2, 1, 1), (3,)), (1), [0, 20, 211, 3, 1, [[210], 22], 4]], [((2, 0), (2, 1, 1), (3,)), (2), [0, 1, 20, 211, 3, [[210], 22], 4]], [((2, 0), (2, 1, 1), (3,)), (3), [0, 1, [[210], 22], 20, 211, 3, 4]], [((2, 1, 1), (3,)), (2, 0), [0, 1, [211, 3, 20, [210], 22], 4]], [((2, 1, 1),), (2, 1, 0), [0, 1, [20, [211, 210], 22], 3, 4]], [((2, 1, 1), (3,)), (2, 1, 0), [0, 1, [20, [211, 3, 210], 22], 4]], [((2, 1, 1), (3,)), (2, 1, 1), [0, 1, [20, [210, 211, 3], 22], 4]], [((2, 1, 1),), (0,), [211, 0, 1, [20, [210], 22], 3, 4]], [((2, 1, 1),), (), [0, 1, [20, [210], 22], 3, 4, 211]], ] NESTED_NEG_INDICES = [ [((2, 0), (2, 1, 1), (3,)), (-1), [0, 1, [[210], 22], 4, 20, 211, 3]], [((2, 0), (2, 1, 1), (3,)), (-2), [0, 1, [[210], 22], 20, 211, 3, 4]], [((2, 0), (2, 1, 1), (3,)), (-4), [0, 1, 20, 211, 3, [[210], 22], 4]], [((2, 1, 1), (3,)), (2, -1), [0, 1, [20, [210], 22, 211, 3], 4]], [((2, 1, 1), (3,)), (2, -2), [0, 1, [20, [210], 211, 3, 22], 4]], ] NESTED_INDICES = NESTED_POS_INDICES + NESTED_NEG_INDICES # type: ignore @pytest.mark.parametrize('sources, dest, expectation', NESTED_INDICES) def test_nested_move_multiple(sources, dest, expectation): """Test that moving multiple indices works and emits right events.""" ne_list = NestableEventedList([0, 1, [20, [210, 211], 22], 3, 4]) ne_list.events = Mock(wraps=ne_list.events) ne_list.move_multiple(sources, dest) ne_list.events.reordered.assert_called_with(value=expectation) class E: def __init__(self) -> None: self.events = EmitterGroup(test=None) def test_child_events(): """Test that evented lists bubble child events.""" # create a random object that emits events e_obj = E() # and two nestable evented lists root = EventedList() observed = [] root.events.connect(lambda e: observed.append(e)) root.append(e_obj) e_obj.events.test(value="hi") obs = [(e.type, e.index, getattr(e, 'value', None)) for e in observed] expected = [ ('inserting', 0, None), # before we inserted b into root ('inserted', 0, e_obj), # after b was inserted into root ('test', 0, 'hi'), # when e_obj emitted an event called "test" ] for o, e in zip(obs, expected): assert o == e def test_nested_child_events(): """Test that nested lists bubbles nested child events. If you add an object that implements the ``SupportsEvents`` Protocol (i.e. has an attribute ``events`` that is an ``EmitterGroup``), to a ``NestableEventedList``, then the parent container will re-emit those events (and this works recursively up to the root container). The index/indices of each child(ren) that bubbled the event will be added to the event. See docstring of :ref:`NestableEventedList` for more info. """ # create a random object that emits events e_obj = E() # and two nestable evented lists root = NestableEventedList() b = NestableEventedList() # collect all events emitted by the root list observed = [] root.events.connect(lambda e: observed.append(e)) # now append a list to root root.append(b) # and append the event-emitter object to the nested list b.append(e_obj) # then have the deeply nested event-emitter actually emit an event e_obj.events.test(value="hi") # look at the (type, index, and value) of all of the events emitted by root # and make sure they match expectations obs = [(e.type, e.index, getattr(e, 'value', None)) for e in observed] expected = [ ('inserting', 0, None), # before we inserted b into root ('inserted', 0, b), # after b was inserted into root ('inserting', (0, 0), None), # before we inserted e_obj into b ('inserted', (0, 0), e_obj), # after e_obj was inserted into b ('test', (0, 0), 'hi'), # when e_obj emitted an event called "test" ] for o, e in zip(obs, expected): assert o == e def test_evented_list_subclass(): """Test that multiple inheritance maintains events from superclass.""" class A: events = EmitterGroup(boom=None) class B(A, EventedList): pass lst = B([1, 2]) assert hasattr(lst, 'events') assert 'boom' in lst.events.emitters assert lst == [1, 2] def test_array_like_setitem(): """Test that EventedList.__setitem__ works for array-like items""" array = np.array((10, 10)) evented_list = EventedList([array]) evented_list[0] = array napari-0.5.0a1/napari/utils/events/_tests/test_evented_model.py000066400000000000000000000316521437041365600246720ustar00rootroot00000000000000import inspect from enum import auto from typing import ClassVar, List, Protocol, Sequence, Union, runtime_checkable from unittest.mock import Mock import dask.array as da import numpy as np import pytest from dask import delayed from dask.delayed import Delayed from pydantic import Field from napari.utils.events import EmitterGroup, EventedModel from napari.utils.events.custom_types import Array from napari.utils.misc import StringEnum def test_creating_empty_evented_model(): """Test creating an empty evented pydantic model.""" model = EventedModel() assert model is not None assert model.events is not None def test_evented_model(): """Test creating an evented pydantic model.""" class User(EventedModel): """Demo evented model. Parameters ---------- id : int User id. name : str, optional User name. """ id: int name: str = 'Alex' age: ClassVar[int] = 100 user = User(id=0) # test basic functionality assert user.id == 0 assert user.name == 'Alex' user.id = 2 assert user.id == 2 # test event system assert isinstance(user.events, EmitterGroup) assert 'id' in user.events assert 'name' in user.events # ClassVars are excluded from events assert 'age' not in user.events # mocking EventEmitters to spy on events user.events.id = Mock(user.events.id) user.events.name = Mock(user.events.name) # setting an attribute should, by default, emit an event with the value user.id = 4 user.events.id.assert_called_with(value=4) user.events.name.assert_not_called() # and event should only be emitted when the value has changed. user.events.id.reset_mock() user.id = 4 user.events.id.assert_not_called() user.events.name.assert_not_called() def test_evented_model_with_array(): """Test creating an evented pydantic model with an array.""" def make_array(): return np.array([[4, 3]]) class Model(EventedModel): """Demo evented model.""" int_values: Array[int] any_values: Array shaped1_values: Array[float, (-1,)] shaped2_values: Array[int, (1, 2)] = Field(default_factory=make_array) shaped3_values: Array[float, (4, -1)] shaped4_values: Array[float, (-1, 4)] model = Model( int_values=[1, 2.2, 3], any_values=[1, 2.2], shaped1_values=np.array([1.1, 2.0]), shaped3_values=np.array([1.1, 2.0, 2.0, 3.0]), shaped4_values=np.array([1.1, 2.0, 2.0, 3.0]), ) # test basic functionality np.testing.assert_almost_equal(model.int_values, np.array([1, 2, 3])) np.testing.assert_almost_equal(model.any_values, np.array([1, 2.2])) np.testing.assert_almost_equal(model.shaped1_values, np.array([1.1, 2.0])) np.testing.assert_almost_equal(model.shaped2_values, np.array([[4, 3]])) np.testing.assert_almost_equal( model.shaped3_values, np.array([[1.1, 2.0, 2.0, 3.0]]).T ) np.testing.assert_almost_equal( model.shaped4_values, np.array([[1.1, 2.0, 2.0, 3.0]]) ) # try changing shape to something impossible to correctly reshape with pytest.raises(ValueError): model.shaped2_values = [1] def test_evented_model_array_updates(): """Test updating an evented pydantic model with an array.""" class Model(EventedModel): """Demo evented model.""" values: Array[int] model = Model(values=[1, 2, 3]) # Mock events model.events.values = Mock(model.events.values) np.testing.assert_almost_equal(model.values, np.array([1, 2, 3])) # Updating with new data model.values = [1, 2, 4] assert model.events.values.call_count == 1 np.testing.assert_almost_equal( model.events.values.call_args[1]['value'], np.array([1, 2, 4]) ) model.events.values.reset_mock() # Updating with same data, no event should be emitted model.values = [1, 2, 4] model.events.values.assert_not_called() def test_evented_model_array_equality(): """Test checking equality with an evented model with custom array.""" class Model(EventedModel): """Demo evented model.""" values: Array[int] model1 = Model(values=[1, 2, 3]) model2 = Model(values=[1, 5, 6]) assert model1 == model1 assert model1 != model2 model2.values = [1, 2, 3] assert model1 == model2 def test_evented_model_np_array_equality(): """Test checking equality with an evented model with direct numpy.""" class Model(EventedModel): values: np.ndarray model1 = Model(values=np.array([1, 2, 3])) model2 = Model(values=np.array([1, 5, 6])) assert model1 == model1 assert model1 != model2 model2.values = np.array([1, 2, 3]) assert model1 == model2 def test_evented_model_da_array_equality(): """Test checking equality with an evented model with direct dask.""" class Model(EventedModel): values: da.Array r = da.ones((64, 64)) model1 = Model(values=r) model2 = Model(values=da.ones((64, 64))) assert model1 == model1 # dask arrays will only evaluate as equal if they are the same object. assert model1 != model2 model2.values = r assert model1 == model2 def test_values_updated(): class User(EventedModel): """Demo evented model. Parameters ---------- id : int User id. name : str, optional User name. """ id: int name: str = 'A' age: ClassVar[int] = 100 user1 = User(id=0) user2 = User(id=1, name='K') # Add mocks user1_events = Mock(user1.events) user1.events.connect(user1_events) user1.events.id = Mock(user1.events.id) user2.events.id = Mock(user2.events.id) # Check user1 and user2 dicts assert user1.dict() == {'id': 0, 'name': 'A'} assert user2.dict() == {'id': 1, 'name': 'K'} # Update user1 from user2 user1.update(user2) assert user1.dict() == {'id': 1, 'name': 'K'} user1.events.id.assert_called_with(value=1) user2.events.id.assert_not_called() assert user1_events.call_count == 1 user1.events.id.reset_mock() user2.events.id.reset_mock() user1_events.reset_mock() # Update user1 from user2 again, no event emission expected user1.update(user2) assert user1.dict() == {'id': 1, 'name': 'K'} user1.events.id.assert_not_called() user2.events.id.assert_not_called() assert user1_events.call_count == 0 def test_update_with_inner_model_union(): class Inner(EventedModel): w: str class AltInner(EventedModel): x: str class Outer(EventedModel): y: int z: Union[Inner, AltInner] original = Outer(y=1, z=Inner(w='a')) updated = Outer(y=2, z=AltInner(x='b')) original.update(updated, recurse=False) assert original == updated def test_update_with_inner_model_protocol(): @runtime_checkable class InnerProtocol(Protocol): def string(self) -> str: ... # Protocol fields are not successfully set without explicit validation. @classmethod def __get_validators__(cls): yield cls.validate @classmethod def validate(cls, v): return v class Inner(EventedModel): w: str def string(self) -> str: return self.w class AltInner(EventedModel): x: str def string(self) -> str: return self.x class Outer(EventedModel): y: int z: InnerProtocol original = Outer(y=1, z=Inner(w='a')) updated = Outer(y=2, z=AltInner(x='b')) original.update(updated, recurse=False) assert original == updated def test_evented_model_signature(): class T(EventedModel): x: int y: str = 'yyy' z = b'zzz' assert isinstance(T.__signature__, inspect.Signature) sig = inspect.signature(T) assert str(sig) == "(*, x: int, y: str = 'yyy', z: bytes = b'zzz') -> None" class MyObj: def __init__(self, a: int, b: str) -> None: self.a = a self.b = b @classmethod def __get_validators__(cls): yield cls.validate_type @classmethod def validate_type(cls, val): # turn a generic dict into object if isinstance(val, dict): a = val.get('a') b = val.get('b') elif isinstance(val, MyObj): return val # perform additional validation here return cls(a, b) def __eq__(self, other): return self.__dict__ == other.__dict__ def _json_encode(self): return self.__dict__ def test_evented_model_serialization(): class Model(EventedModel): """Demo evented model.""" obj: MyObj shaped: Array[float, (-1,)] m = Model(obj=MyObj(1, 'hi'), shaped=[1, 2, 3]) raw = m.json() assert raw == '{"obj": {"a": 1, "b": "hi"}, "shaped": [1.0, 2.0, 3.0]}' deserialized = Model.parse_raw(raw) assert deserialized == m def test_nested_evented_model_serialization(): """Test that encoders on nested sub-models can be used by top model.""" class NestedModel(EventedModel): obj: MyObj class Model(EventedModel): nest: NestedModel m = Model(nest={'obj': {"a": 1, "b": "hi"}}) raw = m.json() assert raw == r'{"nest": {"obj": {"a": 1, "b": "hi"}}}' deserialized = Model.parse_raw(raw) assert deserialized == m def test_evented_model_dask_delayed(): """Test that evented models work with dask delayed objects""" class MyObject(EventedModel): attribute: Delayed @delayed def my_function(): pass o1 = MyObject(attribute=my_function) # check that equality checking works as expected assert o1 == o1 # The following tests ensure that StringEnum field values can be # compared against the enum constants and not their string value. # For more context see the GitHub issue: # https://github.com/napari/napari/issues/3062 class SomeStringEnum(StringEnum): NONE = auto() SOME_VALUE = auto() ANOTHER_VALUE = auto() class ModelWithStringEnum(EventedModel): enum_field: SomeStringEnum = SomeStringEnum.NONE def test_evented_model_with_string_enum_default(): model = ModelWithStringEnum() assert model.enum_field == SomeStringEnum.NONE def test_evented_model_with_string_enum_parameter(): model = ModelWithStringEnum(enum_field=SomeStringEnum.SOME_VALUE) assert model.enum_field == SomeStringEnum.SOME_VALUE def test_evented_model_with_string_enum_parameter_as_str(): model = ModelWithStringEnum(enum_field='some_value') assert model.enum_field == SomeStringEnum.SOME_VALUE def test_evented_model_with_string_enum_setter(): model = ModelWithStringEnum() model.enum_field = SomeStringEnum.SOME_VALUE assert model.enum_field == SomeStringEnum.SOME_VALUE def test_evented_model_with_string_enum_setter_as_str(): model = ModelWithStringEnum() model.enum_field = 'some_value' assert model.enum_field == SomeStringEnum.SOME_VALUE def test_evented_model_with_string_enum_parse_raw(): model = ModelWithStringEnum(enum_field=SomeStringEnum.SOME_VALUE) deserialized_model = ModelWithStringEnum.parse_raw(model.json()) assert deserialized_model.enum_field == model.enum_field def test_evented_model_with_string_enum_parse_obj(): model = ModelWithStringEnum(enum_field=SomeStringEnum.SOME_VALUE) deserialized_model = ModelWithStringEnum.parse_obj(model.dict()) assert deserialized_model.enum_field == model.enum_field class T(EventedModel): a: int = 1 b: int = 1 @property def c(self) -> List[int]: return [self.a, self.b] @c.setter def c(self, val: Sequence[int]): self.a, self.b = val def test_evented_model_with_property_setters(): t = T() assert list(T.__property_setters__) == ['c'] # the metaclass should have figured out that both a and b affect c assert T.__field_dependents__ == {'a': {'c'}, 'b': {'c'}} # all the fields and properties behave as expected assert t.c == [1, 1] t.a = 4 assert t.c == [4, 1] t.c = [2, 3] assert t.c == [2, 3] assert t.a == 2 assert t.b == 3 def test_evented_model_with_property_setters_events(): t = T() assert 'c' in t.events # the setter has an event t.events.a = Mock(t.events.a) t.events.b = Mock(t.events.b) t.events.c = Mock(t.events.c) # setting t.c emits events for all three a, b, and c t.c = [10, 20] t.events.a.assert_called_with(value=10) t.events.b.assert_called_with(value=20) t.events.c.assert_called_with(value=[10, 20]) assert t.a == 10 assert t.b == 20 t.events.a.reset_mock() t.events.b.reset_mock() t.events.c.reset_mock() # setting t.a emits events for a and c, but not b # this is because we declared c to be dependent on ['a', 'b'] t.a = 5 t.events.a.assert_called_with(value=5) t.events.c.assert_called_with(value=[5, 20]) t.events.b.assert_not_called() assert t.c == [5, 20] napari-0.5.0a1/napari/utils/events/_tests/test_evented_set.py000066400000000000000000000062041437041365600243600ustar00rootroot00000000000000from unittest.mock import Mock, call import pytest from napari.utils.events import EventedSet @pytest.fixture def regular_set(): return set(range(5)) @pytest.fixture def test_set(request, regular_set): test_set = EventedSet(regular_set) test_set.events = Mock(wraps=test_set.events) return test_set @pytest.mark.parametrize( 'meth', [ # METHOD, ARGS, EXPECTED EVENTS # primary interface ('add', 2, []), ('add', 10, [call.changed(added={10}, removed={})]), ('discard', 2, [call.changed(added={}, removed={2})]), ('remove', 2, [call.changed(added={}, removed={2})]), ('discard', 10, []), # parity with set ('update', {3, 4, 5, 6}, [call.changed(added={5, 6}, removed={})]), ( 'difference_update', {3, 4, 5, 6}, [call.changed(added={}, removed={3, 4})], ), ( 'intersection_update', {3, 4, 5, 6}, [call.changed(added={}, removed={0, 1, 2})], ), ( 'symmetric_difference_update', {3, 4, 5, 6}, [call.changed(added={5, 6}, removed={3, 4})], ), ], ids=lambda x: x[0], ) def test_set_interface_parity(test_set, regular_set, meth): method_name, arg, expected = meth test_set_method = getattr(test_set, method_name) assert tuple(test_set) == tuple(regular_set) regular_set_method = getattr(regular_set, method_name) assert test_set_method(arg) == regular_set_method(arg) assert tuple(test_set) == tuple(regular_set) assert test_set.events.mock_calls == expected def test_set_pop(): test_set = EventedSet(range(3)) test_set.events = Mock(wraps=test_set.events) test_set.pop() assert len(test_set.events.changed.call_args_list) == 1 test_set.pop() assert len(test_set.events.changed.call_args_list) == 2 test_set.pop() assert len(test_set.events.changed.call_args_list) == 3 with pytest.raises(KeyError): test_set.pop() with pytest.raises(KeyError): test_set.remove(34) def test_set_clear(test_set): assert test_set.events.mock_calls == [] test_set.clear() assert test_set.events.mock_calls == [ call.changed(added={}, removed={0, 1, 2, 3, 4}) ] @pytest.mark.parametrize( 'meth', [ ('difference', {3, 4, 5, 6}), ('intersection', {3, 4, 5, 6}), ('issubset', {3, 4}), ('issubset', {3, 4, 5, 6}), ('issubset', {1, 2, 3, 4, 5, 6}), ('issuperset', {3, 4}), ('issuperset', {3, 4, 5, 6}), ('issuperset', {1, 2, 3, 4, 5, 6}), ('symmetric_difference', {3, 4, 5, 6}), ('union', {3, 4, 5, 6}), ], ) def test_set_new_objects(test_set, regular_set, meth): method_name, arg = meth test_set_method = getattr(test_set, method_name) assert tuple(test_set) == tuple(regular_set) regular_set_method = getattr(regular_set, method_name) result = test_set_method(arg) assert result == regular_set_method(arg) assert isinstance(result, (EventedSet, bool)) assert result is not test_set assert test_set.events.mock_calls == [] napari-0.5.0a1/napari/utils/events/_tests/test_selectable_list.py000066400000000000000000000017621437041365600252150ustar00rootroot00000000000000from typing import Iterable, TypeVar from napari.utils.events.containers import SelectableEventedList T = TypeVar('T') def _make_selectable_list_and_select_first( items: Iterable[T], ) -> SelectableEventedList[T]: selectable_list = SelectableEventedList(items) first = selectable_list[0] selectable_list.selection = [first] assert first in selectable_list.selection return selectable_list def test_remove_discards_from_selection(): selectable_list = _make_selectable_list_and_select_first(['a', 'b', 'c']) selectable_list.remove('a') assert 'a' not in selectable_list.selection def test_pop_discards_from_selection(): selectable_list = _make_selectable_list_and_select_first(['a', 'b', 'c']) selectable_list.pop(0) assert 'a' not in selectable_list.selection def test_del_discards_from_selection(): selectable_list = _make_selectable_list_and_select_first(['a', 'b', 'c']) del selectable_list[0] assert 'a' not in selectable_list.selection napari-0.5.0a1/napari/utils/events/_tests/test_selection.py000066400000000000000000000014341437041365600240400ustar00rootroot00000000000000from unittest.mock import Mock import pytest from pydantic import ValidationError from napari.utils.events import EventedModel, Selection def test_selection(): class T(EventedModel): sel: Selection[int] t = T(sel=[]) t.sel.events._current = Mock() assert not t.sel._current assert not t.sel t.sel.add(1) t.sel._current = 1 t.sel.events._current.assert_called_once() assert 1 in t.sel assert t.sel._current == 1 assert t.json() == r'{"sel": {"selection": [1], "_current": 1}}' assert T(sel={"selection": [1], "_current": 1}) == t t.sel.remove(1) assert not t.sel with pytest.raises(ValidationError): T(sel=['asdf']) with pytest.raises(ValidationError): T(sel={"selection": [1], "_current": 'asdf'}) napari-0.5.0a1/napari/utils/events/_tests/test_typed_dict.py000066400000000000000000000022361437041365600242040ustar00rootroot00000000000000import pytest from napari.utils.events.containers import EventedDict, TypedMutableMapping # this is a parametrized fixture, all tests using ``dict_type`` will be run # once using each of the items in params # https://docs.pytest.org/en/stable/fixture.html#parametrizing-fixtures @pytest.fixture(params=[TypedMutableMapping, EventedDict]) def dict_type(request): return request.param def test_type_enforcement(dict_type): """Test that TypedDicts enforce type during mutation events.""" a = dict_type({"A": 1, "B": 3, "C": 5}, basetype=int) assert tuple(a.values()) == (1, 3, 5) with pytest.raises(TypeError): a["D"] = "string" with pytest.raises(TypeError): a.update({"E": 3.5}) # also on instantiation with pytest.raises(TypeError): dict_type({"A": 1, "B": 3.3, "C": "5"}, basetype=int) def test_multitype_enforcement(dict_type): """Test that basetype also accepts/enforces a sequence of types.""" a = dict_type({"A": 1, "B": 3, "C": 5.5}, basetype=(int, float)) assert tuple(a.values()) == (1, 3, 5.5) with pytest.raises(TypeError): a["D"] = "string" a["D"] = 2.4 a.update({"E": 3.5}) napari-0.5.0a1/napari/utils/events/_tests/test_typed_list.py000066400000000000000000000107571437041365600242430ustar00rootroot00000000000000import pytest from napari.utils.events.containers import ( EventedList, NestableEventedList, TypedMutableSequence, ) # this is a parametrized fixture, all tests using ``list_type`` will be run # once using each of the items in params # https://docs.pytest.org/en/stable/fixture.html#parametrizing-fixtures @pytest.fixture( params=[TypedMutableSequence, EventedList, NestableEventedList] ) def list_type(request): return request.param def test_type_enforcement(list_type): """Test that TypedLists enforce type during mutation events.""" a = list_type([1, 2, 3, 4], basetype=int) assert tuple(a) == (1, 2, 3, 4) with pytest.raises(TypeError): a.append("string") with pytest.raises(TypeError): a.insert(0, "string") with pytest.raises(TypeError): a[0] = "string" with pytest.raises(TypeError): a[0] = 1.23 # also on instantiation with pytest.raises(TypeError): _ = list_type([1, 2, '3'], basetype=int) def test_type_enforcement_with_slices(list_type): """Test that TypedLists enforce type during mutation events.""" a = list_type(basetype=int) a[:] = list(range(10)) with pytest.raises(TypeError): a[4:4] = ['hi'] with pytest.raises(ValueError): a[2:9:2] = [1, 2, 3] # not the right length with pytest.raises(TypeError): # right length, includes bad type a[2:9:2] = [1, 2, 3, 'a'] assert a == list(range(10)), 'List has changed!' def test_multitype_enforcement(list_type): """Test that basetype also accepts/enforces a sequence of types.""" a = list_type([1, 2, 3, 4, 5.5], basetype=(int, float)) assert tuple(a) == (1, 2, 3, 4, 5.5) with pytest.raises(TypeError): a.append("string") a.append(2) a.append(2.4) def test_custom_lookup(list_type): """Test that we can get objects by non-integer index using custom lookups.""" class Custom: def __init__(self, name='', data=()) -> None: self.name = name self.data = data hi = Custom(name='hi') dct = Custom(data={'some': 'data'}) a = list_type( [Custom(), hi, Custom(), dct], basetype=Custom, lookup={str: lambda x: x.name, dict: lambda x: x.data}, ) # index with integer as usual assert a[1].name == 'hi' assert a.index("hi") == 1 # index with string also works assert a['hi'] == hi # index with a dict will use the `dict` type lookup assert a[{'some': 'data'}].data == {'some': 'data'} assert a.index({'some': 'data'}) == 3 assert a[{'some': 'data'}] == dct # index still works with start/stop arguments with pytest.raises(ValueError): assert a.index((1, 2, 3), stop=2) with pytest.raises(ValueError): assert a.index((1, 2, 3), start=-3, stop=-1) # contains works assert 'hi' in a assert 'asdfsad' not in a # deletion works del a['hi'] assert hi not in a assert 'hi' not in a del a[0] repr(a) def test_nested_type_enforcement(): """Test that type enforcement also works with NestableLists.""" data = [1, 2, [3, 4, [5, 6]]] a = NestableEventedList(data, basetype=int) assert a[2, 2, 1] == 6 # first level with pytest.raises(TypeError): a.append("string") with pytest.raises(TypeError): a.insert(0, "string") with pytest.raises(TypeError): a[0] = "string" # deeply nested with pytest.raises(TypeError): a[2, 2].append("string") with pytest.raises(TypeError): a[2, 2].insert(0, "string") with pytest.raises(TypeError): a[2, 2, 0] = "string" # also works during instantiation with pytest.raises(TypeError): _ = NestableEventedList([1, 1, ['string']], basetype=int) with pytest.raises(TypeError): _ = NestableEventedList([1, 2, [3, ['string']]], basetype=int) def test_nested_custom_lookup(): class Custom: def __init__(self, name='') -> None: self.name = name c = Custom() c1 = Custom(name='c1') c2 = Custom(name='c2') c3 = Custom(name='c3') a: NestableEventedList[Custom] = NestableEventedList( [c, c1, [c2, [c3]]], basetype=Custom, lookup={str: lambda x: getattr(x, 'name', '')}, ) # first level assert a[1].name == 'c1' # index with integer as usual assert a.index("c1") == 1 assert a['c1'] == c1 # index with string also works # second level assert a[2, 0].name == 'c2' assert a.index("c2") == (2, 0) assert a['c2'] == c2 napari-0.5.0a1/napari/utils/events/containers/000077500000000000000000000000001437041365600213045ustar00rootroot00000000000000napari-0.5.0a1/napari/utils/events/containers/__init__.py000066400000000000000000000015451437041365600234220ustar00rootroot00000000000000from napari.utils.events.containers._dict import TypedMutableMapping from napari.utils.events.containers._evented_dict import EventedDict from napari.utils.events.containers._evented_list import EventedList from napari.utils.events.containers._nested_list import NestableEventedList from napari.utils.events.containers._selectable_list import ( SelectableEventedList, SelectableNestableEventedList, ) from napari.utils.events.containers._selection import Selectable, Selection from napari.utils.events.containers._set import EventedSet from napari.utils.events.containers._typed import TypedMutableSequence __all__ = [ 'EventedList', 'EventedSet', 'NestableEventedList', 'EventedDict', 'Selectable', 'SelectableEventedList', 'SelectableNestableEventedList', 'Selection', 'TypedMutableSequence', 'TypedMutableMapping', ] napari-0.5.0a1/napari/utils/events/containers/_dict.py000066400000000000000000000035421437041365600227440ustar00rootroot00000000000000"""Evented dictionary""" from typing import ( Any, Dict, Iterator, Mapping, MutableMapping, Sequence, Type, TypeVar, Union, ) _K = TypeVar("_K") _T = TypeVar("_T") class TypedMutableMapping(MutableMapping[_K, _T]): """Dictionary mixin that enforces item type.""" def __init__( self, data: Mapping[_K, _T] = None, basetype: Union[Type[_T], Sequence[Type[_T]]] = (), ) -> None: if data is None: data = {} self._dict: Dict[_K, _T] = dict() self._basetypes = ( basetype if isinstance(basetype, Sequence) else (basetype,) ) self.update(data) # #### START Required Abstract Methods def __setitem__(self, key: int, value: _T): # noqa: F811 self._dict[key] = self._type_check(value) def __delitem__(self, key: _K) -> None: del self._dict[key] def __getitem__(self, key: _K) -> _T: return self._dict[key] def __len__(self) -> int: return len(self._dict) def __iter__(self) -> Iterator[_T]: return iter(self._dict) def __repr__(self): return str(self._dict) def _type_check(self, e: Any) -> _T: if self._basetypes and not any( isinstance(e, t) for t in self._basetypes ): raise TypeError( f"Cannot add object with type {type(e)} to TypedDict expecting type {self._basetypes}", ) return e def __newlike__(self, iterable: MutableMapping[_K, _T]): new = self.__class__() # separating this allows subclasses to omit these from their `__init__` new._basetypes = self._basetypes new.update(**iterable) return new def copy(self) -> "TypedMutableMapping[_T]": """Return a shallow copy of the dictionary.""" return self.__newlike__(self) napari-0.5.0a1/napari/utils/events/containers/_evented_dict.py000066400000000000000000000100441437041365600244510ustar00rootroot00000000000000"""MutableMapping that emits events when altered.""" from typing import Mapping, Sequence, Type, Union from napari.utils.events.containers._dict import _K, _T, TypedMutableMapping from napari.utils.events.event import EmitterGroup, Event from napari.utils.events.types import SupportsEvents class EventedDict(TypedMutableMapping[_K, _T]): """Mutable dictionary that emits events when altered. This class is designed to behave exactly like builting ``dict``, but will emit events before and after all mutations (addition, removal, and changing). Parameters ---------- data : Mapping, optional Dictionary to initialize the class with. basetype : type of sequence of types, optional Type of the element in the dictionary. Events ------ changed (key: K, old_value: T, value: T) emitted when ``key`` is set from ``old_value`` to ``value`` adding (key: K) emitted before an item is added to the dictionary with ``key`` added (key: K, value: T) emitted after ``value`` was added to the dictionary with ``key`` removing (key: K) emitted before ``key`` is removed from the dictionary removed (key: K, value: T) emitted after ``key`` was removed from the dictionary updated (key, K, value: T) emitted after ``value`` of ``key`` was changed. Only implemented by subclasses to give them an option to trigger some update after ``value`` was changed and this class did not register it. This can be useful if the ``basetype`` is not an evented object. """ events: EmitterGroup def __init__( self, data: Mapping[_K, _T] = None, basetype: Union[Type[_T], Sequence[Type[_T]]] = (), ) -> None: _events = { "changing": None, "changed": None, "adding": None, "added": None, "removing": None, "removed": None, "updated": None, } # For inheritance: If the mro already provides an EmitterGroup, add... if hasattr(self, "events") and isinstance(self.events, EmitterGroup): self.events.add(**_events) else: # otherwise create a new one self.events = EmitterGroup(source=self, **_events) super().__init__(data, basetype) def __setitem__(self, key: _K, value: _T): old = self._dict.get(key, None) if value is old or value == old: return if old is None: self.events.adding(key=key) super().__setitem__(key, value) self.events.added(key=key, value=value) self._connect_child_emitters(value) else: super().__setitem__(key, value) self.events.changed(key=key, old_value=old, value=value) def __delitem__(self, key: _K): self.events.removing(key=key) self._disconnect_child_emitters(self[key]) item = self._dict.pop(key) self.events.removed(key=key, value=item) def _reemit_child_event(self, event: Event): """An item in the dict emitted an event. Re-emit with key""" if not hasattr(event, "key"): event.key = self.key(event.source) # re-emit with this object's EventEmitter self.events(event) def _disconnect_child_emitters(self, child: _T): """Disconnect all events from the child from the re-emitter.""" if isinstance(child, SupportsEvents): child.events.disconnect(self._reemit_child_event) def _connect_child_emitters(self, child: _T): """Connect all events from the child to be re-emitted.""" if isinstance(child, SupportsEvents): # make sure the event source has been set on the child if child.events.source is None: child.events.source = child child.events.connect(self._reemit_child_event) def key(self, value: _T): """Return first instance of value.""" for k, v in self._dict.items(): if v is value or v == value: return k napari-0.5.0a1/napari/utils/events/containers/_evented_list.py000066400000000000000000000336531437041365600245140ustar00rootroot00000000000000"""MutableSequence that emits events when altered. Note For Developers =================== Be cautious when re-implementing typical list-like methods here (e.g. extend, pop, clear, etc...). By not re-implementing those methods, we force ALL "CRUD" (create, read, update, delete) operations to go through a few key methods defined by the abc.MutableSequence interface, where we can emit the necessary events. Specifically: - ``insert`` = "create" : add a new item/index to the list - ``__getitem__`` = "read" : get the value of an existing index - ``__setitem__`` = "update" : update the value of an existing index - ``__delitem__`` = "delete" : remove an existing index from the list All of the additional list-like methods are provided by the MutableSequence interface, and call one of those 4 methods. So if you override a method, you MUST make sure that all the appropriate events are emitted. (Tests should cover this in test_evented_list.py) """ import contextlib import logging from typing import Callable, Dict, Iterable, List, Sequence, Tuple, Type, Union from napari.utils.events.containers._typed import ( _L, _T, Index, TypedMutableSequence, ) from napari.utils.events.event import EmitterGroup, Event from napari.utils.events.types import SupportsEvents from napari.utils.translations import trans logger = logging.getLogger(__name__) class EventedList(TypedMutableSequence[_T]): """Mutable Sequence that emits events when altered. This class is designed to behave exactly like the builtin ``list``, but will emit events before and after all mutations (insertion, removal, setting, and moving). Parameters ---------- data : iterable, optional Elements to initialize the list with. basetype : type or sequence of types, optional Type of the elements in the list. lookup : dict of Type[L] : function(object) -> L Mapping between a type, and a function that converts items in the list to that type. Events ------ inserting (index: int) emitted before an item is inserted at ``index`` inserted (index: int, value: T) emitted after ``value`` is inserted at ``index`` removing (index: int) emitted before an item is removed at ``index`` removed (index: int, value: T) emitted after ``value`` is removed at ``index`` moving (index: int, new_index: int) emitted before an item is moved from ``index`` to ``new_index`` moved (index: int, new_index: int, value: T) emitted after ``value`` is moved from ``index`` to ``new_index`` changed (index: int, old_value: T, value: T) emitted when ``index`` is set from ``old_value`` to ``value`` changed (index: slice, old_value: List[_T], value: List[_T]) emitted when ``index`` is set from ``old_value`` to ``value`` reordered (value: self) emitted when the list is reordered (eg. moved/reversed). """ events: EmitterGroup def __init__( self, data: Iterable[_T] = (), *, basetype: Union[Type[_T], Sequence[Type[_T]]] = (), lookup: Dict[Type[_L], Callable[[_T], Union[_T, _L]]] = None, ) -> None: if lookup is None: lookup = {} _events = { 'inserting': None, # int 'inserted': None, # Tuple[int, Any] - (idx, value) 'removing': None, # int 'removed': None, # Tuple[int, Any] - (idx, value) 'moving': None, # Tuple[int, int] 'moved': None, # Tuple[Tuple[int, int], Any] 'changed': None, # Tuple[int, Any, Any] - (idx, old, new) 'reordered': None, # None } # For inheritance: If the mro already provides an EmitterGroup, add... if hasattr(self, 'events') and isinstance(self.events, EmitterGroup): self.events.add(**_events) else: # otherwise create a new one self.events = EmitterGroup(source=self, **_events) super().__init__(data, basetype=basetype, lookup=lookup) # WAIT!! ... Read the module docstring before reimplement these methods # def append(self, item): ... # def clear(self): ... # def pop(self, index=-1): ... # def extend(self, value: Iterable[_T]): ... # def remove(self, value: T): ... def __setitem__(self, key, value): old = self._list[key] # https://github.com/napari/napari/pull/2120 if isinstance(key, slice): if not isinstance(value, Iterable): raise TypeError( trans._( 'Can only assign an iterable to slice', deferred=True, ) ) value = list( value ) # make sure we don't empty generators and reuse them if value == old: return [self._type_check(v) for v in value] # before we mutate the list if key.step is not None: # extended slices are more restricted indices = list(range(*key.indices(len(self)))) if not len(value) == len(indices): raise ValueError( trans._( "attempt to assign sequence of size {size} to extended slice of size {slice_size}", deferred=True, size=len(value), slice_size=len(indices), ) ) for i, v in zip(indices, value): self.__setitem__(i, v) else: del self[key] start = key.start or 0 for i, v in enumerate(value): self.insert(start + i, v) else: if value is old: return super().__setitem__(key, value) self.events.changed(index=key, old_value=old, value=value) def _delitem_indices( self, key: Index ) -> Iterable[Tuple['EventedList[_T]', int]]: # returning List[(self, int)] allows subclasses to pass nested members if isinstance(key, int): return [(self, key if key >= 0 else key + len(self))] elif isinstance(key, slice): return [(self, i) for i in range(*key.indices(len(self)))] elif type(key) in self._lookup: return [(self, self.index(key))] valid = {int, slice}.union(set(self._lookup)) raise TypeError( trans._( "Deletion index must be {valid!r}, got {dtype}", deferred=True, valid=valid, dtype=type(key), ) ) def __delitem__(self, key: Index): # delete from the end for parent, index in sorted(self._delitem_indices(key), reverse=True): parent.events.removing(index=index) self._disconnect_child_emitters(parent[index]) item = parent._list.pop(index) self._process_delete_item(item) parent.events.removed(index=index, value=item) def _process_delete_item(self, item: _T): """Allow process item in inherited class before event was emitted""" def insert(self, index: int, value: _T): """Insert ``value`` before index.""" self.events.inserting(index=index) super().insert(index, value) self.events.inserted(index=index, value=value) self._connect_child_emitters(value) def _reemit_child_event(self, event: Event): """An item in the list emitted an event. Re-emit with index""" if not hasattr(event, 'index'): with contextlib.suppress(ValueError): event.index = self.index(event.source) # reemit with this object's EventEmitter self.events(event) def _disconnect_child_emitters(self, child: _T): """Disconnect all events from the child from the reemitter.""" if isinstance(child, SupportsEvents): child.events.disconnect(self._reemit_child_event) def _connect_child_emitters(self, child: _T): """Connect all events from the child to be reemitted.""" if isinstance(child, SupportsEvents): # make sure the event source has been set on the child if child.events.source is None: child.events.source = child child.events.connect(self._reemit_child_event) def move(self, src_index: int, dest_index: int = 0) -> bool: """Insert object at ``src_index`` before ``dest_index``. Both indices refer to the list prior to any object removal (pre-move space). """ if dest_index < 0: dest_index += len(self) + 1 if dest_index in (src_index, src_index + 1): # this is a no-op return False self.events.moving(index=src_index, new_index=dest_index) item = self._list.pop(src_index) if dest_index > src_index: dest_index -= 1 self._list.insert(dest_index, item) self.events.moved(index=src_index, new_index=dest_index, value=item) self.events.reordered(value=self) return True def move_multiple( self, sources: Iterable[Index], dest_index: int = 0 ) -> int: """Move a batch of `sources` indices, to a single destination. Note, if `dest_index` is higher than any of the `sources`, then the resulting position of the moved objects after the move operation is complete will be lower than `dest_index`. Parameters ---------- sources : Sequence[int or slice] A sequence of indices dest_index : int, optional The destination index. All sources will be inserted before this index (in pre-move space), by default 0... which has the effect of "bringing to front" everything in ``sources``, or acting as a "reorder" method if ``sources`` contains all indices. Returns ------- int The number of successful move operations completed. Raises ------ TypeError If the destination index is a slice, or any of the source indices are not ``int`` or ``slice``. """ logger.debug( f"move_multiple(sources={sources}, dest_index={dest_index})" ) # calling list here makes sure that there are no index errors up front move_plan = list(self._move_plan(sources, dest_index)) # don't assume index adjacency ... so move objects one at a time # this *could* be simplified with an intermediate list ... but this way # allows any views (such as QtViews) to update themselves more easily. # If this needs to be changed in the future for performance reasons, # then the associated QtListView will need to changed from using # `beginMoveRows` & `endMoveRows` to using `layoutAboutToBeChanged` & # `layoutChanged` while *manually* updating model indices with # `changePersistentIndexList`. That becomes much harder to do with # nested tree-like models. with self.events.reordered.blocker(): for src, dest in move_plan: self.move(src, dest) self.events.reordered(value=self) return len(move_plan) def _move_plan(self, sources: Iterable[Index], dest_index: int): """Prepared indices for a multi-move. Given a set of ``sources`` from anywhere in the list, and a single ``dest_index``, this function computes and yields ``(from_index, to_index)`` tuples that can be used sequentially in single move operations. It keeps track of what has moved where and updates the source and destination indices to reflect the model at each point in the process. This is useful for a drag-drop operation with a QtModel/View. Parameters ---------- sources : Iterable[tuple[int, ...]] An iterable of tuple[int] that should be moved to ``dest_index``. dest_index : Tuple[int] The destination for sources. """ if isinstance(dest_index, slice): raise TypeError( trans._( "Destination index may not be a slice", deferred=True, ) ) to_move: List[int] = [] for idx in sources: if isinstance(idx, slice): to_move.extend(list(range(*idx.indices(len(self))))) elif isinstance(idx, int): to_move.append(idx) else: raise TypeError( trans._( "Can only move integer or slice indices, not {t}", deferred=True, t=type(idx), ) ) to_move = list(dict.fromkeys(to_move)) if dest_index < 0: dest_index += len(self) + 1 d_inc = 0 popped: List[int] = [] for i, src in enumerate(to_move): if src != dest_index: # we need to decrement the src_i by 1 for each time we have # previously pulled items out from in front of the src_i src -= sum(x <= src for x in popped) # if source is past the insertion point, increment src for each # previous insertion if src >= dest_index: src += i yield src, dest_index + d_inc popped.append(src) # if the item moved up, icrement the destination index if dest_index <= src: d_inc += 1 def reverse(self) -> None: """Reverse list *IN PLACE*.""" # reimplementing this method to emit a change event # If this method were removed, .reverse() would still be available, # it would just emit a "changed" event for each moved index in the list self._list.reverse() self.events.reordered(value=self) napari-0.5.0a1/napari/utils/events/containers/_nested_list.py000066400000000000000000000420041437041365600243320ustar00rootroot00000000000000"""Nestable MutableSequence that emits events when altered. see module docstring of evented_list.py for more details """ from __future__ import annotations import contextlib import logging from collections import defaultdict from typing import ( DefaultDict, Generator, Iterable, MutableSequence, NewType, Tuple, TypeVar, Union, cast, overload, ) from napari.utils.events.containers._evented_list import EventedList, Index from napari.utils.events.event import Event from napari.utils.translations import trans logger = logging.getLogger(__name__) NestedIndex = Tuple[Index, ...] MaybeNestedIndex = Union[Index, NestedIndex] ParentIndex = NewType('ParentIndex', Tuple[int, ...]) _T = TypeVar("_T") def ensure_tuple_index(index: MaybeNestedIndex) -> NestedIndex: """Return index as a tuple of ints or slices. Parameters ---------- index : Tuple[Union[int, slice], ...] or int or slice An index as an int, tuple, or slice Returns ------- NestedIndex The index, guaranteed to be a tuple. Raises ------ TypeError If the input ``index`` is not an ``int``, ``slice``, or ``tuple``. """ if isinstance(index, (slice, int)): return (index,) # single integer inserts to self elif isinstance(index, tuple): return index raise TypeError( trans._( "Invalid nested index: {index}. Must be an int or tuple", deferred=True, index=index, ) ) def split_nested_index(index: MaybeNestedIndex) -> tuple[ParentIndex, Index]: """Given a nested index, return (nested_parent_index, row). Parameters ---------- index : MaybeNestedIndex An index as an int, tuple, or slice Returns ------- Tuple[NestedIndex, Index] A tuple of ``parent_index``, ``row`` Raises ------ ValueError If any of the items in the returned ParentIndex tuple are not ``int``. Examples -------- >>> split_nested_index((1, 2, 3, 4)) ((1, 2, 3), 4) >>> split_nested_index(1) ((), 1) >>> split_nested_index(()) ((), -1) """ index = ensure_tuple_index(index) if index: *first, last = index if any(not isinstance(p, int) for p in first): raise ValueError( trans._( 'The parent index must be a tuple of int', deferred=True, ) ) return cast(ParentIndex, tuple(first)), last return ParentIndex(()), -1 # empty tuple appends to self class NestableEventedList(EventedList[_T]): """Nestable Mutable Sequence that emits recursive events when altered. ``NestableEventedList`` instances can be indexed with a ``tuple`` of ``int`` (e.g. ``mylist[0, 2, 1]``) to retrieve nested child objects. A key property of this class is that when new mutable sequences are added to the list, they are themselves converted to a ``NestableEventedList``, and all of the ``EventEmitter`` objects in the child are connect to the parent object's ``_reemit_child_event`` method (assuming the child has an attribute called ``events`` that is an instance of ``EmitterGroup``). When ``_reemit_child_event`` receives an event from a child object, it remits the event, but changes any ``index`` keys in the event to a ``NestedIndex`` (a tuple of ``int``) such that indices emitted by any given ``NestableEventedList`` are always relative to itself. Parameters ---------- data : iterable, optional Elements to initialize the list with. by default None. basetype : type or sequence of types, optional Type of the elements in the list. lookup : dict of Type[L] : function(object) -> L Mapping between a type, and a function that converts items in the list to that type. Events ------ types used: Index = Union[int, Tuple[int, ...]] inserting (index: Index) emitted before an item is inserted at ``index`` inserted (index: Index, value: T) emitted after ``value`` is inserted at ``index`` removing (index: Index) emitted before an item is removed at ``index`` removed (index: Index, value: T) emitted after ``value`` is removed at ``index`` moving (index: Index, new_index: Index) emitted before an item is moved from ``index`` to ``new_index`` moved (index: Index, new_index: Index, value: T) emitted after ``value`` is moved from ``index`` to ``new_index`` changed (index: Index, old_value: T, value: T) emitted when ``index`` is set from ``old_value`` to ``value`` changed (index: slice, old_value: list[_T], value: list[_T]) emitted when slice ``index`` is set from ``old_value`` to ``value`` reordered (value: self) emitted when the list is reordered (eg. moved/reversed). """ # WAIT!! ... Read the ._list module docs before reimplement these classes # def append(self, item): ... # def clear(self): ... # def pop(self, index=-1): ... # def extend(self, value: Iterable[_T]): ... # def remove(self, value: T): ... @overload # type: ignore def __getitem__(self, key: int) -> Union[_T, NestableEventedList[_T]]: ... # pragma: no cover @overload def __getitem__(self, key: ParentIndex) -> NestableEventedList[_T]: ... # pragma: no cover @overload def __getitem__(self, key: slice) -> NestableEventedList[_T]: # noqa ... # pragma: no cover @overload def __getitem__( self, key: NestedIndex ) -> Union[_T, NestableEventedList[_T]]: ... # pragma: no cover def __getitem__(self, key: MaybeNestedIndex): if isinstance(key, tuple): item: NestableEventedList[_T] = self for idx in key: if not isinstance(item, MutableSequence): raise IndexError(f'index out of range: {key}') item = item[idx] return item return super().__getitem__(key) @overload def __setitem__(self, key: Union[int, NestedIndex], value: _T): ... # pragma: no cover @overload def __setitem__(self, key: slice, value: Iterable[_T]): ... # pragma: no cover def __setitem__(self, key: MaybeNestedIndex, value): # NOTE: if we check isinstance(..., MutableList), then we'll actually # clobber object of specialized classes being inserted into the list # (for instance, subclasses of NestableEventedList) # this check is more conservative, but will miss some "nestable" things if isinstance(value, list): value = self.__class__(value) if isinstance(key, tuple): parent_i, index = split_nested_index(key) self[parent_i].__setitem__(index, value) return self._connect_child_emitters(value) super().__setitem__(key, value) def _delitem_indices( self, key: MaybeNestedIndex ) -> Iterable[tuple[EventedList[_T], int]]: if isinstance(key, tuple): parent_i, index = split_nested_index(key) if isinstance(index, slice): indices = sorted( range(*index.indices(len(parent_i))), reverse=True ) else: indices = [index] return [(self[parent_i], i) for i in indices] return super()._delitem_indices(key) def insert(self, index: int, value: _T): """Insert object before index.""" # this is delicate, we want to preserve the evented list when nesting # but there is a high risk here of clobbering attributes of a special # child class if isinstance(value, list): value = self.__newlike__(value) super().insert(index, value) def _reemit_child_event(self, event: Event): """An item in the list emitted an event. Re-emit with index""" if hasattr(event, 'index'): # This event is coming from a nested List... # update the index as a nested index. ei = (self.index(event.source),) + ensure_tuple_index(event.index) for attr in ('index', 'new_index'): if hasattr(event, attr): setattr(event, attr, ei) # if the starting event was from a nestable envented list, we can # use the same event type here (e.g: removed, inserted) if isinstance(event.source, NestableEventedList): emitter = getattr(self.events, event.type, self.events) else: emitter = self.events # same as normal evented_list, but now we need to account for the # potentially different emitter if not hasattr(event, 'index'): with contextlib.suppress(ValueError): event.index = self.index(event.source) emitter(event) def _non_negative_index( self, parent_index: ParentIndex, dest_index: Index ) -> Index: """Make sure dest_index is a positive index inside parent_index.""" destination_group = self[parent_index] # not handling slice indexes if isinstance(dest_index, int) and dest_index < 0: dest_index += len(destination_group) + 1 return dest_index def _move_plan( self, sources: Iterable[MaybeNestedIndex], dest_index: NestedIndex ) -> Generator[tuple[NestedIndex, NestedIndex], None, None]: """Prepared indices for a complicated nested multi-move. Given a set of possibly-nested ``sources`` from anywhere in the tree, and a single ``dest_index``, this function computes and yields ``(from_index, to_index)`` tuples that can be used sequentially in single move operations. It keeps track of what has moved where and updates the source and destination indices to reflect the model at each point in the process. This is useful for a drag-drop operation with a QtModel/View. Parameters ---------- sources : Iterable[tuple[int, ...]] An iterable of tuple[int] that should be moved to ``dest_index``. (Note: currently, the order of ``sources`` will NOT be maintained.) dest_index : Tuple[int] The destination for sources. Yields ------ Generator[tuple[int, ...], None, None] [description] Raises ------ ValueError If any source terminal or the destination terminal index is a slice IndexError If any of the sources are the root object: ``()``. NotImplementedError If a slice is provided in the middle of a source index. """ dest_par, dest_i = split_nested_index(dest_index) if isinstance(dest_i, slice): raise ValueError( trans._( "Destination index may not be a slice", deferred=True, ) ) dest_i = cast(int, self._non_negative_index(dest_par, dest_i)) # need to update indices as we pop, so we keep track of the indices # we have previously popped popped: DefaultDict[NestedIndex, list[int]] = defaultdict(list) dumped: list[int] = [] # we iterate indices from the end first, so pop() always works for idx in sorted(sources, reverse=True): if isinstance(idx, (int, slice)): idx = (idx,) if idx == (): raise IndexError( trans._( "Group cannot move itself", deferred=True, ) ) # i.e. we need to increase the (src_par, ...) by 1 for each time # we have previously inserted items in front of the (src_par, ...) _parlen = len(dest_par) if len(idx) > _parlen: _idx: list[Index] = list(idx) if isinstance(_idx[_parlen], slice): raise NotImplementedError( trans._( "Can't yet deal with slice source indices in multimove", deferred=True, ) ) _idx[_parlen] += sum(x <= _idx[_parlen] for x in dumped) idx = tuple(_idx) src_par, src_i = split_nested_index(idx) if isinstance(src_i, slice): raise ValueError( trans._( "Terminal source index may not be a slice", deferred=True, ) ) if src_i < 0: src_i += len(self[src_par]) # we need to decrement the src_i by 1 for each time we have # previously pulled items out from in front of the src_i src_i -= sum(x <= src_i for x in popped.get(src_par, [])) # we need to decrement the dest_i by 1 for each time we have # previously pulled items out from in front of the dest_i ddec = sum(x <= dest_i for x in popped.get(dest_par, [])) # skip noop if src_par == dest_par and src_i == dest_i - ddec: continue yield src_par + (src_i,), dest_par + (dest_i - ddec,) popped[src_par].append(src_i) dumped.append(dest_i - ddec) def move( self, src_index: Union[int, NestedIndex], dest_index: Union[int, NestedIndex] = (0,), ) -> bool: """Move a single item from ``src_index`` to ``dest_index``. Parameters ---------- src_index : Union[int, NestedIndex] The index of the object to move dest_index : Union[int, NestedIndex], optional The destination. Object will be inserted before ``dest_index.``, by default, will insert at the front of the root list. Returns ------- bool Whether the operation completed successfully Raises ------ ValueError If the terminal source is a slice, or if the source is this root object """ logger.debug( "move(src_index=%s, dest_index=%s)", src_index, dest_index, ) src_par_i, src_i = split_nested_index(src_index) dest_par_i, dest_i = split_nested_index(dest_index) dest_i = self._non_negative_index(dest_par_i, dest_i) dest_index = dest_par_i + (dest_i,) if isinstance(src_i, slice): raise ValueError( trans._( "Terminal source index may not be a slice", deferred=True, ) ) if isinstance(dest_i, slice): raise ValueError( trans._( "Destination index may not be a slice", deferred=True, ) ) if src_i == (): raise ValueError( trans._( "Group cannot move itself", deferred=True, ) ) if src_par_i == dest_par_i and isinstance(dest_i, int): if dest_i > src_i: dest_i -= 1 if src_i == dest_i: return False self.events.moving(index=src_index, new_index=dest_index) dest_par = self[dest_par_i] # grab this before popping src_i with self.events.blocker_all(): value = self[src_par_i].pop(src_i) dest_par.insert(dest_i, value) self.events.moved(index=src_index, new_index=dest_index, value=value) self.events.reordered(value=self) return True def _type_check(self, e) -> _T: if isinstance(e, list): return self.__newlike__(e) if self._basetypes: _types = tuple(self._basetypes) + (NestableEventedList,) if not isinstance(e, _types): raise TypeError( trans._( 'Cannot add object with type {dtype!r} to TypedList expecting type {types_!r}', deferred=True, dtype=type(e), types_=_types, ) ) return e def _iter_indices(self, start=0, stop=None, root=()): """Iter indices from start to stop. Depth first traversal of the tree """ for i, item in enumerate(self[start:stop]): yield root + (i,) if root else i if isinstance(item, NestableEventedList): yield from item._iter_indices(root=root + (i,)) def has_index(self, index: Union[int, Tuple[int, ...]]) -> bool: """Return true if `index` is valid for this nestable list.""" if isinstance(index, int): return -len(self) <= index < len(self) if isinstance(index, tuple): try: self[index] return True except IndexError: return False raise TypeError(f"Not supported index type {type(index)}") napari-0.5.0a1/napari/utils/events/containers/_selectable_list.py000066400000000000000000000127271437041365600251640ustar00rootroot00000000000000import warnings from typing import TypeVar from napari.utils.events.containers._evented_list import EventedList from napari.utils.events.containers._nested_list import NestableEventedList from napari.utils.events.containers._selection import Selectable from napari.utils.translations import trans _T = TypeVar("_T") class SelectableEventedList(Selectable[_T], EventedList[_T]): """List model that also supports selection. Events ------ inserting (index: int) emitted before an item is inserted at ``index`` inserted (index: int, value: T) emitted after ``value`` is inserted at ``index`` removing (index: int) emitted before an item is removed at ``index`` removed (index: int, value: T) emitted after ``value`` is removed at ``index`` moving (index: int, new_index: int) emitted before an item is moved from ``index`` to ``new_index`` moved (index: int, new_index: int, value: T) emitted after ``value`` is moved from ``index`` to ``new_index`` changed (index: int, old_value: T, value: T) emitted when ``index`` is set from ``old_value`` to ``value`` changed (index: slice, old_value: List[_T], value: List[_T]) emitted when ``index`` is set from ``old_value`` to ``value`` reordered (value: self) emitted when the list is reordered (eg. moved/reversed). selection.changed (added: Set[_T], removed: Set[_T]) Emitted when the set changes, includes item(s) that have been added and/or removed from the set. selection.active (value: _T) emitted when the current item has changed. selection._current (value: _T) emitted when the current item has changed. (Private event) """ def __init__(self, *args, **kwargs) -> None: self._activate_on_insert = True super().__init__(*args, **kwargs) self.selection._pre_add_hook = self._preselect_hook def _preselect_hook(self, value): """Called before adding an item to the selection.""" if value not in self: raise ValueError( trans._( "Cannot select item that is not in list: {value!r}", deferred=True, value=value, ) ) return value def _process_delete_item(self, item: _T): self.selection.discard(item) def insert(self, index: int, value: _T): super().insert(index, value) if self._activate_on_insert: # Make layer selected and unselect all others self.selection.active = value def select_all(self): """Select all items in the list.""" self.selection.update(self) def remove_selected(self): """Remove selected items from list.""" idx = 0 for i in list(self.selection): idx = self.index(i) self.remove(i) if isinstance(idx, int): new = max(0, (idx - 1)) do_add = len(self) > new else: *root, _idx = idx new = tuple(root) + (_idx - 1,) if _idx >= 1 else tuple(root) do_add = len(self) > new[0] if do_add: self.selection.add(self[new]) def move_selected(self, index: int, insert: int): """Reorder list by moving the item at index and inserting it at the insert index. If additional items are selected these will get inserted at the insert index too. This allows for rearranging the list based on dragging and dropping a selection of items, where index is the index of the primary item being dragged, and insert is the index of the drop location, and the selection indicates if multiple items are being dragged. If the moved layer is not selected select it. This method is deprecated. Please use layers.move_multiple with layers.selection instead. Parameters ---------- index : int Index of primary item to be moved insert : int Index that item(s) will be inserted at """ # this is just here for now to support the old layerlist API warnings.warn( trans._( 'move_selected is deprecated since 0.4.16. Please use layers.move_multiple with layers.selection instead.', deferred=True, ), FutureWarning, stacklevel=2, ) if self[index] not in self.selection: self.selection.select_only(self[index]) moving = [index] else: moving = [i for i, x in enumerate(self) if x in self.selection] offset = insert >= index self.move_multiple(moving, insert + offset) def select_next(self, step=1, shift=False): """Selects next item from list.""" if self.selection: idx = self.index(self.selection._current) + step if len(self) > idx >= 0: next_layer = self[idx] if shift: self.selection.add(next_layer) self.selection._current = next_layer else: self.selection.active = next_layer elif len(self) > 0: self.selection.active = self[-1 if step > 0 else 0] def select_previous(self, shift=False): """Selects previous item from list.""" self.select_next(-1, shift=shift) class SelectableNestableEventedList( SelectableEventedList[_T], NestableEventedList[_T] ): pass napari-0.5.0a1/napari/utils/events/containers/_selection.py000066400000000000000000000155201437041365600240050ustar00rootroot00000000000000from typing import TYPE_CHECKING, Generic, Iterable, Optional, TypeVar from napari.utils.events.containers._set import EventedSet from napari.utils.events.event import EmitterGroup from napari.utils.translations import trans if TYPE_CHECKING: from pydantic.fields import ModelField _T = TypeVar("_T") _S = TypeVar("_S") class Selection(EventedSet[_T]): """A model of selected items, with ``active`` and ``current`` item. There can only be one ``active`` and one ``current` item, but there can be multiple selected items. An "active" item is defined as a single selected item (if multiple items are selected, there is no active item). The "current" item is mostly useful for (e.g.) keyboard actions: even with multiple items selected, you may only have one current item, and keyboard events (like up and down) can modify that current item. It's possible to have a current item without an active item, but an active item will always be the current item. An item can be the current item and selected at the same time. Qt views will ensure that there is always a current item as keyboard navigation, for example, requires a current item. This pattern mimics current/selected items from Qt: https://doc.qt.io/qt-5/model-view-programming.html#current-item-and-selected-items Parameters ---------- data : iterable, optional Elements to initialize the set with. Attributes ---------- active : Any, optional The active item, if any. An active item is the one being edited. _current : Any, optional The current item, if any. This is used primarily by GUI views when handling mouse/key events. Events ------ changed (added: Set[_T], removed: Set[_T]) Emitted when the set changes, includes item(s) that have been added and/or removed from the set. active (value: _T) emitted when the current item has changed. _current (value: _T) emitted when the current item has changed. (Private event) """ def __init__(self, data: Iterable[_T] = ()) -> None: self._active: Optional[_T] = None self._current_ = None self.events = EmitterGroup(source=self, _current=None, active=None) super().__init__(data=data) self._update_active() def _emit_change(self, added=None, removed=None): if added is None: added = set() if removed is None: removed = set() self._update_active() return super()._emit_change(added=added, removed=removed) def __repr__(self) -> str: return f"{type(self).__name__}({repr(self._set)})" def __hash__(self) -> int: """Make selection hashable.""" return id(self) @property def _current(self) -> Optional[_T]: """Get current item.""" return self._current_ @_current.setter def _current(self, index: Optional[_T]): """Set current item.""" if index == self._current_: return self._current_ = index self.events._current(value=index) @property def active(self) -> Optional[_T]: """Return the currently active item or None.""" return self._active @active.setter def active(self, value: Optional[_T]): """Set the active item. This make `value` the only selected item, and make it current. """ if value == self._active: return self._active = value self.clear() if value is None else self.select_only(value) self._current = value self.events.active(value=value) def _update_active(self): """On a selection event, update the active item based on selection. (An active item is a single selected item). """ if len(self) == 1: self.active = list(self)[0] elif self._active is not None: self._active = None self.events.active(value=None) def clear(self, keep_current: bool = False) -> None: """Clear the selection.""" if not keep_current: self._current = None super().clear() def toggle(self, obj: _T): """Toggle selection state of obj.""" self.symmetric_difference_update({obj}) def select_only(self, obj: _T): """Unselect everything but `obj`. Add to selection if not present.""" self.intersection_update({obj}) self.add(obj) @classmethod def __get_validators__(cls): yield cls.validate @classmethod def validate(cls, v, field: 'ModelField'): """Pydantic validator.""" from pydantic.utils import sequence_like if isinstance(v, dict): data = v.get("selection", []) current = v.get("_current", None) elif isinstance(v, Selection): data = v._set current = v._current else: data = v current = None if not sequence_like(data): raise TypeError( trans._( 'Value is not a valid sequence: {data}', deferred=True, data=data, ) ) # no type parameter was provided, just return if not field.sub_fields: obj = cls(data=data) obj._current_ = current return obj # Selection[type] parameter was provided. Validate contents type_field = field.sub_fields[0] errors = [] for i, v_ in enumerate(data): _, error = type_field.validate(v_, {}, loc=f'[{i}]') if error: errors.append(error) if current is not None: _, error = type_field.validate(current, {}, loc='current') if error: errors.append(error) if errors: from pydantic import ValidationError raise ValidationError(errors, cls) # type: ignore obj = cls(data=data) obj._current_ = current return obj def _json_encode(self): """Return an object that can be used by json.dumps.""" # we don't serialize active, as it's gleaned from the selection. return {'selection': super()._json_encode(), '_current': self._current} class Selectable(Generic[_S]): """Mixin that adds a selection model to an object.""" def __init__(self, *args, **kwargs) -> None: self._selection: Selection[_S] = Selection() super().__init__(*args, **kwargs) # type: ignore @property def selection(self) -> Selection[_S]: """Get current selection.""" return self._selection @selection.setter def selection(self, new_selection: Iterable[_S]) -> None: """Set selection, without deleting selection model object.""" self._selection.intersection_update(new_selection) self._selection.update(new_selection) napari-0.5.0a1/napari/utils/events/containers/_set.py000066400000000000000000000151211437041365600226100ustar00rootroot00000000000000from __future__ import annotations from typing import TYPE_CHECKING, Any, Iterable, Iterator, MutableSet, TypeVar from napari.utils.events import EmitterGroup from napari.utils.translations import trans _T = TypeVar("_T") if TYPE_CHECKING: from pydantic.fields import ModelField class EventedSet(MutableSet[_T]): """An unordered collection of unique elements. Parameters ---------- data : iterable, optional Elements to initialize the set with. Events ------ changed (added: Set[_T], removed: Set[_T]) Emitted when the set changes, includes item(s) that have been added and/or removed from the set. """ events: EmitterGroup def __init__(self, data: Iterable[_T] = ()) -> None: _events = {'changed': None} # For inheritance: If the mro already provides an EmitterGroup, add... if hasattr(self, 'events') and isinstance(self.events, EmitterGroup): self.events.add(**_events) else: # otherwise create a new one self.events = EmitterGroup(source=self, **_events) self._set: set[_T] = set() self.update(data) # #### START Required Abstract Methods def __contains__(self, x: Any) -> bool: return x in self._set def __iter__(self) -> Iterator[_T]: return iter(self._set) def __len__(self) -> int: return len(self._set) def _pre_add_hook(self, value): # for subclasses to potentially check value before adding return value def _emit_change(self, added=None, removed=None): # provides a hook for subclasses to update internal state before emit if added is None: added = set() if removed is None: removed = set() self.events.changed(added=added, removed=removed) def add(self, value: _T) -> None: """Add an element to the set, if not already present.""" if value not in self: value = self._pre_add_hook(value) self._set.add(value) self._emit_change(added={value}, removed={}) def discard(self, value: _T) -> None: """Remove an element from a set if it is a member. If the element is not a member, do nothing. """ if value in self: self._set.discard(value) self._emit_change(added={}, removed={value}) # #### END Required Abstract Methods # methods inherited from Set: # __le__, __lt__, __eq__, __ne__, __gt__, __ge__, __and__, __or__, # __sub__, __xor__, and isdisjoint # methods inherited from MutableSet: # clear, pop, remove, __ior__, __iand__, __ixor__, and __isub__ # The rest are for parity with builtins.set: def clear(self) -> None: if self._set: values = set(self) self._set.clear() self._emit_change(added={}, removed=values) def __repr__(self) -> str: return f"{type(self).__name__}({repr(self._set)})" def update(self, others: Iterable[_T] = ()) -> None: """Update this set with the union of this set and others""" to_add = set(others).difference(self._set) if to_add: to_add = {self._pre_add_hook(i) for i in to_add} self._set.update(to_add) self._emit_change(added=set(to_add), removed={}) def copy(self) -> EventedSet[_T]: """Return a shallow copy of this set.""" return type(self)(self._set) def difference(self, others: Iterable[_T] = ()) -> EventedSet[_T]: """Return set of all elements that are in this set but not other.""" return type(self)(self._set.difference(others)) def difference_update(self, others: Iterable[_T] = ()) -> None: """Remove all elements of another set from this set.""" to_remove = self._set.intersection(others) if to_remove: self._set.difference_update(to_remove) self._emit_change(added={}, removed=set(to_remove)) def intersection(self, others: Iterable[_T] = ()) -> EventedSet[_T]: """Return all elements that are in both sets as a new set.""" return type(self)(self._set.intersection(others)) def intersection_update(self, others: Iterable[_T] = ()) -> None: """Remove all elements of in this set that are not present in other.""" self.difference_update(self._set.symmetric_difference(others)) def issubset(self, others: Iterable[_T]) -> bool: """Returns whether another set contains this set or not""" return self._set.issubset(others) def issuperset(self, others: Iterable[_T]) -> bool: """Returns whether this set contains another set or not""" return self._set.issuperset(others) def symmetric_difference(self, others: Iterable[_T]) -> EventedSet[_T]: """Returns set of elements that are in exactly one of the sets""" return type(self)(self._set.symmetric_difference(others)) def symmetric_difference_update(self, others: Iterable[_T]) -> None: """Update set to the symmetric difference of itself and another. This will remove any items in this set that are also in `other`, and add any items in others that are not present in this set. """ to_add = set(others).difference(self._set) to_remove = self._set.intersection(others) self._set.difference_update(to_remove) self._set.update(to_add) self._emit_change(added=to_add, removed=to_remove) def union(self, others: Iterable[_T] = ()) -> EventedSet[_T]: """Return a set containing the union of sets""" return type(self)(self._set.union(others)) @classmethod def __get_validators__(cls): yield cls.validate @classmethod def validate(cls, v, field: ModelField): """Pydantic validator.""" from pydantic.utils import sequence_like if not sequence_like(v): raise TypeError( trans._( 'Value is not a valid sequence: {value}', deferred=True, value=v, ) ) if not field.sub_fields: return cls(v) type_field = field.sub_fields[0] errors = [] for i, v_ in enumerate(v): _valid_value, error = type_field.validate(v_, {}, loc=f'[{i}]') if error: errors.append(error) if errors: from pydantic import ValidationError raise ValidationError(errors, cls) # type: ignore return cls(v) def _json_encode(self): """Return an object that can be used by json.dumps.""" return list(self) napari-0.5.0a1/napari/utils/events/containers/_typed.py000066400000000000000000000171111437041365600231430ustar00rootroot00000000000000import logging from typing import ( Any, Callable, Dict, Iterable, List, MutableSequence, Sequence, Type, TypeVar, Union, overload, ) from napari.utils.translations import trans logger = logging.getLogger(__name__) Index = Union[int, slice] _T = TypeVar("_T") _L = TypeVar("_L") class TypedMutableSequence(MutableSequence[_T]): """List mixin that enforces item type, and enables custom indexing. Parameters ---------- data : iterable, optional Elements to initialize the list with. basetype : type or sequence of types, optional Type of the elements in the list. If a basetype (or multiple) is provided, then a TypeError will be raised when attempting to add an item to this sequence if it is not an instance of one of the types in ``basetype``. lookup : dict of Type[L] : function(object) -> L Mapping between a type, and a function that converts items in the list to that type. This is used for custom indexing. For example, if a ``lookup`` of {str: lambda x: x.name} is provided, then you can index into the list using ``list['frank']`` and it will search for an object whos attribute ``.name`` equals ``'frank'``. """ def __init__( self, data: Iterable[_T] = (), *, basetype: Union[Type[_T], Sequence[Type[_T]]] = (), lookup: Dict[Type[_L], Callable[[_T], Union[_T, _L]]] = None, ) -> None: if lookup is None: lookup = {} self._list: List[_T] = [] self._basetypes = ( basetype if isinstance(basetype, Sequence) else (basetype,) ) self._lookup = lookup.copy() self.extend(data) def __len__(self) -> int: return len(self._list) def __repr__(self) -> str: return repr(self._list) def __eq__(self, other: Any): return self._list == other def __hash__(self) -> int: # it's important to add this to allow this object to be hashable # given that we've also reimplemented __eq__ return id(self) @overload def __setitem__(self, key: int, value: _T): # noqa: F811 ... # pragma: no cover @overload def __setitem__(self, key: slice, value: Iterable[_T]): # noqa: F811 ... # pragma: no cover def __setitem__(self, key, value): # noqa: F811 if isinstance(key, slice): if not isinstance(value, Iterable): raise TypeError( trans._( 'Can only assign an iterable to slice', deferred=True, ) ) self._list[key] = [self._type_check(v) for v in value] else: self._list[key] = self._type_check(value) def insert(self, index: int, value: _T): self._list.insert(index, self._type_check(value)) def __contains__(self, key): if type(key) in self._lookup: try: self[self.index(key)] except ValueError: return False else: return True return super().__contains__(key) @overload def __getitem__(self, key: int) -> _T: # noqa: F811 ... # pragma: no cover @overload def __getitem__(self, key: slice) -> 'TypedMutableSequence[_T]': # noqa ... # pragma: no cover def __getitem__(self, key): # noqa: F811 """Get an item from the list Parameters ---------- key : int, slice, or any type in self._lookup The key to get. Returns ------- The value at `key` Raises ------ IndexError: If ``type(key)`` is not in ``self._lookup`` (usually an int, like a regular list), and the index is out of range. KeyError: If type(key) is in self._lookup and the key is not in the list (after) applying the self._lookup[key] function to each item in the list """ if type(key) in self._lookup: try: return self.__getitem__(self.index(key)) except ValueError as e: raise KeyError(str(e)) from e result = self._list[key] return self.__newlike__(result) if isinstance(result, list) else result def __delitem__(self, key): _key = self.index(key) if type(key) in self._lookup else key del self._list[_key] def _type_check(self, e: Any) -> _T: if self._basetypes and not any( isinstance(e, t) for t in self._basetypes ): raise TypeError( trans._( 'Cannot add object with type {dtype!r} to TypedList expecting type {basetypes!r}', deferred=True, dtype=type(e), basetypes=self._basetypes, ) ) return e def __newlike__(self, iterable: Iterable[_T]): new = self.__class__() # seperating this allows subclasses to omit these from their `__init__` new._basetypes = self._basetypes new._lookup = self._lookup.copy() new.extend(iterable) return new def copy(self) -> 'TypedMutableSequence[_T]': """Return a shallow copy of the list.""" return self.__newlike__(self) def __add__(self, other: Iterable[_T]) -> 'TypedMutableSequence[_T]': """Add other to self, return new object.""" copy = self.copy() copy.extend(other) return copy def __iadd__(self, other: Iterable[_T]) -> 'TypedMutableSequence[_T]': """Add other to self in place (self += other).""" self.extend(other) return self def __radd__(self, other: List) -> List: """Add other to self in place (self += other).""" return other + list(self) def index(self, value: _L, start: int = 0, stop: int = None) -> int: """Return first index of value. Parameters ---------- value : Any A value to lookup. If `type(value)` is in the lookups functions provided for this class, then values in the list will be searched using the corresponding lookup converter function. start : int, optional The starting index to search, by default 0 stop : int, optional The ending index to search, by default None Returns ------- int The index of the value Raises ------ ValueError If the value is not present """ if start is not None and start < 0: start = max(len(self) + start, 0) if stop is not None and stop < 0: stop += len(self) convert = self._lookup.get(type(value), _noop) for i in self._iter_indices(start, stop): v = convert(self[i]) if v is value or v == value: return i raise ValueError( trans._( "{value!r} is not in list", deferred=True, value=value, ) ) def _iter_indices(self, start=0, stop=None): """Iter indices from start to stop. While this is trivial for this basic sequence type, this method lets subclasses (like NestableEventedList modify how they are traversed). """ yield from range(start, len(self) if stop is None else stop) def _ipython_key_completions_(self): if str in self._lookup: return (self._lookup[str](x) for x in self) # type: ignore def _noop(x): return x napari-0.5.0a1/napari/utils/events/custom_types.py000066400000000000000000000053001437041365600222450ustar00rootroot00000000000000from typing import ( TYPE_CHECKING, Any, Callable, Dict, Generator, List, Optional, Type, Union, ) import numpy as np from pydantic import errors, types if TYPE_CHECKING: from decimal import Decimal from pydantic.fields import ModelField Number = Union[int, float, Decimal] class Array(np.ndarray): def __class_getitem__(cls, t): return type('Array', (Array,), {'__dtype__': t}) @classmethod def __get_validators__(cls): yield cls.validate_type @classmethod def validate_type(cls, val): dtype = getattr(cls, '__dtype__', None) if isinstance(dtype, tuple): dtype, shape = dtype else: shape = tuple() result = np.array(val, dtype=dtype, copy=False, ndmin=len(shape)) if any( (shape[i] != -1 and shape[i] != result.shape[i]) for i in range(len(shape)) ): result = result.reshape(shape) return result class NumberNotEqError(errors.PydanticValueError): code = 'number.not_eq' msg_template = 'ensure this value is not equal to {prohibited}' def __init__(self, *, prohibited: 'Number') -> None: super().__init__(prohibited=prohibited) class ConstrainedInt(types.ConstrainedInt): """ConstrainedInt extension that adds not-equal""" ne: Optional[Union[int, List[int]]] = None @classmethod def __modify_schema__(cls, field_schema: Dict[str, Any]) -> None: super().__modify_schema__(field_schema) if cls.ne is not None: f = 'const' if isinstance(cls.ne, int) else 'enum' field_schema['not'] = {f: cls.ne} @classmethod def __get_validators__(cls) -> Generator[Callable[..., Any], None, None]: yield from super().__get_validators__() yield cls.validate_ne @staticmethod def validate_ne(v: 'Number', field: 'ModelField') -> 'Number': field_type: ConstrainedInt = field.type_ _ne = field_type.ne if _ne is not None and v in (_ne if isinstance(_ne, list) else [_ne]): raise NumberNotEqError(prohibited=field_type.ne) return v def conint( *, strict: bool = False, gt: int = None, ge: int = None, lt: int = None, le: int = None, multiple_of: int = None, ne: int = None, ) -> Type[int]: """Extended version of `pydantic.types.conint` that includes not-equal.""" # use kwargs then define conf in a dict to aid with IDE type hinting namespace = dict( strict=strict, gt=gt, ge=ge, lt=lt, le=le, multiple_of=multiple_of, ne=ne, ) return type('ConstrainedIntValue', (ConstrainedInt,), namespace) napari-0.5.0a1/napari/utils/events/debugging.py000066400000000000000000000101021437041365600214360ustar00rootroot00000000000000import inspect import os import site from textwrap import indent from typing import TYPE_CHECKING, ClassVar, Set from pydantic import BaseSettings, Field, PrivateAttr from napari.utils.misc import ROOT_DIR from napari.utils.translations import trans try: from rich import print except ModuleNotFoundError: print( trans._( "TIP: run `pip install rich` for much nicer event debug printout." ) ) try: import dotenv except ModuleNotFoundError: dotenv = None # type: ignore if TYPE_CHECKING: from napari.utils.events.event import Event class EventDebugSettings(BaseSettings): """Parameters controlling how event debugging logs appear. To enable Event debugging: 1. pip install rich pydantic[dotenv] 2. export NAPARI_DEBUG_EVENTS=1 # or modify the .env_sample file 3. see .env_sample file for ways to set these fields here. """ # event emitters (e.g. 'Shapes') and event names (e.g. 'set_data') # to include/exclude when printing events. include_emitters: Set[str] = Field(default_factory=set) include_events: Set[str] = Field(default_factory=set) exclude_emitters: Set[str] = {'TransformChain', 'Context'} exclude_events: Set[str] = {'status', 'position'} # stack depth to show stack_depth: int = 20 # how many sub-emit nesting levels to show # (i.e. events that get triggered by other events) nesting_allowance: int = 0 _cur_depth: ClassVar[int] = PrivateAttr(0) class Config: env_prefix = 'event_debug_' env_file = '.env' if dotenv is not None else '' _SETTINGS = EventDebugSettings() _SP = site.getsitepackages()[0] _STD_LIB = site.__file__.rsplit(os.path.sep, 1)[0] def _shorten_fname(fname: str) -> str: """Reduce extraneous stuff from filenames""" fname = fname.replace(_SP, '.../site-packages') fname = fname.replace(_STD_LIB, '.../python') return fname.replace(ROOT_DIR, "napari") def log_event_stack(event: 'Event', cfg: EventDebugSettings = _SETTINGS): """Print info about what caused this event to be emitted.s""" if cfg.include_events: if event.type not in cfg.include_events: return elif event.type in cfg.exclude_events: return source = type(event.source).__name__ if cfg.include_emitters: if source not in cfg.include_emitters: return elif source in cfg.exclude_emitters: return # get values being emitted vals = ",".join(f"{k}={v}" for k, v in event._kwargs.items()) # show event type and source lines = [f'{source}.events.{event.type}({vals})'] # climb stack and show what caused it. # note, we start 2 frames back in the stack, one frame for *this* function # and the second frame for the EventEmitter.__call__ function (where this # function was likely called). call_stack = inspect.stack(0) for frame in call_stack[2 : 2 + cfg.stack_depth]: fname = _shorten_fname(frame.filename) obj = '' if 'self' in frame.frame.f_locals: obj = type(frame.frame.f_locals['self']).__name__ + '.' ln = f' "{fname}", line {frame.lineno}, in {obj}{frame.function}' lines.append(ln) lines.append("") # find the first caller in the call stack for f in reversed(call_stack): if 'self' in f.frame.f_locals: obj_type = type(f.frame.f_locals['self']) module = obj_type.__module__ or '' if module.startswith("napari"): trigger = f'{obj_type.__name__}.{f.function}()' lines.insert(1, f' was triggered by {trigger}, via:') break # seperate groups of events if not cfg._cur_depth: lines = ["─" * 79, ''] + lines elif not cfg.nesting_allowance: return # log it print(indent("\n".join(lines), ' ' * cfg._cur_depth)) # spy on nested events... # (i.e. events that were emitted while another was being emitted) def _pop_source(): cfg._cur_depth -= 1 return event._sources.pop() event._pop_source = _pop_source cfg._cur_depth += 1 napari-0.5.0a1/napari/utils/events/event.py000066400000000000000000001300051437041365600206310ustar00rootroot00000000000000# Copyright (c) Vispy Development Team. All Rights Reserved. # Distributed under the (new) BSD License. See LICENSE.txt for more info. # # LICENSE.txt # Vispy licensing terms # --------------------- # Vispy is licensed under the terms of the (new) BSD license: # # Copyright (c) 2013-2017, Vispy Development Team. All rights reserved. # # Redistribution and use in source and binary forms, with or without # modification, are permitted provided that the following conditions are met: # * Redistributions of source code must retain the above copyright # notice, this list of conditions and the following disclaimer. # * Redistributions in binary form must reproduce the above copyright # notice, this list of conditions and the following disclaimer in the # documentation and/or other materials provided with the distribution. # * Neither the name of Vispy Development Team nor the names of its # contributors may be used to endorse or promote products # derived from this software without specific prior written permission. # # THIS SOFTWARE IS PROVIDED BY THE COPYRIGHT HOLDERS AND CONTRIBUTORS "AS # IS" AND ANY EXPRESS OR IMPLIED WARRANTIES, INCLUDING, BUT NOT LIMITED # TO, THE IMPLIED WARRANTIES OF MERCHANTABILITY AND FITNESS FOR A # PARTICULAR PURPOSE ARE DISCLAIMED. IN NO EVENT SHALL THE COPYRIGHT OWNER # OR CONTRIBUTORS BE LIABLE FOR ANY DIRECT, INDIRECT, INCIDENTAL, SPECIAL, # EXEMPLARY, OR CONSEQUENTIAL DAMAGES (INCLUDING, BUT NOT LIMITED TO, # PROCUREMENT OF SUBSTITUTE GOODS OR SERVICES; LOSS OF USE, DATA, OR # PROFITS; OR BUSINESS INTERRUPTION) HOWEVER CAUSED AND ON ANY THEORY OF # LIABILITY, WHETHER IN CONTRACT, STRICT LIABILITY, OR TORT (INCLUDING # NEGLIGENCE OR OTHERWISE) ARISING IN ANY WAY OUT OF THE USE OF THIS # SOFTWARE, EVEN IF ADVISED OF THE POSSIBILITY OF SUCH DAMAGE. # # # Exceptions # ---------- # # The examples code in the examples directory can be considered public # domain, unless otherwise indicated in the corresponding source file. """ The event module implements the classes that make up the event system. The Event class and its subclasses are used to represent "stuff that happens". The EventEmitter class provides an interface to connect to events and to emit events. The EmitterGroup groups EventEmitter objects. For more information see http://github.com/vispy/vispy/wiki/API_Events """ import inspect import os import warnings import weakref from collections.abc import Sequence from functools import partial from typing import ( Any, Callable, Dict, Generator, List, Literal, Optional, Tuple, Type, Union, cast, ) from vispy.util.logs import _handle_exception from napari.utils.translations import trans class Event: """Class describing events that occur and can be reacted to with callbacks. Each event instance contains information about a single event that has occurred such as a key press, mouse motion, timer activation, etc. Subclasses: :class:`KeyEvent`, :class:`MouseEvent`, :class:`TouchEvent`, :class:`StylusEvent` The creation of events and passing of events to the appropriate callback functions is the responsibility of :class:`EventEmitter` instances. Note that each event object has an attribute for each of the input arguments listed below. Parameters ---------- type : str String indicating the event type (e.g. mouse_press, key_release) native : object (optional) The native GUI event object **kwargs : keyword arguments All extra keyword arguments become attributes of the event object. """ def __init__(self, type: str, native: Any = None, **kwargs: Any) -> None: # stack of all sources this event has been emitted through self._sources: List[Any] = [] self._handled: bool = False self._blocked: bool = False # Store args self._type = type self._native = native self._kwargs = kwargs for k, v in kwargs.items(): setattr(self, k, v) @property def source(self) -> Any: """The object that the event applies to (i.e. the source of the event).""" return self._sources[-1] if self._sources else None @property def sources(self) -> List[Any]: """List of objects that the event applies to (i.e. are or have been a source of the event). Can contain multiple objects in case the event traverses a hierarchy of objects. """ return self._sources def _push_source(self, source): self._sources.append(source) def _pop_source(self): return self._sources.pop() @property def type(self) -> str: # No docstring; documeted in class docstring return self._type @property def native(self) -> Any: # No docstring; documeted in class docstring return self._native @property def handled(self) -> bool: """This boolean property indicates whether the event has already been acted on by an event handler. Since many handlers may have access to the same events, it is recommended that each check whether the event has already been handled as well as set handled=True if it decides to act on the event. """ return self._handled @handled.setter def handled(self, val) -> bool: self._handled = bool(val) @property def blocked(self) -> bool: """This boolean property indicates whether the event will be delivered to event callbacks. If it is set to True, then no further callbacks will receive the event. When possible, it is recommended to use Event.handled rather than Event.blocked. """ return self._blocked @blocked.setter def blocked(self, val) -> bool: self._blocked = bool(val) def __repr__(self) -> str: # Try to generate a nice string representation of the event that # includes the interesting properties. # need to keep track of depth because it is # very difficult to avoid excessive recursion. global _event_repr_depth _event_repr_depth += 1 try: if _event_repr_depth > 2: return "<...>" attrs = [] for name in dir(self): if name.startswith('_'): continue # select only properties if not hasattr(type(self), name) or not isinstance( getattr(type(self), name), property ): continue attr = getattr(self, name) attrs.append(f"{name}={attr!r}") return "<{} {}>".format(self.__class__.__name__, " ".join(attrs)) finally: _event_repr_depth -= 1 def __str__(self) -> str: """Shorter string representation""" return self.__class__.__name__ # mypy fix for dynamic attribute access def __getattr__(self, name: str) -> Any: return object.__getattribute__(self, name) _event_repr_depth = 0 Callback = Union[Callable[[Event], None], Callable[[], None]] CallbackRef = Tuple['weakref.ReferenceType[Any]', str] # dereferenced method CallbackStr = Tuple[ Union['weakref.ReferenceType[Any]', object], str ] # dereferenced method class _WeakCounter: """ Similar to collection counter but has weak keys. It will only implement the methods we use here. """ def __init__(self) -> None: self._counter = weakref.WeakKeyDictionary() self._nonecount = 0 def update(self, iterable): for it in iterable: if it is None: self._nonecount += 1 else: self._counter[it] = self.get(it, 0) + 1 def get(self, key, default): if key is None: return self._nonecount return self._counter.get(key, default) class EventEmitter: """Encapsulates a list of event callbacks. Each instance of EventEmitter represents the source of a stream of similar events, such as mouse click events or timer activation events. For example, the following diagram shows the propagation of a mouse click event to the list of callbacks that are registered to listen for that event:: User clicks |Canvas creates mouse on |MouseEvent: |'mouse_press' EventEmitter: |callbacks in sequence: # noqa Canvas | | | # noqa -->|event = MouseEvent(...) -->|Canvas.events.mouse_press(event) -->|callback1(event) # noqa | | -->|callback2(event) # noqa | | -->|callback3(event) # noqa Callback functions may be added or removed from an EventEmitter using :func:`connect() ` or :func:`disconnect() `. Calling an instance of EventEmitter will cause each of its callbacks to be invoked in sequence. All callbacks are invoked with a single argument which will be an instance of :class:`Event `. EventEmitters are generally created by an EmitterGroup instance. Parameters ---------- source : object The object that the generated events apply to. All emitted Events will have their .source property set to this value. type : str or None String indicating the event type (e.g. mouse_press, key_release) event_class : subclass of Event The class of events that this emitter will generate. """ def __init__( self, source: Any = None, type: Optional[str] = None, event_class: Type[Event] = Event, ) -> None: # connected callbacks self._callbacks: List[Union[Callback, CallbackRef]] = [] # used when connecting new callbacks at specific positions self._callback_refs: List[Optional[str]] = [] self._callback_pass_event: List[bool] = [] # count number of times this emitter is blocked for each callback. self._blocked: Dict[Optional[Callback], int] = {None: 0} self._block_counter: _WeakCounter[Optional[Callback]] = _WeakCounter() # used to detect emitter loops self._emitting = False self.source = source self.default_args = {} if type is not None: self.default_args['type'] = type assert inspect.isclass(event_class) self.event_class = event_class self._ignore_callback_errors: bool = False # True self.print_callback_errors = 'reminders' # 'reminders' @property def ignore_callback_errors(self) -> bool: """Whether exceptions during callbacks will be caught by the emitter This allows it to continue invoking other callbacks if an error occurs. """ return self._ignore_callback_errors @ignore_callback_errors.setter def ignore_callback_errors(self, val: bool): self._ignore_callback_errors = val @property def print_callback_errors(self) -> str: """Print a message and stack trace if a callback raises an exception Valid values are "first" (only show first instance), "reminders" (show complete first instance, then counts), "always" (always show full traceback), or "never". This assumes ignore_callback_errors=True. These will be raised as warnings, so ensure that the vispy logging level is set to at least "warning". """ return self._print_callback_errors @print_callback_errors.setter def print_callback_errors( self, val: Union[ Literal['first'], Literal['reminders'], Literal['always'], Literal['never'], ], ): if val not in ('first', 'reminders', 'always', 'never'): raise ValueError( trans._( 'print_callback_errors must be "first", "reminders", "always", or "never"', deferred=True, ) ) self._print_callback_errors = val @property def callback_refs(self) -> Tuple[Optional[str], ...]: """The set of callback references""" return tuple(self._callback_refs) @property def callbacks(self) -> Tuple[Union[Callback, CallbackRef], ...]: """The set of callbacks""" return tuple(self._callbacks) @property def source(self) -> Any: """The object that events generated by this emitter apply to""" return ( None if self._source is None else self._source() ) # get object behind weakref @source.setter def source(self, s): self._source = None if s is None else weakref.ref(s) def _is_core_callback( self, callback: Union[CallbackRef, Callback], core: str ): """ Check if the callback is a core callback Parameters ---------- callback : Union[CallbackRef, Callback] The callback to check. Callback could be function or weak reference to object method coded using weakreference to object and method name stored in tuple. core : str Name of core module, for example 'napari'. """ try: if isinstance(callback, partial): callback = callback.func if not isinstance(callback, tuple): return callback.__module__.startswith(core + '.') obj = callback[0]() # get object behind weakref if obj is None: # object is dead return False return obj.__module__.startswith(core + '.') except AttributeError: return False def connect( self, callback: Union[Callback, CallbackRef, CallbackStr, 'EventEmitter'], ref: Union[bool, str] = False, position: Union[Literal['first'], Literal['last']] = 'first', before: Union[str, Callback, List[Union[str, Callback]], None] = None, after: Union[str, Callback, List[Union[str, Callback]], None] = None, until: Optional['EventEmitter'] = None, ): """Connect this emitter to a new callback. Parameters ---------- callback : function | tuple *callback* may be either a callable object or a tuple (object, attr_name) where object.attr_name will point to a callable object. Note that only a weak reference to ``object`` will be kept. ref : bool | str Reference used to identify the callback in ``before``/``after``. If True, the callback ref will automatically determined (see Notes). If False, the callback cannot be referred to by a string. If str, the given string will be used. Note that if ``ref`` is not unique in ``callback_refs``, an error will be thrown. position : str If ``'first'``, the first eligible position is used (that meets the before and after criteria), ``'last'`` will use the last position. before : str | callback | list of str or callback | None List of callbacks that the current callback should precede. Can be None if no before-criteria should be used. after : str | callback | list of str or callback | None List of callbacks that the current callback should follow. Can be None if no after-criteria should be used. until : optional eventEmitter if provided, when the event `until` is emitted, `callback` will be disconnected from this emitter. Notes ----- If ``ref=True``, the callback reference will be determined from: 1. If ``callback`` is ``tuple``, the second element in the tuple. 2. The ``__name__`` attribute. 3. The ``__class__.__name__`` attribute. The current list of callback refs can be obtained using ``event.callback_refs``. Callbacks can be referred to by either their string reference (if given), or by the actual callback that was attached (e.g., ``(canvas, 'swap_buffers')``). If the specified callback is already connected, then the request is ignored. If before is None and after is None (default), the new callback will be added to the beginning of the callback list. Thus the callback that is connected _last_ will be the _first_ to receive events from the emitter. """ callbacks = self.callbacks callback_refs = self.callback_refs old_callback = callback callback, pass_event = self._normalize_cb(callback) if callback in callbacks: return # deal with the ref _ref: Union[str, None] if isinstance(ref, bool): if ref: if isinstance(callback, tuple): _ref = callback[1] elif hasattr(callback, '__name__'): # function _ref = callback.__name__ else: # Method, or other _ref = callback.__class__.__name__ else: _ref = None elif isinstance(ref, str): _ref = ref else: raise TypeError( trans._( 'ref must be a bool or string', deferred=True, ) ) if _ref is not None and _ref in self._callback_refs: raise ValueError( trans._('ref "{ref}" is not unique', deferred=True, ref=_ref) ) # positions if position not in ('first', 'last'): raise ValueError( trans._( 'position must be "first" or "last", not {position}', deferred=True, position=position, ) ) core_callbacks_indexes = [ i for i, c in enumerate(self._callbacks) if self._is_core_callback(c, 'napari') ] core_callbacks_count = ( max(core_callbacks_indexes) + 1 if core_callbacks_indexes else 0 ) if self._is_core_callback(callback, 'napari'): callback_bounds = (0, core_callbacks_count) else: callback_bounds = (core_callbacks_count, len(callback_refs)) # bounds: upper & lower bnds (inclusive) of possible cb locs bounds: List[int] = [] for ri, criteria in enumerate((before, after)): if criteria is None or criteria == []: bounds.append( callback_bounds[1] if ri == 0 else callback_bounds[0] ) else: if not isinstance(criteria, list): criteria = [criteria] for c in criteria: count = sum( c in [cn, cc] for cn, cc in zip(callback_refs, callbacks) ) if count != 1: raise ValueError( trans._( 'criteria "{criteria}" is in the current callback list {count} times:\n{callback_refs}\n{callbacks}', deferred=True, criteria=criteria, count=count, callback_refs=callback_refs, callbacks=callbacks, ) ) matches = [ ci for ci, (cn, cc) in enumerate( zip(callback_refs, callbacks) ) if (cc in criteria or cn in criteria) ] bounds.append(matches[0] if ri == 0 else (matches[-1] + 1)) if bounds[0] < bounds[1]: # i.e., "place before" < "place after" raise RuntimeError( trans._( 'cannot place callback before "{before}" and after "{after}" for callbacks: {callback_refs}', deferred=True, before=before, after=after, callback_refs=callback_refs, ) ) idx = bounds[1] if position == 'first' else bounds[0] # 'last' # actually add the callback self._callbacks.insert(idx, callback) self._callback_refs.insert(idx, _ref) self._callback_pass_event.insert(idx, pass_event) if until is not None: until.connect(partial(self.disconnect, callback)) return old_callback # allows connect to be used as a decorator def disconnect( self, callback: Union[Callback, CallbackRef, None, object] = None ): """Disconnect a callback from this emitter. If no callback is specified, then *all* callbacks are removed. If the callback was not already connected, then the call does nothing. """ if callback is None: self._callbacks = [] self._callback_refs = [] self._callback_pass_event = [] elif isinstance(callback, (Callable, tuple)): callback, _pass_event = self._normalize_cb(callback) if callback in self._callbacks: idx = self._callbacks.index(callback) self._callbacks.pop(idx) self._callback_refs.pop(idx) self._callback_pass_event.pop(idx) else: index_list = [] for idx, local_callback in enumerate(self._callbacks): if not ( isinstance(local_callback, Sequence) and isinstance(local_callback[0], weakref.ref) ): continue if ( local_callback[0]() is callback or local_callback[0]() is None ): index_list.append(idx) for idx in index_list[::-1]: self._callbacks.pop(idx) self._callback_refs.pop(idx) self._callback_pass_event.pop(idx) @staticmethod def _get_proper_name(callback): assert inspect.ismethod(callback) obj = callback.__self__ if ( not hasattr(obj, callback.__name__) or getattr(obj, callback.__name__) != callback ): # some decorators will alter method.__name__, so that obj.method # will not be equal to getattr(obj, obj.method.__name__). We check # for that case here and traverse to find the right method here. for name in dir(obj): meth = getattr(obj, name) if inspect.ismethod(meth) and meth == callback: return obj, name raise RuntimeError( trans._( "During bind method {callback} of object {obj} an error happen", deferred=True, callback=callback, obj=obj, ) ) return obj, callback.__name__ @staticmethod def _check_signature(fun: Callable) -> bool: """ Check if function will accept event parameter """ signature = inspect.signature(fun) parameters_list = list(signature.parameters.values()) if sum(map(_is_pos_arg, parameters_list)) > 1: raise RuntimeError( trans._( "Binning function cannot have more than one positional argument", deferred=True, ) ) return any( map( lambda x: x.kind in [ inspect.Parameter.POSITIONAL_ONLY, inspect.Parameter.POSITIONAL_OR_KEYWORD, inspect.Parameter.VAR_POSITIONAL, ], signature.parameters.values(), ) ) def _normalize_cb( self, callback ) -> Tuple[Union[CallbackRef, Callback], bool]: # dereference methods into a (self, method_name) pair so that we can # make the connection without making a strong reference to the # instance. start_callback = callback if inspect.ismethod(callback): callback = self._get_proper_name(callback) # always use a weak ref if isinstance(callback, tuple) and not isinstance( callback[0], weakref.ref ): callback = (weakref.ref(callback[0]), *callback[1:]) if isinstance(start_callback, Callable): callback = callback, self._check_signature(start_callback) else: obj = callback[0]() if obj is None: callback = callback, False else: callback_fun = getattr(obj, callback[1]) callback = callback, self._check_signature(callback_fun) return callback def __call__(self, *args, **kwargs) -> Event: """__call__(**kwargs) Invoke all callbacks for this emitter. Emit a new event object, created with the given keyword arguments, which must match with the input arguments of the corresponding event class. Note that the 'type' argument is filled in by the emitter. Alternatively, the emitter can also be called with an Event instance as the only argument. In this case, the specified Event will be used rather than generating a new one. This allows customized Event instances to be emitted and also allows EventEmitters to be chained by connecting one directly to another. Note that the same Event instance is sent to all callbacks. This allows some level of communication between the callbacks (notably, via Event.handled) but also requires that callbacks be careful not to inadvertently modify the Event. """ # This is a VERY highly used method; must be fast! blocked = self._blocked # create / massage event as needed event = self._prepare_event(*args, **kwargs) # Add our source to the event; remove it after all callbacks have been # invoked. event._push_source(self.source) self._emitting = True try: if blocked.get(None, 0) > 0: # this is the same as self.blocked() self._block_counter.update([None]) return event _log_event_stack(event) rem: List[CallbackRef] = [] for cb, pass_event in zip( self._callbacks[:], self._callback_pass_event[:] ): if isinstance(cb, tuple): obj = cb[0]() if obj is None: rem.append(cb) # add dead weakref continue old_cb = cb cb = getattr(obj, cb[1], None) if cb is None: warnings.warn( trans._( "Problem with function {old_cb} of {obj} connected to event {self_}", deferred=True, old_cb=old_cb[1], obj=obj, self_=self, ), stacklevel=2, category=RuntimeWarning, ) continue cb = cast(Callback, cb) if blocked.get(cb, 0) > 0: self._block_counter.update([cb]) continue self._invoke_callback(cb, event if pass_event else None) if event.blocked: break # remove callbacks to dead objects for cb in rem: self.disconnect(cb) finally: self._emitting = False ps = event._pop_source() if ps is not self.source: raise RuntimeError( trans._( "Event source-stack mismatch.", deferred=True, ) ) return event def _invoke_callback( self, cb: Union[Callback, Callable[[], None]], event: Optional[Event] ): try: if event is not None: cb(event) else: cb() except Exception as e: # noqa: BLE001 # dead Qt object with living python pointer. not importing Qt # here... but this error is consistent across backends if ( isinstance(e, RuntimeError) and 'C++' in str(e) and str(e).endswith(('has been deleted', 'already deleted.')) ): self.disconnect(cb) return _handle_exception( self.ignore_callback_errors, self.print_callback_errors, self, cb_event=(cb, event), ) def _prepare_event(self, *args, **kwargs) -> Event: # When emitting, this method is called to create or otherwise alter # an event before it is sent to callbacks. Subclasses may extend # this method to make custom modifications to the event. if len(args) == 1 and not kwargs and isinstance(args[0], Event): event: Event = args[0] # Ensure that the given event matches what we want to emit assert isinstance(event, self.event_class) elif not args: _kwargs = self.default_args.copy() _kwargs.update(kwargs) event = self.event_class(**_kwargs) else: raise ValueError( trans._( "Event emitters can be called with an Event instance or with keyword arguments only.", deferred=True, ) ) return event def blocked(self, callback: Optional[Callback] = None) -> bool: """Return boolean indicating whether the emitter is blocked for the given callback. """ return self._blocked.get(callback, 0) > 0 def block(self, callback: Optional[Callback] = None): """Block this emitter. Any attempts to emit an event while blocked will be silently ignored. If *callback* is given, then the emitter is only blocked for that specific callback. Calls to block are cumulative; the emitter must be unblocked the same number of times as it is blocked. """ self._blocked[callback] = self._blocked.get(callback, 0) + 1 def unblock(self, callback: Optional[Callback] = None): """Unblock this emitter. See :func:`event.EventEmitter.block`. Note: Use of ``unblock(None)`` only reverses the effect of ``block(None)``; it does not unblock callbacks that were explicitly blocked using ``block(callback)``. """ if callback not in self._blocked or self._blocked[callback] == 0: raise RuntimeError( trans._( "Cannot unblock {self_} for callback {callback}; emitter was not previously blocked.", deferred=True, self_=self, callback=callback, ) ) b = self._blocked[callback] - 1 if b == 0 and callback is not None: del self._blocked[callback] else: self._blocked[callback] = b def blocker(self, callback: Optional[Callback] = None): """Return an EventBlocker to be used in 'with' statements Notes ----- For example, one could do:: with emitter.blocker(): pass # ..do stuff; no events will be emitted.. """ return EventBlocker(self, callback) class WarningEmitter(EventEmitter): """ EventEmitter subclass used to allow deprecated events to be used with a warning message. """ def __init__( self, message, category=FutureWarning, stacklevel=3, *args, **kwargs, ) -> None: self._message = message self._warned = False self._category = category self._stacklevel = stacklevel EventEmitter.__init__(self, *args, **kwargs) def connect(self, cb, *args, **kwargs): self._warn(cb) return EventEmitter.connect(self, cb, *args, **kwargs) def _invoke_callback(self, cb, event): self._warn(cb) return EventEmitter._invoke_callback(self, cb, event) def _warn(self, cb): if self._warned: return # don't warn about unimplemented connections if isinstance(cb, tuple) and getattr(cb[0], cb[1], None) is None: return import warnings warnings.warn( self._message, category=self._category, stacklevel=self._stacklevel ) self._warned = True class EmitterGroup(EventEmitter): """EmitterGroup instances manage a set of related :class:`EventEmitters `. Its primary purpose is to provide organization for objects that make use of multiple emitters and to reduce the boilerplate code needed to initialize those emitters with default connections. EmitterGroup instances are usually stored as an 'events' attribute on objects that use multiple emitters. For example:: EmitterGroup EventEmitter | | Canvas.events.mouse_press Canvas.events.resized Canvas.events.key_press EmitterGroup is also a subclass of :class:`EventEmitters `, allowing it to emit its own events. Any callback that connects directly to the EmitterGroup will receive *all* of the events generated by the group's emitters. Parameters ---------- source : object The object that the generated events apply to. auto_connect : bool If *auto_connect* is True, then one connection will be made for each emitter that looks like :func:`emitter.connect((source, 'on_' + event_name)) `. This provides a simple mechanism for automatically connecting a large group of emitters to default callbacks. By default, false. emitters : keyword arguments See the :func:`add ` method. """ def __init__( self, source: Any = None, auto_connect: bool = False, **emitters: Union[Type[Event], EventEmitter, None], ) -> None: EventEmitter.__init__(self, source) self.auto_connect = auto_connect self.auto_connect_format = "on_%s" self._emitters: Dict[str, EventEmitter] = dict() # whether the sub-emitters have been connected to the group: self._emitters_connected: bool = False self.add(**emitters) # type: ignore def __getattr__(self, name) -> EventEmitter: return object.__getattribute__(self, name) def __getitem__(self, name: str) -> EventEmitter: """ Return the emitter assigned to the specified name. Note that emitters may also be retrieved as an attribute of the EmitterGroup. """ return self._emitters[name] def __setitem__( self, name: str, emitter: Union[Type[Event], EventEmitter, None] ): """ Alias for EmitterGroup.add(name=emitter) """ self.add(**{name: emitter}) # type: ignore def add( self, auto_connect: Optional[bool] = None, **kwargs: Union[Type[Event], EventEmitter, None], ): """Add one or more EventEmitter instances to this emitter group. Each keyword argument may be specified as either an EventEmitter instance or an Event subclass, in which case an EventEmitter will be generated automatically:: # This statement: group.add(mouse_press=MouseEvent, mouse_release=MouseEvent) # ..is equivalent to this statement: group.add(mouse_press=EventEmitter(group.source, 'mouse_press', MouseEvent), mouse_release=EventEmitter(group.source, 'mouse_press', MouseEvent)) """ if auto_connect is None: auto_connect = self.auto_connect # check all names before adding anything for name in kwargs: if name in self._emitters: raise ValueError( trans._( "EmitterGroup already has an emitter named '{name}'", deferred=True, name=name, ) ) elif hasattr(self, name): raise ValueError( trans._( "The name '{name}' cannot be used as an emitter; it is already an attribute of EmitterGroup", deferred=True, name=name, ) ) # add each emitter specified in the keyword arguments for name, emitter in kwargs.items(): if emitter is None: emitter = Event if inspect.isclass(emitter) and issubclass(emitter, Event): # type: ignore emitter = EventEmitter( source=self.source, type=name, event_class=emitter # type: ignore ) elif not isinstance(emitter, EventEmitter): raise RuntimeError( trans._( 'Emitter must be specified as either an EventEmitter instance or Event subclass. (got {name}={emitter})', deferred=True, name=name, emitter=emitter, ) ) # give this emitter the same source as the group. emitter.source = self.source setattr(self, name, emitter) # this is a bummer for typing. self._emitters[name] = emitter if ( auto_connect and self.source is not None and hasattr(self.source, self.auto_connect_format % name) ): emitter.connect((self.source, self.auto_connect_format % name)) # If emitters are connected to the group already, then this one # should be connected as well. if self._emitters_connected: emitter.connect(self) @property def emitters(self) -> Dict[str, EventEmitter]: """List of current emitters in this group.""" return self._emitters def __iter__(self) -> Generator[str, None, None]: """ Iterates over the names of emitters in this group. """ yield from self._emitters def block_all(self): """ Block all emitters in this group by increase counter of semaphores for each event emitter """ self.block() for em in self._emitters.values(): em.block() def unblock_all(self): """ Unblock all emitters in this group, by decrease counter of semaphores for each event emitter. if block is called twice and unblock is called once, then events will be still blocked. See `Semaphore (programming) `__. """ self.unblock() for em in self._emitters.values(): em.unblock() def connect( self, callback: Union[Callback, CallbackRef, 'EmitterGroup'], ref: Union[bool, str] = False, position: Union[Literal['first'], Literal['last']] = 'first', before: Union[str, Callback, List[Union[str, Callback]], None] = None, after: Union[str, Callback, List[Union[str, Callback]], None] = None, ): """Connect the callback to the event group. The callback will receive events from *all* of the emitters in the group. See :func:`EventEmitter.connect() ` for arguments. """ self._connect_emitters(True) return EventEmitter.connect( self, callback, ref, position, before, after ) def disconnect(self, callback: Optional[Callback] = None): """Disconnect the callback from this group. See :func:`connect() ` and :func:`EventEmitter.connect() ` for more information. """ ret = EventEmitter.disconnect(self, callback) if len(self._callbacks) == 0: self._connect_emitters(False) return ret def _connect_emitters(self, connect): # Connect/disconnect all sub-emitters from the group. This allows the # group to emit an event whenever _any_ of the sub-emitters emit, # while simultaneously eliminating the overhead if nobody is listening. if connect: for emitter in self: if not isinstance(self[emitter], WarningEmitter): self[emitter].connect(self) else: for emitter in self: self[emitter].disconnect(self) self._emitters_connected = connect @property def ignore_callback_errors(self): return super().ignore_callback_errors @ignore_callback_errors.setter def ignore_callback_errors(self, ignore): EventEmitter.ignore_callback_errors.fset(self, ignore) for emitter in self._emitters.values(): if isinstance(emitter, EventEmitter): emitter.ignore_callback_errors = ignore elif isinstance(emitter, EmitterGroup): emitter.ignore_callback_errors_all(ignore) def blocker_all(self) -> 'EventBlockerAll': """Return an EventBlockerAll to be used in 'with' statements Notes ----- For example, one could do:: with emitter.blocker_all(): pass # ..do stuff; no events will be emitted.. """ return EventBlockerAll(self) class EventBlocker: """Represents a block for an EventEmitter to be used in a context manager (i.e. 'with' statement). """ def __init__(self, target, callback=None) -> None: self.target = target self.callback = callback self._base_count = target._block_counter.get(callback, 0) @property def count(self): n_blocked = self.target._block_counter.get(self.callback, 0) return n_blocked - self._base_count def __enter__(self): self.target.block(self.callback) return self def __exit__(self, *args): self.target.unblock(self.callback) class EventBlockerAll: """Represents a block_all for an EmitterGroup to be used in a context manager (i.e. 'with' statement). """ def __init__(self, target) -> None: self.target = target def __enter__(self): self.target.block_all() def __exit__(self, *args): self.target.unblock_all() def _is_pos_arg(param: inspect.Parameter): """ Check if param is positional or named and has no default parameter. """ return ( param.kind in [ inspect.Parameter.POSITIONAL_ONLY, inspect.Parameter.POSITIONAL_OR_KEYWORD, ] and param.default == inspect.Parameter.empty ) try: # this could move somewhere higher up in napari imports ... but where? __import__('dotenv').load_dotenv() except ImportError: pass def _noop(*a, **k): pass _log_event_stack = _noop def set_event_tracing_enabled(enabled=True, cfg=None): global _log_event_stack if enabled: from napari.utils.events.debugging import log_event_stack if cfg is not None: _log_event_stack = partial(log_event_stack, cfg=cfg) else: _log_event_stack = log_event_stack else: _log_event_stack = _noop if os.getenv("NAPARI_DEBUG_EVENTS", '').lower() in ('1', 'true'): set_event_tracing_enabled(True) napari-0.5.0a1/napari/utils/events/event_utils.py000066400000000000000000000031751437041365600220600ustar00rootroot00000000000000from __future__ import annotations import weakref from typing import TYPE_CHECKING if TYPE_CHECKING: from typing import Callable, Protocol class Emitter(Protocol): def connect(self, callback: Callable): ... def disconnect(self, callback: Callable): ... def disconnect_events(emitter, listener): """Disconnect all events between an emitter group and a listener. Parameters ---------- emitter : napari.utils.events.event.EmitterGroup Emitter group. listener : Object Any object that has been connected to. """ for em in emitter.emitters.values(): em.disconnect(listener) def connect_setattr(emitter: Emitter, obj, attr: str): ref = weakref.ref(obj) def _cb(*value): setattr(ref(), attr, value[0] if len(value) == 1 else value) emitter.connect(_cb) # There are scenarios where emitter is deleted before obj. # Also there is no option to create weakref to QT Signal # but even if keep reference to base object and signal name it is possible to meet # problem with C++ "wrapped C/C++ object has been deleted" # weakref.finalize(obj, emitter.disconnect, _cb) def connect_no_arg(emitter: Emitter, obj, attr: str): ref = weakref.ref(obj) def _cb(*_value): getattr(ref(), attr)() emitter.connect(_cb) # as in connect_setattr # weakref.finalize(obj, emitter.disconnect, _cb) def connect_setattr_value(emitter: Emitter, obj, attr: str): """To get value from Event""" ref = weakref.ref(obj) def _cb(value): setattr(ref(), attr, value.value) emitter.connect(_cb) napari-0.5.0a1/napari/utils/events/evented_model.py000066400000000000000000000353061437041365600223320ustar00rootroot00000000000000import operator import sys import warnings from contextlib import contextmanager from typing import Any, Callable, ClassVar, Dict, Set, Union import numpy as np from app_model.types import KeyBinding from pydantic import BaseModel, PrivateAttr, main, utils from napari.utils.events.event import EmitterGroup, Event from napari.utils.misc import pick_equality_operator from napari.utils.translations import trans # encoders for non-napari specific field types. To declare a custom encoder # for a napari type, add a `_json_encode` method to the class itself. # it will be added to the model json_encoders in :func:`EventedMetaclass.__new__` _BASE_JSON_ENCODERS = { np.ndarray: lambda arr: arr.tolist(), KeyBinding: lambda v: str(v), } @contextmanager def no_class_attributes(): """Context in which pydantic.main.ClassAttribute just passes value 2. Due to a very annoying decision by PySide2, all class ``__signature__`` attributes may only be assigned **once**. (This seems to be regardless of whether the class has anything to do with PySide2 or not). Furthermore, the PySide2 ``__signature__`` attribute seems to break the python descriptor protocol, which means that class attributes that have a ``__get__`` method will not be able to successfully retrieve their value (instead, the descriptor object itself will be accessed). This plays terribly with Pydantic, which assigns a ``ClassAttribute`` object to the value of ``cls.__signature__`` in ``ModelMetaclass.__new__`` in order to avoid masking the call signature of object instances that have a ``__call__`` method (https://github.com/samuelcolvin/pydantic/pull/1466). So, because we only get to set the ``__signature__`` once, this context manager basically "opts-out" of pydantic's ``ClassAttribute`` strategy, thereby directly setting the ``cls.__signature__`` to an instance of ``inspect.Signature``. For additional context, see: - https://github.com/napari/napari/issues/2264 - https://github.com/napari/napari/pull/2265 - https://bugreports.qt.io/browse/PYSIDE-1004 - https://codereview.qt-project.org/c/pyside/pyside-setup/+/261411 """ if "PySide2" not in sys.modules: yield return # monkey patch the pydantic ClassAttribute object # the second argument to ClassAttribute is the inspect.Signature object def _return2(x, y): return y main.ClassAttribute = _return2 try: yield finally: # undo our monkey patch main.ClassAttribute = utils.ClassAttribute class EventedMetaclass(main.ModelMetaclass): """pydantic ModelMetaclass that preps "equality checking" operations. A metaclass is the thing that "constructs" a class, and ``ModelMetaclass`` is where pydantic puts a lot of it's type introspection and ``ModelField`` creation logic. Here, we simply tack on one more function, that builds a ``cls.__eq_operators__`` dict which is mapping of field name to a function that can be called to check equality of the value of that field with some other object. (used in ``EventedModel.__eq__``) This happens only once, when an ``EventedModel`` class is created (and not when each instance of an ``EventedModel`` is instantiated). """ def __new__(mcs, name, bases, namespace, **kwargs): with no_class_attributes(): cls = super().__new__(mcs, name, bases, namespace, **kwargs) cls.__eq_operators__ = {} for n, f in cls.__fields__.items(): cls.__eq_operators__[n] = pick_equality_operator(f.type_) # If a field type has a _json_encode method, add it to the json # encoders for this model. # NOTE: a _json_encode field must return an object that can be # passed to json.dumps ... but it needn't return a string. if hasattr(f.type_, '_json_encode'): encoder = f.type_._json_encode cls.__config__.json_encoders[f.type_] = encoder # also add it to the base config # required for pydantic>=1.8.0 due to: # https://github.com/samuelcolvin/pydantic/pull/2064 EventedModel.__config__.json_encoders[f.type_] = encoder # check for @_.setters defined on the class, so we can allow them # in EventedModel.__setattr__ cls.__property_setters__ = {} for name, attr in namespace.items(): if isinstance(attr, property) and attr.fset is not None: cls.__property_setters__[name] = attr cls.__field_dependents__ = _get_field_dependents(cls) return cls def _get_field_dependents(cls: 'EventedModel') -> Dict[str, Set[str]]: """Return mapping of field name -> dependent set of property names. Dependencies may be declared in the Model Config to emit an event for a computed property when a model field that it depends on changes e.g. (@property 'c' depends on model fields 'a' and 'b') Examples -------- class MyModel(EventedModel): a: int = 1 b: int = 1 @property def c(self) -> List[int]: return [self.a, self.b] @c.setter def c(self, val: Sequence[int]): self.a, self.b = val class Config: dependencies={'c': ['a', 'b']} """ if not cls.__property_setters__: return {} deps: Dict[str, Set[str]] = {} _deps = getattr(cls.__config__, 'dependencies', None) if _deps: for prop, fields in _deps.items(): if prop not in cls.__property_setters__: raise ValueError( 'Fields with dependencies must be property.setters. ' f'{prop!r} is not.' ) for field in fields: if field not in cls.__fields__: warnings.warn(f"Unrecognized field dependency: {field}") deps.setdefault(field, set()).add(prop) else: # if dependencies haven't been explicitly defined, we can glean # them from the property.fget code object: for prop, setter in cls.__property_setters__.items(): for name in setter.fget.__code__.co_names: if name in cls.__fields__: deps.setdefault(name, set()).add(prop) return deps class EventedModel(BaseModel, metaclass=EventedMetaclass): """A Model subclass that emits an event whenever a field value is changed. Note: As per the standard pydantic behavior, default Field values are not validated (#4138) and should be correctly typed. """ # add private attributes for event emission _events: EmitterGroup = PrivateAttr(default_factory=EmitterGroup) # mapping of name -> property obj for methods that are property setters __property_setters__: ClassVar[Dict[str, property]] # mapping of field name -> dependent set of property names # when field is changed, an event for dependent properties will be emitted. __field_dependents__: ClassVar[Dict[str, Set[str]]] __eq_operators__: ClassVar[Dict[str, Callable[[Any, Any], bool]]] __slots__: ClassVar[Set[str]] = {"__weakref__"} # type: ignore # pydantic BaseModel configuration. see: # https://pydantic-docs.helpmanual.io/usage/model_config/ class Config: # whether to allow arbitrary user types for fields (they are validated # simply by checking if the value is an instance of the type). If # False, RuntimeError will be raised on model declaration arbitrary_types_allowed = True # whether to perform validation on assignment to attributes validate_assignment = True # whether to treat any underscore non-class var attrs as private # https://pydantic-docs.helpmanual.io/usage/models/#private-model-attributes underscore_attrs_are_private = True # whether to validate field defaults (default: False) validate_all = True # https://pydantic-docs.helpmanual.io/usage/exporting_models/#modeljson # NOTE: json_encoders are also added EventedMetaclass.__new__ if the # field declares a _json_encode method. json_encoders = _BASE_JSON_ENCODERS def __init__(self, **kwargs) -> None: super().__init__(**kwargs) self._events.source = self # add event emitters for each field which is mutable field_events = [ name for name, field in self.__fields__.items() if field.field_info.allow_mutation ] self._events.add( **dict.fromkeys(field_events + list(self.__property_setters__)) ) # while seemingly redundant, this next line is very important to maintain # correct sources; see https://github.com/napari/napari/pull/4138 # we solve it by re-setting the source after initial validation, which allows # us to use `validate_all = True` self._reset_event_source() def _super_setattr_(self, name: str, value: Any) -> None: # pydantic will raise a ValueError if extra fields are not allowed # so we first check to see if this field has a property.setter. # if so, we use it instead. if name in self.__property_setters__: self.__property_setters__[name].fset(self, value) else: super().__setattr__(name, value) def __setattr__(self, name: str, value: Any) -> None: if name not in getattr(self, 'events', {}): # fallback to default behavior self._super_setattr_(name, value) return # grab current value before = getattr(self, name, object()) # set value using original setter self._super_setattr_(name, value) # if different we emit the event with new value after = getattr(self, name) are_equal = self.__eq_operators__.get(name, operator.eq) if not are_equal(after, before): getattr(self.events, name)(value=after) # emit event # emit events for any dependent computed property setters as well for dep in self.__field_dependents__.get(name, {}): getattr(self.events, dep)(value=getattr(self, dep)) # expose the private EmitterGroup publically @property def events(self) -> EmitterGroup: return self._events def _reset_event_source(self): """ set the event sources of self and all the children to the correct values """ # events are all messed up due to objects being probably # recreated arbitrarily during validation self.events.source = self for name in self.__fields__: child = getattr(self, name) if isinstance(child, EventedModel): # TODO: this isinstance check should be EventedMutables in the future child._reset_event_source() elif name in self.events.emitters: getattr(self.events, name).source = self @property def _defaults(self): return get_defaults(self) def reset(self): """Reset the state of the model to default values.""" for name, value in self._defaults.items(): if isinstance(value, EventedModel): getattr(self, name).reset() elif ( self.__config__.allow_mutation and self.__fields__[name].field_info.allow_mutation ): setattr(self, name, value) def update( self, values: Union['EventedModel', dict], recurse: bool = True ) -> None: """Update a model in place. Parameters ---------- values : dict, napari.utils.events.EventedModel Values to update the model with. If an EventedModel is passed it is first converted to a dictionary. The keys of this dictionary must be found as attributes on the current model. recurse : bool If True, recursively update fields that are EventedModels. Otherwise, just update the immediate fields of this EventedModel, which is useful when the declared field type (e.g. ``Union``) can have different realized types with different fields. """ if isinstance(values, self.__class__): values = values.dict() if not isinstance(values, dict): raise ValueError( trans._( "Unsupported update from {values}", deferred=True, values=type(values), ) ) with self.events.blocker() as block: for key, value in values.items(): field = getattr(self, key) if isinstance(field, EventedModel) and recurse: field.update(value, recurse=recurse) else: setattr(self, key, value) if block.count: self.events(Event(self)) def __eq__(self, other) -> bool: """Check equality with another object. We override the pydantic approach (which just checks ``self.dict() == other.dict()``) to accommodate more complicated types like arrays, whose truth value is often ambiguous. ``__eq_operators__`` is constructed in ``EqualityMetaclass.__new__`` """ if self is other: return True if not isinstance(other, EventedModel): return self.dict() == other for f_name, eq in self.__eq_operators__.items(): if f_name not in other.__eq_operators__: return False if ( hasattr(self, f_name) and hasattr(other, f_name) and not eq(getattr(self, f_name), getattr(other, f_name)) ): return False return True @contextmanager def enums_as_values(self, as_values: bool = True): """Temporarily override how enums are retrieved. Parameters ---------- as_values : bool, optional Whether enums should be shown as values (or as enum objects), by default `True` """ null = object() before = getattr(self.Config, 'use_enum_values', null) self.Config.use_enum_values = as_values try: yield finally: if before is not null: self.Config.use_enum_values = before else: delattr(self.Config, 'use_enum_values') def get_defaults(obj: BaseModel): """Get possibly nested default values for a Model object.""" dflt = {} for k, v in obj.__fields__.items(): d = v.get_default() if d is None and isinstance(v.type_, main.ModelMetaclass): d = get_defaults(v.type_) dflt[k] = d return dflt napari-0.5.0a1/napari/utils/events/types.py000066400000000000000000000002611437041365600206540ustar00rootroot00000000000000from typing import Protocol, runtime_checkable from napari.utils.events.event import EmitterGroup @runtime_checkable class SupportsEvents(Protocol): events: EmitterGroup napari-0.5.0a1/napari/utils/geometry.py000066400000000000000000000674141437041365600200540ustar00rootroot00000000000000from typing import Dict, Optional, Tuple import numpy as np # normal vectors for a 3D axis-aligned box # coordinates are ordered [z, y, x] FACE_NORMALS = { "x_pos": np.array([0, 0, 1]), "x_neg": np.array([0, 0, -1]), "y_pos": np.array([0, 1, 0]), "y_neg": np.array([0, -1, 0]), "z_pos": np.array([1, 0, 0]), "z_neg": np.array([-1, 0, 0]), } def project_points_onto_plane( points: np.ndarray, plane_point: np.ndarray, plane_normal: np.ndarray ) -> Tuple[np.ndarray, np.ndarray]: """Project points on to a plane. Plane is defined by a point and a normal vector. This function is designed to work with points and planes in 3D. Parameters ---------- points : np.ndarray The coordinate of the point to be projected. The points should be 3D and have shape shape (N,3) for N points. plane_point : np.ndarray The point on the plane used to define the plane. Should have shape (3,). plane_normal : np.ndarray The normal vector used to define the plane. Should be a unit vector and have shape (3,). Returns ------- projected_point : np.ndarray The point that has been projected to the plane. This is always an Nx3 array. signed_distance_to_plane : np.ndarray The signed projection distance between the points and the plane. Positive values indicate the point is on the positive normal side of the plane. Negative values indicate the point is on the negative normal side of the plane. """ points = np.atleast_2d(points) plane_point = np.asarray(plane_point) # make the plane normals have the same shape as the points plane_normal = np.tile(plane_normal, (points.shape[0], 1)) # get the vector from point on the plane # to the point to be projected point_vector = points - plane_point # find the distance to the plane along the normal direction signed_distance_to_plane = np.multiply(point_vector, plane_normal).sum( axis=1 ) # project the point projected_points = points - ( signed_distance_to_plane[:, np.newaxis] * plane_normal ) return projected_points, signed_distance_to_plane def rotation_matrix_from_vectors_2d( vec_1: np.ndarray, vec_2: np.ndarray ) -> np.ndarray: """Calculate the 2D rotation matrix to rotate vec_1 onto vec_2 Parameters ---------- vec_1 : np.ndarray The (2,) array containing the starting vector. vec_2 : np.ndarray The (2,) array containing the destination vector. Returns ------- rotation_matrix : np.ndarray The (2, 2) tranformation matrix that rotates vec_1 to vec_2. """ # ensure unit vectors vec_1 = vec_1 / np.linalg.norm(vec_1) vec_2 = vec_2 / np.linalg.norm(vec_2) # calculate the rotation matrix diagonal_1 = (vec_1[0] * vec_2[0]) + (vec_1[1] * vec_2[1]) diagonal_2 = (vec_1[0] * vec_2[1]) - (vec_2[0] * vec_1[0]) rotation_matrix = np.array( [[diagonal_1, -1 * diagonal_2], [diagonal_2, diagonal_1]] ) return rotation_matrix def rotation_matrix_from_vectors_3d(vec_1, vec_2): """Calculate the rotation matrix that aligns vec1 to vec2. Parameters ---------- vec_1 : np.ndarray The vector you want to rotate vec_2 : np.ndarray The vector you would like to align to. Returns ------- rotation_matrix : np.ndarray The rotation matrix that aligns vec_1 with vec_2. That is rotation_matrix.dot(vec_1) == vec_2 """ vec_1 = (vec_1 / np.linalg.norm(vec_1)).reshape(3) vec_2 = (vec_2 / np.linalg.norm(vec_2)).reshape(3) cross_prod = np.cross(vec_1, vec_2) dot_prod = np.dot(vec_1, vec_2) if any(cross_prod): # if not all zeros then s = np.linalg.norm(cross_prod) kmat = np.array( [ [0, -cross_prod[2], cross_prod[1]], [cross_prod[2], 0, -cross_prod[0]], [-cross_prod[1], cross_prod[0], 0], ] ) rotation_matrix = ( np.eye(3) + kmat + kmat.dot(kmat) * ((1 - dot_prod) / (s**2)) ) else: if np.allclose(dot_prod, 1): # if the vectors are already aligned, return the identity rotation_matrix = np.eye(3) else: # if the vectors are in opposite direction, rotate 180 degrees rotation_matrix = np.diag([-1, -1, 1]) return rotation_matrix def rotate_points( points: np.ndarray, current_plane_normal: np.ndarray, new_plane_normal: np.ndarray, ) -> Tuple[np.ndarray, np.ndarray]: """Rotate points using a rotation matrix defined by the rotation from current_plane to new_plane. Parameters ---------- points : np.ndarray The points to rotate. They should all lie on the same plane with the normal vector current_plane_normal. Should be (NxD) array. current_plane_normal : np.ndarray The normal vector for the plane the points currently reside on. new_plane_normal : np.ndarray The normal vector for the plane the points will be rotated to. Returns ------- rotated_points : np.ndarray The points that have been rotated rotation_matrix : np.ndarray The rotation matrix used for rotating the points. """ rotation_matrix = rotation_matrix_from_vectors_3d( current_plane_normal, new_plane_normal ) rotated_points = points @ rotation_matrix.T return rotated_points, rotation_matrix def point_in_bounding_box(point: np.ndarray, bounding_box: np.ndarray) -> bool: """Determine whether an nD point is inside an nD bounding box. Parameters ---------- point : np.ndarray (n,) array containing nD point coordinates to check. bounding_box : np.ndarray (2, n) array containing the min and max of the nD bounding box. As returned by `Layer._extent_data`. """ if np.all(point >= bounding_box[0]) and np.all(point <= bounding_box[1]): return True return False def clamp_point_to_bounding_box(point: np.ndarray, bounding_box: np.ndarray): """Ensure that a point is inside of the bounding box. If the point has a coordinate outside of the bounding box, the value is clipped to the max extent of the bounding box. Parameters ---------- point : np.ndarray n-dimensional point as an (n,) ndarray. Multiple points can be passed as an (n, D) array. bounding_box : np.ndarray n-dimensional bounding box as a (n, 2) ndarray Returns ------- clamped_point : np.ndarray `point` clamped to the limits of `bounding_box` """ clamped_point = np.clip(point, bounding_box[:, 0], bounding_box[:, 1] - 1) return clamped_point def face_coordinate_from_bounding_box( bounding_box: np.ndarray, face_normal: np.ndarray ) -> float: """Get the coordinate for a given face in an axis-aligned bounding box. For example, if the bounding box has extents [[0, 10], [0, 20], [0, 30]] (ordered zyx), then the face with normal [0, 1, 0] is described by y=20. Thus, the face_coordinate in this case is 20. Parameters ---------- bounding_box : np.ndarray n-dimensional bounding box as a (n, 2) ndarray. Each row should contain the [min, max] extents for the axis. face_normal : np.ndarray normal vector of the face as an (n,) ndarray Returns ------- face_coordinate : float The value where the bounding box face specified by face_normal intersects the axis its normal is aligned with. """ axis = np.argwhere(face_normal) if face_normal[axis] > 0: # face is pointing in the positive direction, # take the max extent face_coordinate = bounding_box[axis, 1] else: # face is pointing in the negative direction, # take the min extent face_coordinate = bounding_box[axis, 0] return face_coordinate def intersect_line_with_axis_aligned_plane( plane_intercept: float, plane_normal: np.ndarray, line_start: np.ndarray, line_direction: np.ndarray, ) -> np.ndarray: """Find the intersection of a line with an axis aligned plane. Parameters ---------- plane_intercept : float The coordinate that the plane intersects on the axis to which plane is normal. For example, if the plane is described by y=42, plane_intercept is 42. plane_normal : np.ndarray normal vector of the plane as an (n,) ndarray line_start : np.ndarray start point of the line as an (n,) ndarray line_direction : np.ndarray direction vector of the line as an (n,) ndarray Returns ------- intersection_point : np.ndarray point where the line intersects the axis aligned plane """ # find the axis the plane exists in plane_axis = np.squeeze(np.argwhere(plane_normal)) # get the intersection coordinate t = (plane_intercept - line_start[plane_axis]) / line_direction[plane_axis] return line_start + t * line_direction def bounding_box_to_face_vertices( bounding_box: np.ndarray, ) -> Dict[str, np.ndarray]: """From a layer bounding box (N, 2), N=ndim, return a dictionary containing the vertices of each face of the bounding_box. Parameters ---------- bounding_box : np.ndarray (N, 2), N=ndim array with the min and max value for each dimension of the bounding box. The bounding box is take form the last three rows, which are assumed to be in order (z, y, x). Returns ------- face_coords : Dict[str, np.ndarray] A dictionary containing the coordinates for the vertices for each face. The keys are strings: 'x_pos', 'x_neg', 'y_pos', 'y_neg', 'z_pos', 'z_neg'. 'x_pos' is the face with the normal in the positive x direction and 'x_neg' is the face with the normal in the negative direction. Coordinates are ordered (z, y, x). """ x_min, x_max = bounding_box[-1, :] y_min, y_max = bounding_box[-2, :] z_min, z_max = bounding_box[-3, :] face_coords = { "x_pos": np.array( [ [z_min, y_min, x_max], [z_min, y_max, x_max], [z_max, y_max, x_max], [z_max, y_min, x_max], ] ), "x_neg": np.array( [ [z_min, y_min, x_min], [z_min, y_max, x_min], [z_max, y_max, x_min], [z_max, y_min, x_min], ] ), "y_pos": np.array( [ [z_min, y_max, x_min], [z_min, y_max, x_max], [z_max, y_max, x_max], [z_max, y_max, x_min], ] ), "y_neg": np.array( [ [z_min, y_min, x_min], [z_min, y_min, x_max], [z_max, y_min, x_max], [z_max, y_min, x_min], ] ), "z_pos": np.array( [ [z_max, y_min, x_min], [z_max, y_min, x_max], [z_max, y_max, x_max], [z_max, y_max, x_min], ] ), "z_neg": np.array( [ [z_min, y_min, x_min], [z_min, y_min, x_max], [z_min, y_max, x_max], [z_min, y_max, x_min], ] ), } return face_coords def inside_triangles(triangles): """Checks which triangles contain the origin Parameters ---------- triangles : (N, 3, 2) array Array of N triangles that should be checked Returns ------- inside : (N,) array of bool Array with `True` values for triangles containing the origin """ AB = triangles[:, 1, :] - triangles[:, 0, :] AC = triangles[:, 2, :] - triangles[:, 0, :] BC = triangles[:, 2, :] - triangles[:, 1, :] s_AB = -AB[:, 0] * triangles[:, 0, 1] + AB[:, 1] * triangles[:, 0, 0] >= 0 s_AC = -AC[:, 0] * triangles[:, 0, 1] + AC[:, 1] * triangles[:, 0, 0] >= 0 s_BC = -BC[:, 0] * triangles[:, 1, 1] + BC[:, 1] * triangles[:, 1, 0] >= 0 inside = np.all(np.array([s_AB != s_AC, s_AB == s_BC]), axis=0) return inside def intersect_line_with_plane_3d( line_position: np.ndarray, line_direction: np.ndarray, plane_position: np.ndarray, plane_normal: np.ndarray, ) -> np.ndarray: """Find the intersection of a line with an arbitrarily oriented plane in 3D. The line is defined by a position and a direction vector. The plane is defined by a position and a normal vector. https://en.wikipedia.org/wiki/Line%E2%80%93plane_intersection Parameters ---------- line_position : np.ndarray a position on a 3D line with shape (3,). line_direction : np.ndarray direction of the 3D line with shape (3,). plane_position : np.ndarray a position on a plane in 3D with shape (3,). plane_normal : np.ndarray a vector normal to the plane in 3D with shape (3,). Returns ------- plane_intersection : np.ndarray the intersection of the line with the plane, shape (3,) """ # cast to arrays line_position = np.asarray(line_position, dtype=float) line_direction = np.asarray(line_direction, dtype=float) plane_position = np.asarray(plane_position, dtype=float) plane_normal = np.asarray(plane_normal, dtype=float) # project direction between line and plane onto the plane normal line_plane_direction = plane_position - line_position line_plane_on_plane_normal = np.dot(line_plane_direction, plane_normal) # project line direction onto the plane normal line_direction_on_plane_normal = np.dot(line_direction, plane_normal) # find scale factor for line direction scale_factor = line_plane_on_plane_normal / line_direction_on_plane_normal return line_position + (scale_factor * line_direction) def intersect_line_with_multiple_planes_3d( line_position: np.ndarray, line_direction: np.ndarray, plane_position: np.ndarray, plane_normal: np.ndarray, ) -> np.ndarray: """Find the intersection of a line with multiple arbitrarily oriented planes in 3D. The line is defined by a position and a direction vector. The plane is defined by a position and a normal vector. https://en.wikipedia.org/wiki/Line%E2%80%93plane_intersection Parameters ---------- line_position : np.ndarray a position on a 3D line with shape (3,). line_direction : np.ndarray direction of the 3D line with shape (3,). plane_position : np.ndarray point on a plane in 3D with shape (n, 3) for n planes. plane_normal : np.ndarray a vector normal to the plane in 3D with shape (n,3) for n planes. Returns ------- plane_intersection : np.ndarray the intersection of the line with the plane, shape (3,) """ # cast to arrays line_position = np.asarray(line_position, dtype=float) line_direction = np.asarray(line_direction, dtype=float) plane_position = np.atleast_2d(plane_position).astype(float) plane_normal = np.atleast_2d(plane_normal).astype(float) # project direction between line and plane onto the plane normal line_plane_direction = plane_position - line_position line_plane_on_plane_normal = np.sum( line_plane_direction * plane_normal, axis=1 ) # project line direction onto the plane normal line_direction_on_plane_normal = np.sum( line_direction * plane_normal, axis=1 ) # find scale factor for line direction scale_factor = line_plane_on_plane_normal / line_direction_on_plane_normal # if plane_position.ndim == 2: repeated_line_position = np.repeat( line_position[np.newaxis, :], len(scale_factor), axis=0 ) repeated_line_direction = np.repeat( line_direction[np.newaxis, :], len(scale_factor), axis=0 ) return repeated_line_position + ( np.expand_dims(scale_factor, axis=1) * repeated_line_direction ) def intersect_line_with_triangles( line_point: np.ndarray, line_direction: np.ndarray, triangles: np.ndarray ) -> np.ndarray: """Find the intersection of a ray with a set of triangles. This function does not test whether the ray intersects the triangles, so you should have tested for intersection first. See line_in_triangles_3d() for testing for intersection. Parameters ---------- line_point : np.ndarray The (3,) array containing the starting point of the ray. line_direction : np.ndarray The (3,) array containing the unit vector in the direction of the ray. triangles : np.ndarray The 3D vertices of the triangles. Should be (n, 3, 3) for n triangles. Axis 1 indexes each vertex and axis 2 contains the coordinates. That to access the 0th vertex from triangle index 3, one would use: triangles[3, 0, :]. Returns ------- intersection_points : np.ndarray (n, 3) array containing the point at which the specified ray intersects the each triangle. """ edge_1 = triangles[:, 1, :] - triangles[:, 0, :] edge_2 = triangles[:, 2, :] - triangles[:, 0, :] triangle_normals = np.cross(edge_1, edge_2) triangle_normals = triangle_normals / np.expand_dims( np.linalg.norm(triangle_normals, axis=1), 1 ) intersection_points = intersect_line_with_multiple_planes_3d( line_position=line_point, line_direction=line_direction, plane_position=triangles[:, 0, :], plane_normal=triangle_normals, ) return intersection_points def point_in_quadrilateral_2d( point: np.ndarray, quadrilateral: np.ndarray ) -> bool: """Determines whether a point is inside a 2D quadrilateral. Parameters ---------- point : np.ndarray (2,) array containing coordinates of a point. quadrilateral : np.ndarray (4, 2) array containing the coordinates for the 4 corners of a quadrilateral. The vertices should be in clockwise order such that indexing with [0, 1, 2], and [0, 2, 3] results in the two non-overlapping triangles that divide the quadrilateral. Returns ------- """ triangle_vertices = np.stack( (quadrilateral[[0, 1, 2]], quadrilateral[[0, 2, 3]]) ) in_triangles = inside_triangles(triangle_vertices - point) if in_triangles.sum() < 1: return False else: return True def line_in_quadrilateral_3d( line_point: np.ndarray, line_direction: np.ndarray, quadrilateral: np.ndarray, ) -> bool: """Determine if a line goes tbrough any of a set of quadrilaterals. For example, this could be used to determine if a click was in a specific face of a bounding box. Parameters ---------- line_point : np.ndarray (3,) array containing the location that was clicked. This should be in the same coordinate system as the vertices. line_direction : np.ndarray (3,) array describing the direction camera is pointing in the scene. This should be in the same coordinate system as the vertices. quadrilateral : np.ndarray (4, 3) array containing the coordinates for the 4 corners of a quadrilateral. The vertices should be in clockwise order such that indexing with [0, 1, 2], and [0, 2, 3] results in the two non-overlapping triangles that divide the quadrilateral. Returns ------- in_region : bool True if the click is in the region specified by vertices. """ # project the vertices of the bound region on to the view plane vertices_plane, _ = project_points_onto_plane( points=quadrilateral, plane_point=line_point, plane_normal=line_direction, ) # rotate the plane to make the triangles 2D rotated_vertices, rotation_matrix = rotate_points( points=vertices_plane, current_plane_normal=line_direction, new_plane_normal=[0, 0, 1], ) quadrilateral_2D = rotated_vertices[:, :2] click_pos_2D = rotation_matrix.dot(line_point)[:2] return point_in_quadrilateral_2d(click_pos_2D, quadrilateral_2D) def line_in_triangles_3d( line_point: np.ndarray, line_direction: np.ndarray, triangles: np.ndarray ): """Determine if a line goes through any of a set of triangles. For example, this could be used to determine if a click was in a triangle of a mesh. Parameters ---------- line_point : np.ndarray (3,) array containing the location that was clicked. This should be in the same coordinate system as the vertices. line_direction : np.ndarray (3,) array describing the direction camera is pointing in the scene. This should be in the same coordinate system as the vertices. triangles : np.ndarray (n, 3, 3) array containing the coordinates for the 3 corners of n triangles. Returns ------- in_triangles : np.ndarray (n,) boolean array that is True of the ray intersects the triangle """ vertices = triangles.reshape((-1, triangles.shape[2])) # project the vertices of the bound region on to the view plane vertices_plane, _ = project_points_onto_plane( points=vertices, plane_point=line_point, plane_normal=line_direction ) # rotate the plane to make the triangles 2D rotation_matrix = rotation_matrix_from_vectors_3d( line_direction, [0, 0, 1] ) rotated_vertices = vertices_plane @ rotation_matrix.T rotated_vertices_2d = rotated_vertices[:, :2] rotated_triangles_2d = rotated_vertices_2d.reshape(-1, 3, 2) line_pos_2D = rotation_matrix.dot(line_point)[:2] return inside_triangles(rotated_triangles_2d - line_pos_2D) def find_front_back_face( click_pos: np.ndarray, bounding_box: np.ndarray, view_dir: np.ndarray ): """Find the faces of an axis aligned bounding box a click intersects with. Parameters ---------- click_pos : np.ndarray (3,) array containing the location that was clicked. bounding_box : np.ndarray (N, 2), N=ndim array with the min and max value for each dimension of the bounding box. The bounding box is take form the last three rows, which are assumed to be in order (z, y, x). This should be in the same coordinate system as click_pos. view_dir (3,) array describing the direction camera is pointing in the scene. This should be in the same coordinate system as click_pos. Returns ------- front_face_normal : np.ndarray The (3,) normal vector of the face closest to the camera the click intersects with. back_face_normal : np.ndarray The (3,) normal vector of the face farthest from the camera the click intersects with. """ front_face_normal = None back_face_normal = None bbox_face_coords = bounding_box_to_face_vertices(bounding_box) for k, v in FACE_NORMALS.items(): if (np.dot(view_dir, v) + 0.001) < 0: if line_in_quadrilateral_3d( click_pos, view_dir, bbox_face_coords[k] ): front_face_normal = v elif (np.dot(view_dir, v) + 0.001) > 0: if line_in_quadrilateral_3d( click_pos, view_dir, bbox_face_coords[k] ): back_face_normal = v if front_face_normal is not None and back_face_normal is not None: # stop looping if both the front and back faces have been found break return front_face_normal, back_face_normal def intersect_line_with_axis_aligned_bounding_box_3d( line_point: np.ndarray, line_direction: np.ndarray, bounding_box: np.ndarray, face_normal: np.ndarray, ): """Find the intersection of a ray with the specified face of an axis-aligned bounding box. Parameters ---------- face_normal : np.ndarray The (3,) normal vector of the face the click intersects with. line_point : np.ndarray (3,) array containing the location that was clicked. bounding_box : np.ndarray (N, 2), N=ndim array with the min and max value for each dimension of the bounding box. The bounding box is take form the last three rows, which are assumed to be in order (z, y, x). This should be in the same coordinate system as click_pos. line_direction (3,) array describing the direction camera is pointing in the scene. This should be in the same coordinate system as click_pos. Returns ------- intersection_point : np.ndarray (3,) array containing the coordinate for the intersection of the click on the specified face. """ front_face_coordinate = face_coordinate_from_bounding_box( bounding_box, face_normal ) intersection_point = np.squeeze( intersect_line_with_axis_aligned_plane( front_face_coordinate, face_normal, line_point, -line_direction, ) ) return intersection_point def distance_between_point_and_line_3d( point: np.ndarray, line_position: np.ndarray, line_direction: np.ndarray ): """Determine the minimum distance between a point and a line in 3D. Parameters ---------- point : np.ndarray (3,) array containing coordinates of a point in 3D space. line_position : np.ndarray (3,) array containing coordinates of a point on a line in 3D space. line_direction : np.ndarray (3,) array containing a vector describing the direction of a line in 3D space. Returns ------- distance : float The minimum distance between `point` and the line defined by `line_position` and `line_direction`. """ line_direction_normalized = line_direction / np.linalg.norm(line_direction) projection_on_line_direction = np.dot( (point - line_position), line_direction ) closest_point_on_line = ( line_position + line_direction_normalized * projection_on_line_direction ) distance = np.linalg.norm(point - closest_point_on_line) return distance def find_nearest_triangle_intersection( ray_position: np.ndarray, ray_direction: np.ndarray, triangles: np.ndarray ) -> Tuple[Optional[int], Optional[np.ndarray]]: """Given an array of triangles, find the index and intersection location of a ray and the nearest triangle. This returns only the triangle closest to the the ray_position. Parameters ---------- ray_position : np.ndarray The coordinate of the starting point of the ray. ray_direction : np.ndarray A unit vector describing the direction of the ray. triangles : np.ndarray (N, 3, 3) array containing the vertices of the triangles. Returns ------- closest_intersected_triangle_index : int The index of the intersected triangle. intersection : np.ndarray The coordinate of where the ray intersects the triangle. """ inside = line_in_triangles_3d( line_point=ray_position, line_direction=ray_direction, triangles=triangles, ) n_intersected_triangles = np.sum(inside) if n_intersected_triangles == 0: return None, None # find the intersection points for the intersected_triangles = triangles[inside] intersection_points = intersect_line_with_triangles( line_point=ray_position, line_direction=ray_direction, triangles=intersected_triangles, ) # find the intersection closest to the start point of the ray and return start_to_intersection = intersection_points - ray_position distances = np.linalg.norm(start_to_intersection, axis=1) closest_triangle_index = np.argmin(distances) intersected_triangle_indices = np.argwhere(inside) closest_intersected_triangle_index = intersected_triangle_indices[ closest_triangle_index ][0] intersection = intersection_points[closest_triangle_index] return closest_intersected_triangle_index, intersection napari-0.5.0a1/napari/utils/history.py000066400000000000000000000031011437041365600177010ustar00rootroot00000000000000import os from pathlib import Path from napari.settings import get_settings def update_open_history(filename): """Updates open history of files in settings. Parameters ---------- filename : str New file being added to open history. """ settings = get_settings() folders = settings.application.open_history new_loc = os.path.dirname(filename) if new_loc in folders: folders.insert(0, folders.pop(folders.index(new_loc))) else: folders.insert(0, new_loc) folders = folders[0:10] settings.application.open_history = folders def update_save_history(filename): """Updates save history of files in settings. Parameters ---------- filename : str New file being added to save history. """ settings = get_settings() folders = settings.application.save_history new_loc = os.path.dirname(filename) if new_loc in folders: folders.insert(0, folders.pop(folders.index(new_loc))) else: folders.insert(0, new_loc) folders = folders[0:10] settings.application.save_history = folders def get_open_history(): """A helper for history handling.""" settings = get_settings() folders = settings.application.open_history folders = [f for f in folders if os.path.isdir(f)] return folders or [str(Path.home())] def get_save_history(): """A helper for history handling.""" settings = get_settings() folders = settings.application.save_history folders = [f for f in folders if os.path.isdir(f)] return folders or [str(Path.home())] napari-0.5.0a1/napari/utils/info.py000066400000000000000000000121641437041365600171440ustar00rootroot00000000000000import contextlib import os import platform import subprocess import sys import napari OS_RELEASE_PATH = "/etc/os-release" def _linux_sys_name(): """ Try to discover linux system name base on /etc/os-release file or lsb_release command output https://www.freedesktop.org/software/systemd/man/os-release.html """ if os.path.exists(OS_RELEASE_PATH): with open(OS_RELEASE_PATH) as f_p: data = {} for line in f_p: field, value = line.split("=") data[field.strip()] = value.strip().strip('"') if "PRETTY_NAME" in data: return data["PRETTY_NAME"] if "NAME" in data: if "VERSION" in data: return f'{data["NAME"]} {data["VERSION"]}' if "VERSION_ID" in data: return f'{data["NAME"]} {data["VERSION_ID"]}' return f'{data["NAME"]} (no version)' try: res = subprocess.run( ["lsb_release", "-d", "-r"], check=True, capture_output=True ) text = res.stdout.decode() data = {} for line in text.split("\n"): key, val = line.split(":") data[key.strip()] = val.strip() version_str = data["Description"] if not version_str.endswith(data["Release"]): version_str += " " + data["Release"] return version_str except subprocess.CalledProcessError: pass return "" def _sys_name(): """ Discover MacOS or Linux Human readable information. For Linux provide information about distribution. """ with contextlib.suppress(Exception): if sys.platform == "linux": return _linux_sys_name() if sys.platform == "darwin": with contextlib.suppress(subprocess.CalledProcessError): res = subprocess.run( ["sw_vers", "-productVersion"], check=True, capture_output=True, ) return f"MacOS {res.stdout.decode().strip()}" return "" def sys_info(as_html=False): """Gathers relevant module versions for troubleshooting purposes. Parameters ---------- as_html : bool if True, info will be returned as HTML, suitable for a QTextEdit widget """ sys_version = sys.version.replace('\n', ' ') text = ( f"napari: {napari.__version__}
" f"Platform: {platform.platform()}
" ) __sys_name = _sys_name() if __sys_name: text += f"System: {__sys_name}
" text += f"Python: {sys_version}
" try: from qtpy import API_NAME, PYQT_VERSION, PYSIDE_VERSION, QtCore if API_NAME == 'PySide2': API_VERSION = PYSIDE_VERSION elif API_NAME == 'PyQt5': API_VERSION = PYQT_VERSION else: API_VERSION = '' text += ( f"Qt: {QtCore.__version__}
" f"{API_NAME}: {API_VERSION}
" ) except Exception as e: # noqa BLE001 text += f"Qt: Import failed ({e})
" modules = ( ('numpy', 'NumPy'), ('scipy', 'SciPy'), ('dask', 'Dask'), ('vispy', 'VisPy'), ('magicgui', 'magicgui'), ('superqt', 'superqt'), ('in_n_out', 'in-n-out'), ('app_model', 'app-model'), ('npe2', 'npe2'), ) loaded = {} for module, name in modules: try: loaded[module] = __import__(module) text += f"{name}: {loaded[module].__version__}
" except Exception as e: # noqa BLE001 text += f"{name}: Import failed ({e})
" text += "
OpenGL:
" if loaded.get('vispy', False): sys_info_text = ( "
".join( [ loaded['vispy'].sys_info().split("\n")[index] for index in [-4, -3] ] ) .replace("'", "") .replace("
", "
- ") ) text += f' - {sys_info_text}
' else: text += " - failed to load vispy" text += "
Screens:
" try: from qtpy.QtGui import QGuiApplication screen_list = QGuiApplication.screens() for i, screen in enumerate(screen_list, start=1): text += f" - screen {i}: resolution {screen.geometry().width()}x{screen.geometry().height()}, scale {screen.devicePixelRatio()}
" except Exception as e: # noqa BLE001 text += f" - failed to load screen information {e}" text += "
Settings path:
" try: from napari.settings import get_settings text += f" - {get_settings().config_path}" except ValueError: from napari.utils._appdirs import user_config_dir text += f" - {os.getenv('NAPARI_CONFIG', user_config_dir())}" if not as_html: text = ( text.replace("
", "\n").replace("", "").replace("", "") ) return text citation_text = ( 'napari contributors (2019). napari: a ' 'multi-dimensional image viewer for python. ' 'doi:10.5281/zenodo.3555620' ) napari-0.5.0a1/napari/utils/interactions.py000066400000000000000000000245321437041365600207150ustar00rootroot00000000000000import inspect import sys import warnings from typing import List from numpydoc.docscrape import FunctionDoc from napari.utils.key_bindings import ( KeyBinding, KeyBindingLike, coerce_keybinding, ) from napari.utils.translations import trans def mouse_wheel_callbacks(obj, event): """Run mouse wheel callbacks on either layer or viewer object. Note that drag callbacks should have the following form: .. code-block:: python def hello_world(layer, event): "dragging" # on press print('hello world!') yield # on move while event.type == 'mouse_move': print(event.pos) yield # on release print('goodbye world ;(') Parameters --------- obj : ViewerModel or Layer Layer or Viewer object to run callbacks on event : Event Mouse event """ # iterate through drag callback functions for mouse_wheel_func in obj.mouse_wheel_callbacks: # execute function to run press event code gen = mouse_wheel_func(obj, event) # if function returns a generator then try to iterate it if inspect.isgenerator(gen): try: next(gen) # now store iterated genenerator obj._mouse_wheel_gen[mouse_wheel_func] = gen # and now store event that initially triggered the press obj._persisted_mouse_event[gen] = event except StopIteration: pass def mouse_double_click_callbacks(obj, event) -> None: """Run mouse double_click callbacks on either layer or viewer object. Note that unlike other press and release callback those can't be generators: .. code-block:: python def double_click_callback(layer, event): layer._finish_drawing() Parameters ---------- obj : ViewerModel or Layer Layer or Viewer object to run callbacks on event : Event Mouse event Returns ------- None """ # iterate through drag callback functions for mouse_click_func in obj.mouse_double_click_callbacks: # execute function to run press event code if inspect.isgeneratorfunction(mouse_click_func): raise ValueError( trans._( "Double-click actions can't be generators.", deferred=True ) ) mouse_click_func(obj, event) def mouse_press_callbacks(obj, event): """Run mouse press callbacks on either layer or viewer object. Note that drag callbacks should have the following form: .. code-block:: python def hello_world(layer, event): "dragging" # on press print('hello world!') yield # on move while event.type == 'mouse_move': print(event.pos) yield # on release print('goodbye world ;(') Parameters ---------- obj : ViewerModel or Layer Layer or Viewer object to run callbacks on event : Event Mouse event """ # iterate through drag callback functions for mouse_drag_func in obj.mouse_drag_callbacks: # execute function to run press event code gen = mouse_drag_func(obj, event) # if function returns a generator then try to iterate it if inspect.isgenerator(gen): try: next(gen) # now store iterated genenerator obj._mouse_drag_gen[mouse_drag_func] = gen # and now store event that initially triggered the press obj._persisted_mouse_event[gen] = event except StopIteration: pass def mouse_move_callbacks(obj, event): """Run mouse move callbacks on either layer or viewer object. Note that drag callbacks should have the following form: .. code-block:: python def hello_world(layer, event): "dragging" # on press print('hello world!') yield # on move while event.type == 'mouse_move': print(event.pos) yield # on release print('goodbye world ;(') Parameters ---------- obj : ViewerModel or Layer Layer or Viewer object to run callbacks on event : Event Mouse event """ if not event.is_dragging: # if not dragging simply call the mouse move callbacks for mouse_move_func in obj.mouse_move_callbacks: mouse_move_func(obj, event) # for each drag callback get the current generator for func, gen in tuple(obj._mouse_drag_gen.items()): # save the event current event obj._persisted_mouse_event[gen].__wrapped__ = event try: # try to advance the generator next(gen) except StopIteration: # If done deleted the generator and stored event del obj._mouse_drag_gen[func] del obj._persisted_mouse_event[gen] def mouse_release_callbacks(obj, event): """Run mouse release callbacks on either layer or viewer object. Note that drag callbacks should have the following form: .. code-block:: python def hello_world(layer, event): "dragging" # on press print('hello world!') yield # on move while event.type == 'mouse_move': print(event.pos) yield # on release print('goodbye world ;(') Parameters ---------- obj : ViewerModel or Layer Layer or Viewer object to run callbacks on event : Event Mouse event """ for func, gen in tuple(obj._mouse_drag_gen.items()): obj._persisted_mouse_event[gen].__wrapped__ = event try: # Run last part of the function to trigger release event next(gen) except StopIteration: pass # Finally delete the generator and stored event del obj._mouse_drag_gen[func] del obj._persisted_mouse_event[gen] KEY_SYMBOLS = { 'Ctrl': 'Ctrl', 'Shift': '⇧', 'Alt': 'Alt', 'Meta': '⊞', 'Left': '←', 'Right': '→', 'Up': '↑', 'Down': '↓', 'Backspace': '⌫', 'Delete': '⌦', 'Tab': '↹', 'Escape': 'Esc', 'Return': '⏎', 'Enter': '↵', 'Space': '␣', } joinchar = '+' if sys.platform.startswith('darwin'): KEY_SYMBOLS.update({'Ctrl': '⌘', 'Alt': '⌥', 'Meta': '⌃'}) joinchar = '' elif sys.platform.startswith('linux'): KEY_SYMBOLS.update({'Meta': 'Super'}) def _kb2mods(key_bind: KeyBinding) -> List[str]: """Extract list of modifiers from a key binding. Parameters ---------- key_bind : KeyBinding The key binding whose mods are to be extracted. Returns ------- list of str The key modifiers used by the key binding. """ mods = [] if key_bind.ctrl: mods.append('Ctrl') if key_bind.shift: mods.append('Shift') if key_bind.alt: mods.append('Alt') if key_bind.meta: mods.append('Meta') return mods class Shortcut: """ Wrapper object around shortcuts, Mostly help to handle cross platform differences in UI: - whether the joiner is -,'' or something else. - replace the corresponding modifier with their equivalents. As well as integration with qt which uses a different convention with + instead of -. """ def __init__(self, shortcut: KeyBindingLike) -> None: """Parameters ---------- shortcut : keybinding-like shortcut to format """ error_msg = trans._( "{shortcut} does not seem to be a valid shortcut Key.", shortcut=shortcut, ) error = False try: self._kb = coerce_keybinding(shortcut) except ValueError: error = True else: for part in self._kb.parts: shortcut_key = str(part.key) if len(shortcut_key) > 1 and shortcut_key not in KEY_SYMBOLS: error = True if error: warnings.warn(error_msg, UserWarning, stacklevel=2) @property def qt(self) -> str: """Representation of the keybinding as it would appear in Qt. Returns ------- string Shortcut formatted to be used with Qt. """ return str(self._kb) @property def platform(self) -> str: """Format the given shortcut for the current platform. Replace Cmd, Ctrl, Meta...etc by appropriate symbols if relevant for the given platform. Returns ------- string Shortcut formatted to be displayed on current paltform. """ return ' '.join( joinchar.join( KEY_SYMBOLS.get(x, x) for x in (_kb2mods(part) + [str(part.key)]) ) for part in self._kb.parts ) def __str__(self): return self.platform def get_key_bindings_summary(keymap, col='rgb(134, 142, 147)'): """Get summary of key bindings in keymap. Parameters ---------- keymap : dict Dictionary of key bindings. col : str Color string in format rgb(int, int, int) used for highlighting keypress combination. Returns ------- str String with summary of all key_bindings and their functions. """ key_bindings_strs = [''] for key in keymap: keycodes = [KEY_SYMBOLS.get(k, k) for k in key.split('-')] keycodes = "+".join( [f"{k}" for k in keycodes] ) key_bindings_strs.append( "" "" ) key_bindings_strs.append('
" f"{keycodes}" f"{keymap[key]}
') return ''.join(key_bindings_strs) def get_function_summary(func): """Get summary of doc string of function.""" doc = FunctionDoc(func) summary = '' for s in doc['Summary']: summary += s return summary.rstrip('.') napari-0.5.0a1/napari/utils/io.py000066400000000000000000000044561437041365600166250ustar00rootroot00000000000000import os import warnings from typing import TYPE_CHECKING from napari.utils.translations import trans if TYPE_CHECKING: import numpy as np def imsave(filename: str, data: "np.ndarray"): """Custom implementation of imsave to avoid skimage dependency. Parameters ---------- filename : string The path to write the file to. data : np.ndarray The image data. """ ext = os.path.splitext(filename)[1] if ext in [".tif", ".tiff"]: import tifffile compression_instead_of_compress = False try: current_version = tuple( int(x) for x in tifffile.__version__.split('.')[:3] ) compression_instead_of_compress = current_version >= (2021, 6, 6) except Exception: # noqa: BLE001 # Just in case anything goes wrong in parsing version number # like repackaging on linux or anything else we fallback to # using compress warnings.warn( trans._( 'Error parsing tiffile version number {version_number}', deferred=True, version_number=f"{tifffile.__version__:!r}", ) ) if compression_instead_of_compress: # 'compression' scheme is more complex. See: # https://forum.image.sc/t/problem-saving-generated-labels-in-cellpose-napari/54892/8 tifffile.imwrite(filename, data, compression=('zlib', 1)) else: # older version of tifffile since 2021.6.6 this is deprecated tifffile.imwrite(filename, data, compress=1) else: import imageio imageio.imsave(filename, data) def __getattr__(name: str): if name in { 'imsave_extensions', 'write_csv', 'read_csv', 'csv_to_layer_data', 'read_zarr_dataset', }: warnings.warn( trans._( '{name} was moved from napari.utils.io in v0.4.17. Import it from napari_builtins.io instead.', deferred=True, name=name, ), FutureWarning, stacklevel=2, ) import napari_builtins.io return getattr(napari_builtins.io, name) raise AttributeError(f"module {__name__} has no attribute {name}") napari-0.5.0a1/napari/utils/key_bindings.py000066400000000000000000000352631437041365600206630ustar00rootroot00000000000000"""Key combinations are represented in the form ``[modifier-]key``, e.g. ``a``, ``Control-c``, or ``Control-Alt-Delete``. Valid modifiers are Control, Alt, Shift, and Meta. Letters will always be read as upper-case. Due to the native implementation of the key system, Shift pressed in certain key combinations may yield inconsistent or unexpected results. Therefore, it is not recommended to use Shift with non-letter keys. On OSX, Control is swapped with Meta such that pressing Command reads as Control. Special keys include Shift, Control, Alt, Meta, Up, Down, Left, Right, PageUp, PageDown, Insert, Delete, Home, End, Escape, Backspace, F1, F2, F3, F4, F5, F6, F7, F8, F9, F10, F11, F12, Space, Enter, and Tab Functions take in only one argument: the parent that the function was bound to. By default, all functions are assumed to work on key presses only, but can be denoted to work on release too by separating the function into two statements with the yield keyword:: @viewer.bind_key('h') def hello_world(viewer): # on key press viewer.status = 'hello world!' yield # on key release viewer.status = 'goodbye world :(' To create a keymap that will block others, ``bind_key(..., ...)```. """ import contextlib import inspect import sys import time from collections import ChainMap from types import MethodType from typing import Callable, Mapping, Union from app_model.types import KeyBinding, KeyCode, KeyMod from vispy.util import keys from napari.utils.translations import trans if sys.version_info >= (3, 10): from types import EllipsisType else: EllipsisType = type(Ellipsis) KeyBindingLike = Union[KeyBinding, str, int] Keymap = Mapping[ Union[KeyBinding, EllipsisType], Union[Callable, EllipsisType] ] # global user keymap; to be made public later in refactoring process USER_KEYMAP: Mapping[str, Callable] = {} KEY_SUBS = { 'Control': 'Ctrl', 'Option': 'Alt', } _UNDEFINED = object() _VISPY_SPECIAL_KEYS = [ keys.SHIFT, keys.CONTROL, keys.ALT, keys.META, keys.UP, keys.DOWN, keys.LEFT, keys.RIGHT, keys.PAGEUP, keys.PAGEDOWN, keys.INSERT, keys.DELETE, keys.HOME, keys.END, keys.ESCAPE, keys.BACKSPACE, keys.F1, keys.F2, keys.F3, keys.F4, keys.F5, keys.F6, keys.F7, keys.F8, keys.F9, keys.F10, keys.F11, keys.F12, keys.SPACE, keys.ENTER, keys.TAB, ] _VISPY_MODS = { keys.CONTROL: KeyMod.CtrlCmd, keys.SHIFT: KeyMod.Shift, keys.ALT: KeyMod.Alt, keys.META: KeyMod.WinCtrl, } # TODO: add this to app-model instead KeyBinding.__hash__ = lambda self: hash(str(self)) def coerce_keybinding(key_bind: KeyBindingLike) -> KeyBinding: """Convert a keybinding-like object to a KeyBinding. Parameters ---------- key_bind : keybinding-like Object to coerce. Returns ------- key_bind : KeyBinding Object as KeyBinding. """ if isinstance(key_bind, str): for k, v in KEY_SUBS.items(): key_bind = key_bind.replace(k, v) return KeyBinding.validate(key_bind) def bind_key( keymap: Keymap, key_bind: Union[KeyBindingLike, EllipsisType], func=_UNDEFINED, *, overwrite=False, ): """Bind a key combination to a keymap. Parameters ---------- keymap : dict of str: callable Keymap to modify. key_bind : keybinding-like or ... Key combination. ``...`` acts as a wildcard if no key combinations can be matched in the keymap (this will overwrite all key combinations further down the lookup chain). func : callable, None, or ... Callable to bind to the key combination. If ``None`` is passed, unbind instead. ``...`` acts as a blocker, effectively unbinding the key combination for all keymaps further down the lookup chain. overwrite : bool, keyword-only, optional Whether to overwrite the key combination if it already exists. Returns ------- unbound : callable or None Callable unbound by this operation, if any. Notes ----- Key combinations are represented in the form ``[modifier-]key``, e.g. ``a``, ``Control-c``, or ``Control-Alt-Delete``. Valid modifiers are Control, Alt, Shift, and Meta. Letters will always be read as upper-case. Due to the native implementation of the key system, Shift pressed in certain key combinations may yield inconsistent or unexpected results. Therefore, it is not recommended to use Shift with non-letter keys. On OSX, Control is swapped with Meta such that pressing Command reads as Control. Special keys include Shift, Control, Alt, Meta, Up, Down, Left, Right, PageUp, PageDown, Insert, Delete, Home, End, Escape, Backspace, F1, F2, F3, F4, F5, F6, F7, F8, F9, F10, F11, F12, Space, Enter, and Tab Functions take in only one argument: the parent that the function was bound to. By default, all functions are assumed to work on key presses only, but can be denoted to work on release too by separating the function into two statements with the yield keyword:: @viewer.bind_key('h') def hello_world(viewer): # on key press viewer.status = 'hello world!' yield # on key release viewer.status = 'goodbye world :(' To create a keymap that will block others, ``bind_key(..., ...)```. """ if func is _UNDEFINED: def inner(func): bind_key(keymap, key_bind, func, overwrite=overwrite) return func return inner if key_bind is not Ellipsis: key_bind = coerce_keybinding(key_bind) if func is not None and key_bind in keymap and not overwrite: raise ValueError( trans._( 'keybinding {key} already used! specify \'overwrite=True\' to bypass this check', deferred=True, key=str(key_bind), ) ) unbound = keymap.pop(key_bind, None) if func is not None: if func is not Ellipsis and not callable(func): raise TypeError( trans._( "'func' must be a callable", deferred=True, ) ) keymap[key_bind] = func return unbound def _get_user_keymap() -> Keymap: """Retrieve the current user keymap. The user keymap is global and takes precedent over all other keymaps. Returns ------- user_keymap : dict of str: callable User keymap. """ return USER_KEYMAP def _bind_user_key( key_bind: KeyBindingLike, func=_UNDEFINED, *, overwrite=False ): """Bind a key combination to the user keymap. See ``bind_key`` docs for details. """ return bind_key(_get_user_keymap(), key_bind, func, overwrite=overwrite) def _vispy2appmodel(event) -> KeyBinding: key, modifiers = event.key.name, event.modifiers if len(key) == 1 and key.isalpha(): # it's a letter key = key.upper() cond = lambda m: True # noqa: E731 elif key in _VISPY_SPECIAL_KEYS: # remove redundant information i.e. an output of 'Shift-Shift' cond = lambda m: m != key # noqa: E731 else: # Shift is consumed to transform key # bug found on OSX: Command will cause Shift to not # transform the key so do not consume it # note: 'Control' is OSX Command key cond = lambda m: m != 'Shift' or 'Control' in modifiers # noqa: E731 kb = KeyCode.from_string(KEY_SUBS.get(key, key)) for key in filter(lambda key: key in modifiers and cond(key), _VISPY_MODS): kb |= _VISPY_MODS[key] return coerce_keybinding(kb) class KeybindingDescriptor: """Descriptor which transforms ``func`` into a method with the first argument bound to ``class_keymap`` or ``keymap`` depending on if it was called from the class or the instance, respectively. Parameters ---------- func : callable Function to bind. """ def __init__(self, func) -> None: self.__func__ = func def __get__(self, instance, cls): keymap = instance.keymap if instance is not None else cls.class_keymap return MethodType(self.__func__, keymap) class KeymapProvider: """Mix-in to add keymap functionality. Attributes ---------- class_keymap : dict Class keymap. keymap : dict Instance keymap. """ def __init__(self, *args, **kwargs) -> None: super().__init__(*args, **kwargs) self.keymap = {} def __init_subclass__(cls, **kwargs): super().__init_subclass__(**kwargs) if 'class_keymap' not in cls.__dict__: # if in __dict__, was defined in class and not inherited cls.class_keymap = {} else: cls.class_keymap = { coerce_keybinding(k): v for k, v in cls.class_keymap.items() } bind_key = KeybindingDescriptor(bind_key) def _bind_keymap(keymap, instance): """Bind all functions in a keymap to an instance. Parameters ---------- keymap : dict Keymap to bind. instance : object Instance to bind to. Returns ------- bound_keymap : dict Keymap with functions bound to the instance. """ bound_keymap = { key: MethodType(func, instance) if func is not Ellipsis else func for key, func in keymap.items() } return bound_keymap class KeymapHandler: """Handle key mapping and calling functionality. Attributes ---------- keymap_providers : list of KeymapProvider Classes that provide the keymaps for this class to handle. """ def __init__(self) -> None: super().__init__() self._key_release_generators = {} self.keymap_providers = [] @property def keymap_chain(self): """collections.ChainMap: Chain of keymaps from keymap providers.""" maps = [_get_user_keymap()] for parent in self.keymap_providers: maps.append(_bind_keymap(parent.keymap, parent)) # For parent and superclasses add inherited keybindings for cls in parent.__class__.__mro__: if hasattr(cls, 'class_keymap'): maps.append(_bind_keymap(cls.class_keymap, parent)) return ChainMap(*maps) @property def active_keymap(self): """dict: Active keymap, created by resolving the keymap chain.""" active_keymap = self.keymap_chain keymaps = active_keymap.maps for i, keymap in enumerate(keymaps): if Ellipsis in keymap: # catch-all key # trim all keymaps after catch-all active_keymap = ChainMap(*keymaps[: i + 1]) break active_keymap_final = { k: func for k, func in active_keymap.items() if func is not Ellipsis } return active_keymap_final def press_key(self, key_bind): """Simulate a key press to activate a keybinding. Parameters ---------- key_bind : keybinding-like Key combination. """ key_bind = coerce_keybinding(key_bind) keymap = self.active_keymap if key_bind in keymap: func = keymap[key_bind] elif Ellipsis in keymap: # catch-all func = keymap[...] else: return # no keybinding found if func is Ellipsis: # blocker return elif not callable(func): raise TypeError( trans._( "expected {func} to be callable", deferred=True, func=func, ) ) generator_or_callback = func() key = str(key_bind.parts[-1].key) if inspect.isgeneratorfunction(func): try: next(generator_or_callback) # call function except StopIteration: # only one statement pass else: self._key_release_generators[key] = generator_or_callback if isinstance(generator_or_callback, Callable): self._key_release_generators[key] = ( generator_or_callback, time.time(), ) def release_key(self, key_bind): """Simulate a key release for a keybinding. Parameters ---------- key_bind : keybinding-like Key combination. """ from napari.settings import get_settings key_bind = coerce_keybinding(key_bind) key = str(key_bind.parts[-1].key) with contextlib.suppress(KeyError, StopIteration): val = self._key_release_generators[key] # val could be callback function with time to check # if it should be called or generator that need to make # additional step on key release if isinstance(val, tuple): callback, start = val if ( time.time() - start > get_settings().application.hold_button_delay ): callback() else: next(val) # call function def on_key_press(self, event): """Called whenever key pressed in canvas. Parameters ---------- event : vispy.util.event.Event The vispy key press event that triggered this method. """ from napari.utils.action_manager import action_manager if event.key is None: # TODO determine when None key could be sent. return kb = _vispy2appmodel(event) repeatables = { *action_manager._get_repeatable_shortcuts(self.keymap_chain), "Up", "Down", "Left", "Right", } if ( event.native is not None and event.native.isAutoRepeat() and kb not in repeatables ) or event.key is None: # pass if no key is present or if the shortcut combo is held down, # unless the combo being held down is one of the autorepeatables or # one of the navigation keys (helps with scrolling). return self.press_key(kb) def on_key_release(self, event): """Called whenever key released in canvas. Parameters ---------- event : vispy.util.event.Event The vispy key release event that triggered this method. """ if event.key is None or ( # on linux press down is treated as multiple press and release event.native is not None and event.native.isAutoRepeat() ): return kb = _vispy2appmodel(event) self.release_key(kb) napari-0.5.0a1/napari/utils/migrations.py000066400000000000000000000027741437041365600203730ustar00rootroot00000000000000import warnings from functools import wraps from napari.utils.translations import trans def rename_argument(from_name: str, to_name: str, version: str): """ This is decorator for simple rename function argument without break backward compatibility. Parameters ---------- from_name : str old name of argument to_name : str new name of argument """ def _wrapper(func): @wraps(func) def _update_from_dict(*args, **kwargs): if from_name in kwargs: if to_name in kwargs: raise ValueError( trans._( "Argument {to_name} already defined, please do not mix {from_name} and {to_name} in one call.", from_name=from_name, to_name=to_name, ) ) warnings.warn( trans._( "Argument {from_name} is deprecated, please use {to_name} instead. It will be removed in {version}.", from_name=from_name, to_name=to_name, version=version, ), category=DeprecationWarning, stacklevel=2, ) kwargs = kwargs.copy() kwargs[to_name] = kwargs.pop(from_name) return func(*args, **kwargs) return _update_from_dict return _wrapper napari-0.5.0a1/napari/utils/misc.py000066400000000000000000000524601437041365600171470ustar00rootroot00000000000000"""Miscellaneous utility functions. """ import builtins import collections.abc import contextlib import importlib.metadata import inspect import itertools import os import re import sys import warnings from enum import Enum, EnumMeta from os import fspath from os import path as os_path from pathlib import Path from typing import ( TYPE_CHECKING, Any, Callable, Iterable, Iterator, List, Optional, Sequence, Tuple, Type, TypeVar, Union, ) import numpy as np from napari.utils.translations import trans if TYPE_CHECKING: import packaging.version ROOT_DIR = os_path.dirname(os_path.dirname(__file__)) def parse_version(v) -> 'packaging.version._BaseVersion': """Parse a version string and return a packaging.version.Version obj.""" import packaging.version try: return packaging.version.Version(v) except packaging.version.InvalidVersion: return packaging.version.LegacyVersion(v) def running_as_bundled_app(*, check_conda=True) -> bool: """Infer whether we are running as a briefcase bundle.""" # https://github.com/beeware/briefcase/issues/412 # https://github.com/beeware/briefcase/pull/425 # note that a module may not have a __package__ attribute # From 0.4.12 we add a sentinel file next to the bundled sys.executable if ( check_conda and (Path(sys.executable).parent / ".napari_is_bundled").exists() ): return True try: app_module = sys.modules['__main__'].__package__ except AttributeError: return False try: metadata = importlib.metadata.metadata(app_module) except importlib.metadata.PackageNotFoundError: return False return 'Briefcase-Version' in metadata def running_as_constructor_app() -> bool: """Infer whether we are running as a constructor bundle.""" return ( Path(sys.prefix).parent.parent / ".napari_is_bundled_constructor" ).exists() def bundle_bin_dir() -> Optional[str]: """Return path to briefcase app_packages/bin if it exists.""" bin = os_path.join(os_path.dirname(sys.exec_prefix), 'app_packages', 'bin') if os_path.isdir(bin): return bin def in_jupyter() -> bool: """Return true if we're running in jupyter notebook/lab or qtconsole.""" with contextlib.suppress(ImportError): from IPython import get_ipython return get_ipython().__class__.__name__ == 'ZMQInteractiveShell' return False def in_ipython() -> bool: """Return true if we're running in an IPython interactive shell.""" with contextlib.suppress(ImportError): from IPython import get_ipython return get_ipython().__class__.__name__ == 'TerminalInteractiveShell' return False def in_python_repl() -> bool: """Return true if we're running in a Python REPL.""" with contextlib.suppress(ImportError): from IPython import get_ipython return get_ipython().__class__.__name__ == 'NoneType' and hasattr( sys, 'ps1' ) return False def str_to_rgb(arg): """Convert an rgb string 'rgb(x,y,z)' to a list of ints [x,y,z].""" return list( map(int, re.match(r'rgb\((\d+),\s*(\d+),\s*(\d+)\)', arg).groups()) ) def ensure_iterable(arg, color=False): """Ensure an argument is an iterable. Useful when an input argument can either be a single value or a list. If a color is passed then it will be treated specially to determine if it is iterable. """ if is_iterable(arg, color=color): return arg else: return itertools.repeat(arg) def is_iterable(arg, color=False, allow_none=False): """Determine if a single argument is an iterable. If a color is being provided and the argument is a 1-D array of length 3 or 4 then the input is taken to not be iterable. If allow_none is True, `None` is considered iterable. """ if arg is None and not allow_none: return False elif type(arg) is str: return False elif np.isscalar(arg): return False elif color and isinstance(arg, (list, np.ndarray)): if np.array(arg).ndim == 1 and (len(arg) == 3 or len(arg) == 4): return False else: return True else: return True def is_sequence(arg): """Check if ``arg`` is a sequence like a list or tuple. return True: list tuple return False string numbers dict set """ if isinstance(arg, collections.abc.Sequence) and not isinstance(arg, str): return True return False def ensure_sequence_of_iterables( obj, length: Optional[int] = None, repeat_empty: bool = False, allow_none: bool = False, ): """Ensure that ``obj`` behaves like a (nested) sequence of iterables. If length is provided and the object is already a sequence of iterables, a ValueError will be raised if ``len(obj) != length``. Parameters ---------- obj : Any the object to check length : int, optional If provided, assert that obj has len ``length``, by default None repeat_empty : bool whether to repeat an empty sequence (otherwise return the empty sequence itself) allow_none : bool treat None as iterable Returns ------- iterable nested sequence of iterables, or an itertools.repeat instance Examples -------- In [1]: ensure_sequence_of_iterables([1, 2]) Out[1]: repeat([1, 2]) In [2]: ensure_sequence_of_iterables([(1, 2), (3, 4)]) Out[2]: [(1, 2), (3, 4)] In [3]: ensure_sequence_of_iterables([(1, 2), None], allow_none=True) Out[3]: [(1, 2), None] In [4]: ensure_sequence_of_iterables({'a':1}) Out[4]: repeat({'a': 1}) In [5]: ensure_sequence_of_iterables(None) Out[5]: repeat(None) In [6]: ensure_sequence_of_iterables([]) Out[6]: repeat([]) In [7]: ensure_sequence_of_iterables([], repeat_empty=False) Out[7]: [] """ if ( obj is not None and is_sequence(obj) and all(is_iterable(el, allow_none=allow_none) for el in obj) ): if length is not None and len(obj) != length: if (len(obj) == 0 and not repeat_empty) or len(obj) > 0: # sequence of iterables of wrong length raise ValueError( trans._( "length of {obj} must equal {length}", deferred=True, obj=obj, length=length, ) ) if len(obj) > 0 or not repeat_empty: return obj return itertools.repeat(obj) def formatdoc(obj): """Substitute globals and locals into an object's docstring.""" frame = inspect.currentframe().f_back try: obj.__doc__ = obj.__doc__.format( **{**frame.f_globals, **frame.f_locals} ) return obj finally: del frame class StringEnumMeta(EnumMeta): def __getitem__(self, item): """set the item name case to uppercase for name lookup""" if isinstance(item, str): item = item.upper() return super().__getitem__(item) def __call__( cls, value, names=None, *, module=None, qualname=None, type=None, start=1, ): """set the item value case to lowercase for value lookup""" # simple value lookup if names is None: if isinstance(value, str): return super().__call__(value.lower()) elif isinstance(value, cls): return value else: raise ValueError( trans._( '{class_name} may only be called with a `str` or an instance of {class_name}. Got {dtype}', deferred=True, class_name=cls, dtype=builtins.type(value), ) ) # otherwise create new Enum class return cls._create_( value, names, module=module, qualname=qualname, type=type, start=start, ) def keys(self): return list(map(str, self)) class StringEnum(Enum, metaclass=StringEnumMeta): def _generate_next_value_(name, start, count, last_values): """autonaming function assigns each value its own name as a value""" return name.lower() def __str__(self): """String representation: The string method returns the lowercase string of the Enum name """ return self.value def __eq__(self, other): if type(self) is type(other): return self is other elif isinstance(other, str): return str(self) == other return NotImplemented def __hash__(self): return hash(str(self)) camel_to_snake_pattern = re.compile(r'(.)([A-Z][a-z]+)') camel_to_spaces_pattern = re.compile( r"((?<=[a-z])[A-Z]|(? T: """Utility function that normalizes paths or a sequence thereof. Expands user directory and converts relpaths to abspaths... but ignores URLS that begin with "http", "ftp", or "file". Parameters ---------- relpath : str|Path A path, either as string or Path object. must_exist : bool, default True Raise ValueError if `relpath` is not a URL and does not exist. Returns ------- abspath : str|Path An absolute path, or list or tuple of absolute paths (same type as input) """ from urllib.parse import urlparse if not isinstance(relpath, (str, Path)): raise TypeError( trans._("Argument must be a string or Path", deferred=True) ) OriginType = type(relpath) relpath = fspath(relpath) urlp = urlparse(relpath) if urlp.scheme and urlp.netloc: return relpath path = os_path.abspath(os_path.expanduser(relpath)) if must_exist and not (urlp.scheme or urlp.netloc or os.path.exists(path)): raise ValueError( trans._( "Requested path {path!r} does not exist.", deferred=True, path=path, ) ) return OriginType(path) class CallDefault(inspect.Parameter): def __str__(self): """wrap defaults""" kind = self.kind formatted = self._name # Fill in defaults if ( self._default is not inspect._empty or kind == inspect._KEYWORD_ONLY ): formatted = f'{formatted}={formatted}' if kind == inspect._VAR_POSITIONAL: formatted = '*' + formatted elif kind == inspect._VAR_KEYWORD: formatted = '**' + formatted return formatted def all_subclasses(cls: Type) -> set: """Recursively find all subclasses of class ``cls``. Parameters ---------- cls : class A python class (or anything that implements a __subclasses__ method). Returns ------- set the set of all classes that are subclassed from ``cls`` """ return set(cls.__subclasses__()).union( [s for c in cls.__subclasses__() for s in all_subclasses(c)] ) def ensure_n_tuple(val, n, fill=0): """Ensure input is a length n tuple. Parameters ---------- val : iterable Iterable to be forced into length n-tuple. n : int Length of tuple. Returns ------- tuple Coerced tuple. """ assert n > 0, 'n must be greater than 0' tuple_value = tuple(val) return (fill,) * (n - len(tuple_value)) + tuple_value[-n:] def ensure_layer_data_tuple(val): msg = trans._( 'Not a valid layer data tuple: {value!r}', deferred=True, value=val, ) if not isinstance(val, tuple) and val: raise TypeError(msg) if len(val) > 1: if not isinstance(val[1], dict): raise TypeError(msg) if len(val) > 2 and not isinstance(val[2], str): raise TypeError(msg) return val def ensure_list_of_layer_data_tuple(val) -> List[tuple]: # allow empty list to be returned but do nothing in that case if isinstance(val, list): with contextlib.suppress(TypeError): return [ensure_layer_data_tuple(v) for v in val] raise TypeError( trans._('Not a valid list of layer data tuples!', deferred=True) ) def _quiet_array_equal(*a, **k): with warnings.catch_warnings(): warnings.filterwarnings("ignore", "elementwise comparison") return np.array_equal(*a, **k) def _arraylike_short_names(obj) -> Iterator[str]: """Yield all the short names of an array-like or its class.""" type_ = type(obj) if not inspect.isclass(obj) else obj for base in type_.mro(): yield f'{base.__module__.split(".", maxsplit=1)[0]}.{base.__name__}' def pick_equality_operator(obj) -> Callable[[Any, Any], bool]: """Return a function that can check equality between ``obj`` and another. Rather than always using ``==`` (i.e. ``operator.eq``), this function returns operators that are aware of object types: mostly "array types with more than one element" whose truth value is ambiguous. This function works for both classes (types) and instances. If an instance is passed, it will be first cast to a type with type(obj). Parameters ---------- obj : Any An object whose equality with another object you want to check. Returns ------- operator : Callable[[Any, Any], bool] An operation that can be called as ``operator(obj, other)`` to check equality between objects of type ``type(obj)``. """ import operator # yes, it's a little riskier, but we are checking namespaces instead of # actual `issubclass` here to avoid slow import times _known_arrays = { 'numpy.ndarray': _quiet_array_equal, # numpy.ndarray 'dask.Array': operator.is_, # dask.array.core.Array 'dask.Delayed': operator.is_, # dask.delayed.Delayed 'zarr.Array': operator.is_, # zarr.core.Array 'xarray.DataArray': _quiet_array_equal, # xarray.core.dataarray.DataArray } for name in _arraylike_short_names(obj): func = _known_arrays.get(name) if func: return func return operator.eq def _is_array_type(array, type_name: str) -> bool: """Checks if an array-like instance or class is of the type described by a short name. This is useful when you want to check the type of array-like quickly without importing its package, which might take a long time. Parameters ---------- array The array-like object. type_name : str The short name of the type to test against (e.g. 'numpy.ndarray', 'xarray.DataArray'). Returns ------- True if the array is associated with the type name. """ return type_name in _arraylike_short_names(array) def dir_hash( path: Union[str, Path], include_paths=True, ignore_hidden=True ) -> str: """Compute the hash of a directory, based on structure and contents. Parameters ---------- path : Union[str, Path] Source path which will be used to select all files (and files in subdirectories) to compute the hexadecimal digest. include_paths : bool If ``True``, the hash will also include the ``file`` parts. ignore_hidden : bool If ``True``, hidden files (starting with ``.``) will be ignored when computing the hash. Returns ------- hash : str Hexadecimal digest of all files in the provided path. """ import hashlib if not Path(path).is_dir(): raise TypeError( trans._( "{path} is not a directory.", deferred=True, path=path, ) ) hash_func = hashlib.md5 _hash = hash_func() for root, _, files in os.walk(path): for fname in sorted(files): if fname.startswith(".") and ignore_hidden: continue _file_hash(_hash, Path(root) / fname, path, include_paths) return _hash.hexdigest() def paths_hash( paths: Iterable[Union[str, Path]], include_paths: bool = True, ignore_hidden: bool = True, ) -> str: """Compute the hash of list of paths. Parameters ---------- paths : Iterable[Union[str, Path]] An iterable of paths to files which will be used when computing the hash. include_paths : bool If ``True``, the hash will also include the ``file`` parts. ignore_hidden : bool If ``True``, hidden files (starting with ``.``) will be ignored when computing the hash. Returns ------- hash : str Hexadecimal digest of the contents of provided files. """ import hashlib hash_func = hashlib.md5 _hash = hash_func() for file_path in sorted(paths): file_path = Path(file_path) if ignore_hidden and str(file_path.stem).startswith("."): continue _file_hash(_hash, file_path, file_path.parent, include_paths) return _hash.hexdigest() def _file_hash(_hash, file: Path, path: Path, include_paths: bool = True): """Update hash with based on file contents and optionally relative path. Parameters ---------- _hash file : Path Path to the source file which will be used to compute the hash. path : Path Path to the base directory of the `file`. This can be usually obtained by using `file.parent`. include_paths : bool If ``True``, the hash will also include the ``file`` parts. """ _hash.update(file.read_bytes()) if include_paths: # update the hash with the filename fparts = file.relative_to(path).parts _hash.update(''.join(fparts).encode()) def _combine_signatures( *objects: Callable, return_annotation=inspect.Signature.empty, exclude=() ) -> inspect.Signature: """Create combined Signature from objects, excluding names in `exclude`. Parameters ---------- *objects : Callable callables whose signatures should be combined return_annotation : [type], optional The return annotation to use for combined signature, by default inspect.Signature.empty (as it's ambiguous) exclude : tuple, optional Parameter names to exclude from the combined signature (such as 'self'), by default () Returns ------- inspect.Signature Signature object with the combined signature. Reminder, str(signature) provides a very nice repr for code generation. """ params = itertools.chain( *(inspect.signature(o).parameters.values() for o in objects) ) new_params = sorted( (p for p in params if p.name not in exclude), key=lambda p: p.kind, ) return inspect.Signature(new_params, return_annotation=return_annotation) def deep_update(dct: dict, merge_dct: dict, copy=True) -> dict: """Merge possibly nested dicts""" _dct = dct.copy() if copy else dct for k, v in merge_dct.items(): if k in _dct and isinstance(dct[k], dict) and isinstance(v, dict): deep_update(_dct[k], v, copy=False) else: _dct[k] = v return _dct def install_certifi_opener(): """Install urlopener that uses certifi context. This is useful in the bundle, where otherwise users might get SSL errors when using `urllib.request.urlopen`. """ import ssl from urllib import request import certifi context = ssl.create_default_context(cafile=certifi.where()) https_handler = request.HTTPSHandler(context=context) opener = request.build_opener(https_handler) request.install_opener(opener) def reorder_after_dim_reduction(order: Sequence[int]) -> Tuple[int, ...]: """Ensure current dimension order is preserved after dims are dropped. This is similar to :func:`scipy.stats.rankdata`, but only deals with unique integers (like dimension indices), so is simpler and faster. Parameters ---------- order : Sequence[int] The data to reorder. Returns ------- Tuple[int, ...] A permutation of ``range(len(order))`` that is consistent with the input order. Examples -------- >>> reorder_after_dim_reduction([2, 0]) (1, 0) >>> reorder_after_dim_reduction([0, 1, 2]) (0, 1, 2) >>> reorder_after_dim_reduction([4, 0, 2]) (2, 0, 1) """ # A single argsort works for strictly increasing/decreasing orders, # but not for arbitrary orders. return tuple(argsort(argsort(order))) def argsort(values: Sequence[int]) -> List[int]: """Equivalent to :func:`numpy.argsort` but faster in some cases. Parameters ---------- values : Sequence[int] The integer values to sort. Returns ------- List[int] The indices that when used to index the input values will produce the values sorted in increasing order. Examples -------- >>> argsort([2, 0]) [1, 0] >>> argsort([0, 1, 2]) [0, 1, 2] >>> argsort([4, 0, 2]) [1, 2, 0] """ return sorted(range(len(values)), key=values.__getitem__) napari-0.5.0a1/napari/utils/mouse_bindings.py000066400000000000000000000024401437041365600212120ustar00rootroot00000000000000from typing import List class MousemapProvider: """Mix-in to add mouse binding functionality. Attributes ---------- mouse_move_callbacks : list Callbacks from when mouse moves with nothing pressed. mouse_drag_callbacks : list Callbacks from when mouse is pressed, dragged, and released. mouse_wheel_callbacks : list Callbacks from when mouse wheel is scrolled. mouse_double_click_callbacks : list Callbacks from when mouse wheel is scrolled. """ mouse_move_callbacks: List[callable] mouse_wheel_callbacks: List[callable] mouse_drag_callbacks: List[callable] mouse_double_click_callbacks: List[callable] def __init__(self, *args, **kwargs) -> None: super().__init__(*args, **kwargs) # Hold callbacks for when mouse moves with nothing pressed self.mouse_move_callbacks = [] # Hold callbacks for when mouse is pressed, dragged, and released self.mouse_drag_callbacks = [] # hold callbacks for when mouse is double clicked self.mouse_double_click_callbacks = [] # Hold callbacks for when mouse wheel is scrolled self.mouse_wheel_callbacks = [] self._persisted_mouse_event = {} self._mouse_drag_gen = {} self._mouse_wheel_gen = {} napari-0.5.0a1/napari/utils/naming.py000066400000000000000000000117771437041365600174730ustar00rootroot00000000000000"""Automatically generate names. """ import inspect import re from collections import ChainMap from types import FrameType from typing import Any, Callable, Optional from napari.utils.misc import ROOT_DIR, formatdoc sep = ' ' start = 1 # Match integer between square brackets at end of string if after space # or at beginning of string or just match end of string numbered_patt = re.compile(r'((?<=\A\[)|(?<=\s\[))(?:\d+|)(?=\]$)|$') def _inc_name_count_sub(match): count = match.group(0) try: count = int(count) except ValueError: # not an int count = f'{sep}[{start}]' else: count = f'{count + 1}' return count @formatdoc def inc_name_count(name: str) -> str: """Increase a name's count matching `{numbered_patt}` by ``1``. If the name is not already numbered, append '{sep}[{start}]'. Parameters ---------- name : str Original name. Returns ------- incremented_name : str Numbered name incremented by ``1``. """ return numbered_patt.sub(_inc_name_count_sub, name, count=1) class CallerFrame: """ Context manager to access the namespace in one of the upper caller frames. It is a context manager in order to be able to properly cleanup references to some frame objects after it is gone. Constructor takes a predicate taking a index and frame and returning whether to skip this frame and keep walking up the stack. The index starts at 1 (caller frame), and increases. For example the following gives you the caller: - at least 5 Frames up - at most 42 Frames up - first one outside of Napari def skip_napari_frames(index, frame): if index < 5: return True if index > 42: return False return frame.f_globals.get("__name__", '').startswith('napari') with CallerFrame(skip_napari_frames) as c: print(c.namespace) This will be used for two things: - find the name of a value in caller frame. - capture local namespace of `napari.run()` when starting the qt-console For more complex logic you could use a callable that keep track of previous/state/frames, though be careful, the predicate is not guarantied to be called on all subsequents frames. """ def __init__( self, skip_predicate: Callable[[int, FrameType], bool] ) -> None: self.predicate = skip_predicate self.namespace = {} self.names = () def __enter__(self): frame = inspect.currentframe().f_back try: # See issue #1635 regarding potential AttributeError # since frame could be None. # https://github.com/napari/napari/pull/1635 if inspect.isframe(frame): frame = frame.f_back # Iterate frames while filename starts with path_prefix (part of Napari) n = 1 while ( inspect.isframe(frame) and inspect.isframe(frame.f_back) and inspect.iscode(frame.f_code) and (self.predicate(n, frame)) ): n += 1 frame = frame.f_back self.frame = frame if inspect.isframe(frame) and inspect.iscode(frame.f_code): self.namespace = ChainMap(frame.f_locals, frame.f_globals) self.names = ( *frame.f_code.co_varnames, *frame.f_code.co_names, ) finally: # We need to delete the frame explicitly according to the inspect # documentation for deterministic removal of the frame. # Otherwise, proper deletion is dependent on a cycle detector and # automatic garbage collection. # See handle_stackframe_without_leak example at the following URLs: # https://docs.python.org/3/library/inspect.html#the-interpreter-stack # https://bugs.python.org/issue543148 del frame return self def __exit__(self, exc_type, exc_value, traceback): del self.namespace del self.names def magic_name(value: Any, *, path_prefix: str = ROOT_DIR) -> Optional[str]: """Fetch the name of the variable with the given value passed to the calling function. Parameters ---------- value : any The value of the desired variable. path_prefix : absolute path-like, kwonly The path prefixes to ignore. Returns ------- name : str or None Name of the variable, if found. """ # Iterate frames while filename starts with path_prefix (part of Napari) with CallerFrame( lambda n, frame: frame.f_code.co_filename.startswith(path_prefix) ) as w: varmap = w.namespace names = w.names for name in names: if ( name.isidentifier() and name in varmap and varmap[name] is value ): return name return None napari-0.5.0a1/napari/utils/notebook_display.py000066400000000000000000000100231437041365600215460ustar00rootroot00000000000000import base64 import html from io import BytesIO from warnings import warn try: from lxml.etree import ParserError from lxml.html import document_fromstring from lxml.html.clean import Cleaner lxml_unavailable = False except ModuleNotFoundError: lxml_unavailable = True __all__ = ['nbscreenshot'] class NotebookScreenshot: """Display napari screenshot in the jupyter notebook. Functions returning an object with a _repr_png_() method will displayed as a rich image in the jupyter notebook. https://ipython.readthedocs.io/en/stable/api/generated/IPython.display.html Parameters ---------- viewer : napari.Viewer The napari viewer. canvas_only : bool, optional If True includes the napari viewer frame in the screenshot, otherwise just includes the canvas. By default, True. Examples -------- ``` import napari from napari.utils import nbscreenshot from skimage.data import chelsea viewer = napari.view_image(chelsea(), name='chelsea-the-cat') nbscreenshot(viewer) # screenshot just the canvas with the napari viewer framing it nbscreenshot(viewer, canvas_only=False) ``` """ def __init__( self, viewer, *, canvas_only=False, alt_text=None, ) -> None: """Initialize screenshot object. Parameters ---------- viewer : napari.Viewer The napari viewer canvas_only : bool, optional If False include the napari viewer frame in the screenshot, and if True then take screenshot of just the image display canvas. By default, False. alt_text : str, optional Image description alternative text, for screenreader accessibility. Good alt-text describes the image and any text within the image in no more than three short, complete sentences. """ self.viewer = viewer self.canvas_only = canvas_only self.image = None self.alt_text = self._clean_alt_text(alt_text) def _clean_alt_text(self, alt_text): """Clean user input to prevent script injection.""" if alt_text is not None: if lxml_unavailable: warn( 'The lxml library is not installed, and is required to ' 'sanitize alt text for napari screenshots. Alt-text ' 'will be stripped altogether without lxml.' ) return None # cleaner won't recognize escaped script tags, so always unescape # to be safe alt_text = html.unescape(str(alt_text)) cleaner = Cleaner() try: doc = document_fromstring(alt_text) alt_text = cleaner.clean_html(doc).text_content() except ParserError: warn( 'The provided alt text does not constitute valid html, so it was discarded.', stacklevel=3, ) alt_text = "" if alt_text == "": alt_text = None return alt_text def _repr_png_(self): """PNG representation of the viewer object for IPython. Returns ------- In memory binary stream containing PNG screenshot image. """ from imageio import imsave from napari._qt.qt_event_loop import get_app get_app().processEvents() self.image = self.viewer.screenshot( canvas_only=self.canvas_only, flash=False ) with BytesIO() as file_obj: imsave(file_obj, self.image, format='png') file_obj.seek(0) png = file_obj.read() return png def _repr_html_(self): png = self._repr_png_() url = 'data:image/png;base64,' + base64.b64encode(png).decode('utf-8') _alt = html.escape(self.alt_text) if self.alt_text is not None else '' return f'{_alt}' nbscreenshot = NotebookScreenshot napari-0.5.0a1/napari/utils/notifications.py000066400000000000000000000274441437041365600210710ustar00rootroot00000000000000from __future__ import annotations import os import sys import threading import warnings from datetime import datetime from enum import auto from types import TracebackType from typing import Callable, List, Optional, Sequence, Tuple, Type, Union from napari.utils.events import Event, EventEmitter from napari.utils.misc import StringEnum try: from napari_error_reporter import capture_exception, install_error_reporter except ImportError: def _noop(*_, **__): pass install_error_reporter = _noop capture_exception = _noop name2num = { 'error': 40, 'warning': 30, 'info': 20, 'debug': 10, 'none': 0, } __all__ = [ 'NotificationSeverity', 'Notification', 'ErrorNotification', 'WarningNotification', 'NotificationManager', 'show_debug', 'show_info', 'show_warning', 'show_error', 'show_console_notification', ] class NotificationSeverity(StringEnum): """Severity levels for the notification dialog. Along with icons for each.""" ERROR = auto() WARNING = auto() INFO = auto() DEBUG = auto() NONE = auto() def as_icon(self): return { self.ERROR: "ⓧ", self.WARNING: "⚠️", self.INFO: "ⓘ", self.DEBUG: "🐛", self.NONE: "", }[self] def __lt__(self, other): return name2num[str(self)] < name2num[str(other)] def __le__(self, other): return name2num[str(self)] <= name2num[str(other)] def __gt__(self, other): return name2num[str(self)] > name2num[str(other)] def __ge__(self, other): return name2num[str(self)] >= name2num[str(other)] def __eq__(self, other): return str(self) == str(other) def __hash__(self): return hash(self.value) ActionSequence = Sequence[Tuple[str, Callable[[], None]]] class Notification(Event): """A Notifcation event. Usually created by :class:`NotificationManager`. Parameters ---------- message : str The main message/payload of the notification. severity : str or NotificationSeverity, optional The severity of the notification, by default `NotificationSeverity.WARNING`. actions : sequence of tuple, optional Where each tuple is a `(str, callable)` 2-tuple where the first item is a name for the action (which may, for example, be put on a button), and the callable is a callback to perform when the action is triggered. (for example, one might show a traceback dialog). by default () """ def __init__( self, message: str, severity: Union[ str, NotificationSeverity ] = NotificationSeverity.WARNING, actions: ActionSequence = (), **kwargs, ) -> None: self.severity = NotificationSeverity(severity) super().__init__(type=str(self.severity).lower(), **kwargs) self._message = message self.actions = actions # let's store when the object was created; self.date = datetime.now() @property def message(self): return self._message @message.setter def message(self, value): self._message = value @classmethod def from_exception(cls, exc: BaseException, **kwargs) -> Notification: return ErrorNotification(exc, **kwargs) @classmethod def from_warning(cls, warning: Warning, **kwargs) -> Notification: return WarningNotification(warning, **kwargs) def __str__(self): return f'{str(self.severity).upper()}: {self.message}' class ErrorNotification(Notification): """ Notification at an Error severity level. """ exception: BaseException def __init__(self, exception: BaseException, *args, **kwargs) -> None: msg = getattr(exception, 'message', str(exception)) actions = getattr(exception, 'actions', ()) super().__init__(msg, NotificationSeverity.ERROR, actions) self.exception = exception def as_html(self): from napari.utils._tracebacks import get_tb_formatter fmt = get_tb_formatter() exc_info = ( self.exception.__class__, self.exception, self.exception.__traceback__, ) return fmt(exc_info, as_html=True) def as_text(self): from napari.utils._tracebacks import get_tb_formatter fmt = get_tb_formatter() exc_info = ( self.exception.__class__, self.exception, self.exception.__traceback__, ) return fmt(exc_info, as_html=False, color="NoColor") def __str__(self): from napari.utils._tracebacks import get_tb_formatter fmt = get_tb_formatter() exc_info = ( self.exception.__class__, self.exception, self.exception.__traceback__, ) return fmt(exc_info, as_html=False) class WarningNotification(Notification): """ Notification at a Warning severity level. """ warning: Warning def __init__( self, warning: Warning, filename=None, lineno=None, *args, **kwargs ) -> None: msg = getattr(warning, 'message', str(warning)) actions = getattr(warning, 'actions', ()) super().__init__(msg, NotificationSeverity.WARNING, actions) self.warning = warning self.filename = filename self.lineno = lineno def __str__(self): category = type(self.warning).__name__ return f'{self.filename}:{self.lineno}: {category}: {self.warning}!' class NotificationManager: """ A notification manager, to route all notifications through. Only one instance is in general available through napari; as we need notification to all flow to a single location that is registered with the sys.except_hook and showwarning hook. This can and should be used a context manager; the context manager will properly re-entered, and install/remove hooks and keep them in a stack to restore them. While it might seem unnecessary to make it re-entrant; or to make the re-entrancy no-op; one need to consider that this could be used inside another context manager that modify except_hook and showwarning. Currently the original except and show warnings hooks are not called; but this could be changed in the future; this poses some questions with the re-entrency of the hooks themselves. """ records: List[Notification] _instance: Optional[NotificationManager] = None def __init__(self) -> None: self.records: List[Notification] = [] self.exit_on_error = os.getenv('NAPARI_EXIT_ON_ERROR') in ('1', 'True') self.catch_error = os.getenv("NAPARI_CATCH_ERRORS") not in ( '0', 'False', ) self.notification_ready = self.changed = EventEmitter( source=self, event_class=Notification ) self._originals_except_hooks = [] self._original_showwarnings_hooks = [] self._originals_thread_except_hooks = [] def __enter__(self): self.install_hooks() return self def __exit__(self, *args, **kwargs): self.restore_hooks() def install_hooks(self): """ Install a `sys.excepthook`, a `showwarning` hook and a threading.excepthook to display any message in the UI, storing the previous hooks to be restored if necessary. """ if getattr(threading, 'excepthook', None): # TODO: we might want to display the additional thread information self._originals_thread_except_hooks.append(threading.excepthook) threading.excepthook = self.receive_thread_error else: # Patch for Python < 3.8 _setup_thread_excepthook() install_error_reporter() self._originals_except_hooks.append(sys.excepthook) self._original_showwarnings_hooks.append(warnings.showwarning) sys.excepthook = self.receive_error warnings.showwarning = self.receive_warning def restore_hooks(self): """ Remove hooks installed by `install_hooks` and restore previous hooks. """ if getattr(threading, 'excepthook', None): # `threading.excepthook` available only for Python >= 3.8 threading.excepthook = self._originals_thread_except_hooks.pop() sys.excepthook = self._originals_except_hooks.pop() warnings.showwarning = self._original_showwarnings_hooks.pop() def dispatch(self, notification: Notification): self.records.append(notification) self.notification_ready(notification) def receive_thread_error(self, args: threading.ExceptHookArgs): self.receive_error(*args) def receive_error( self, exctype: Optional[Type[BaseException]] = None, value: Optional[BaseException] = None, traceback: Optional[TracebackType] = None, thread: Optional[threading.Thread] = None, ): if isinstance(value, KeyboardInterrupt): sys.exit("Closed by KeyboardInterrupt") capture_exception(value) if self.exit_on_error: sys.__excepthook__(exctype, value, traceback) sys.exit("Exit on error") if not self.catch_error: sys.__excepthook__(exctype, value, traceback) return self.dispatch(Notification.from_exception(value)) def receive_warning( self, message: Warning, category: Type[Warning], filename: str, lineno: int, file=None, line=None, ): self.dispatch( Notification.from_warning( message, filename=filename, lineno=lineno ) ) def receive_info(self, message: str): self.dispatch(Notification(message, 'INFO')) notification_manager = NotificationManager() def show_debug(message: str): """ Show a debug message in the notification manager. """ notification_manager.dispatch( Notification(message, severity=NotificationSeverity.DEBUG) ) def show_info(message: str): """ Show an info message in the notification manager. """ notification_manager.dispatch( Notification(message, severity=NotificationSeverity.INFO) ) def show_warning(message: str): """ Show a warning in the notification manager. """ notification_manager.dispatch( Notification(message, severity=NotificationSeverity.WARNING) ) def show_error(message: str): """ Show an error in the notification manager. """ notification_manager.dispatch( Notification(message, severity=NotificationSeverity.ERROR) ) def show_console_notification(notification: Notification): """ Show a notification in the console. """ try: from napari.settings import get_settings if ( notification.severity < get_settings().application.console_notification_level ): return print(notification) except Exception: print( "An error occurred while trying to format an error and show it in console.\n" "You can try to uninstall IPython to disable rich traceback formatting\n" "And/or report a bug to napari" ) # this will likely get silenced by QT. raise def _setup_thread_excepthook(): """ Workaround for `sys.excepthook` thread bug from: http://bugs.python.org/issue1230540 """ _init = threading.Thread.__init__ def init(self, *args, **kwargs): _init(self, *args, **kwargs) _run = self.run def run_with_except_hook(*args2, **kwargs2): try: _run(*args2, **kwargs2) except Exception: # noqa BLE001 sys.excepthook(*sys.exc_info()) self.run = run_with_except_hook threading.Thread.__init__ = init napari-0.5.0a1/napari/utils/perf/000077500000000000000000000000001437041365600165675ustar00rootroot00000000000000napari-0.5.0a1/napari/utils/perf/__init__.py000066400000000000000000000043161437041365600207040ustar00rootroot00000000000000"""Performance Monitoring. The perfmon module lets you instrument your code and visualize its run-time behavior and timings in Chrome's Tracing GUI. To enable perfmon define the env var NAPARI_PERFMON as follows: NAPARI_PERFMON=1 Activates perfmon, trace using Debug -> Performance Trace menu. NAPARI_PERFMON=/path/to/config.json Configure perfmon using the config.json configuration. See the PerfmonConfig docs for the spec of the config file. Chrome Tracing --------------- Chrome has a nice built-in performance tool called chrome://tracing. Chrome can record traces of web applications. But the format is well-documented and anyone can create the files and use the nice GUI. And other programs accept the format including: 1) https://www.speedscope.app/ which does flamegraphs (Chrome doesn't). 2) Qt Creator's performance tools. Monkey Patching --------------- The best way to add perf_timers is using the perfmon config file. You can list which methods or functions you want to time, and a perf_timer will be monkey-patched into each callable on startup. The monkey patching is done only if perfmon is enabled. Trace On Start --------------- Add a line to the config file like: "trace_file_on_start": "/Path/to/my/trace.json" Perfmon will start tracing on startup. You must quit napari with the Quit command for napari to write trace file. See PerfmonConfig docs. Manual Timing ------------- You can also manually add "perf_timer" context objects and "add_counter_event()" and "add_instant_event()" functions to your code. All three of these should be removed before merging the PR into main. While they have almost zero overhead when perfmon is disabled, it's still better not to leave them in the code. Think of them as similar to debug prints. """ import os from napari.utils.perf._config import perf_config from napari.utils.perf._event import PerfEvent from napari.utils.perf._timers import ( add_counter_event, add_instant_event, block_timer, perf_timer, timers, ) USE_PERFMON = os.getenv("NAPARI_PERFMON", "0") != "0" __all__ = [ "perf_config", "USE_PERFMON", "add_counter_event", "add_instant_event", "block_timer", "perf_timer", "timers", "PerfEvent", ] napari-0.5.0a1/napari/utils/perf/_config.py000066400000000000000000000114651437041365600205540ustar00rootroot00000000000000"""Perf configuration flags. """ import json import os from pathlib import Path from typing import List, Optional import wrapt from napari.utils.perf._patcher import patch_callables from napari.utils.perf._timers import perf_timer from napari.utils.translations import trans PERFMON_ENV_VAR = "NAPARI_PERFMON" class PerfmonConfigError(Exception): """Error parsing or interpreting config file.""" def __init__(self, message) -> None: self.message = message def _patch_perf_timer(parent, callable: str, label: str) -> None: """Patches the callable to run it inside a perf_timer. Parameters ---------- parent The module or class that contains the callable. callable : str The name of the callable (function or method). label : str The or . we are patching. """ @wrapt.patch_function_wrapper(parent, callable) def perf_time_callable(wrapped, instance, args, kwargs): with perf_timer(f"{label}"): return wrapped(*args, **kwargs) class PerfmonConfig: """Reads the perfmon config file and sets up performance monitoring. Parameters ---------- config_path : Path Path to the perfmon configuration file (JSON format). Config File Format ------------------ { "trace_qt_events": true, "trace_file_on_start": "/Path/To/latest.json", "trace_callables": [ "my_callables_1", "my_callables_2", ], "callable_lists": { "my_callables_1": [ "module1.module2.Class1.method1", "module1.Class2.method2", "module2.module3.function1" ], "my_callables_2": [ ... ] } } """ def __init__(self, config_path: Optional[str]) -> None: # Should only patch once, but it can't be on module load, user # should patch once main() as started running during startup. self.patched = False self.config_path = config_path if config_path is None: return # Legacy mode, trace Qt events only. path = Path(config_path) with path.open() as infile: self.data = json.load(infile) def patch_callables(self): """Patch callables according to the config file. Call once at startup but after main() has started running. Do not call at module init or you will likely get circular dependencies. This function potentially imports many modules. """ if self.config_path is None: return # disabled assert self.patched is False self._patch_callables() self.patched = True def _get_callables(self, list_name: str) -> List[str]: """Get the list of callables from the config file. list_name : str The name of the list to return. """ try: return self.data["callable_lists"][list_name] except KeyError as e: raise PerfmonConfigError( trans._( "{path} has no callable list '{list_name}'", deferred=True, path=self.config_path, list_name=list_name, ) ) from e def _patch_callables(self): """Add a perf_timer to every callable. Notes ----- data["trace_callables"] should contain the names of one or more lists of callables which are defined in data["callable_lists"]. """ for list_name in self.data["trace_callables"]: callable_list = self._get_callables(list_name) patch_callables(callable_list, _patch_perf_timer) @property def trace_qt_events(self) -> bool: """Return True if we should time Qt events.""" if self.config_path is None: return True # always trace qt events in legacy mode try: return self.data["trace_qt_events"] except KeyError: return False @property def trace_file_on_start(self) -> str: """Return path of trace file to write or None.""" if self.config_path is None: return None # don't trace on start in legacy mode try: path = self.data["trace_file_on_start"] # Return None if it was empty string or false. return path if path else None except KeyError: return None def _create_perf_config(): value = os.getenv("NAPARI_PERFMON") if value is None or value == "0": return None # Totally disabled elif value == "1": return PerfmonConfig(None) # Legacy no config, Qt events only. else: return PerfmonConfig(value) # Normal parse the config file. # The global instance perf_config = _create_perf_config() napari-0.5.0a1/napari/utils/perf/_event.py000066400000000000000000000065311437041365600204260ustar00rootroot00000000000000"""PerfEvent class. """ import os import threading from collections import namedtuple from typing import Optional # The span of time that the event ocurred. Span = namedtuple("Span", "start_ns end_ns") # What process/thread produced the event. Origin = namedtuple("Origin", "process_id thread_id") class PerfEvent: """A performance related event: timer, counter, etc. Parameters ---------- name : str The name of this event like "draw". start_ns : int Start time in nanoseconds. end_ns : int End time in nanoseconds. category :str Comma separated categories such has "render,update". process_id : int The process id that produced the event. thread_id : int The thread id that produced the event. phase : str The Chrome Tracing "phase" such as "X", "I", "C". **kwargs : dict Additional keyword arguments for the "args" field of the event. Attributes ---------- name : str The name of this event like "draw". span : Span The time span when the event happened. category : str Comma separated categories such has "render,update". origin : Origin The process and thread that produced the event. args : dict Arbitrary keyword arguments for this event. phase : str The Chrome Tracing phase (event type): "X" - Complete Events "I" - Instant Events "C" - Counter Events Notes ----- The time stamps are from perf_counter_ns() and do not indicate time of day. The origin is arbitrary, but subtracting two counters results in a valid span of wall clock time. If start is the same as the end the event was instant. Google the phrase "Trace Event Format" for the full Chrome Tracing spec. """ def __init__( self, name: str, start_ns: int, end_ns: int, category: Optional[str] = None, process_id: int = None, thread_id: int = None, phase: str = "X", # "X" is a "complete event" in their spec. **kwargs: dict, ) -> None: if process_id is None: process_id = os.getpid() if thread_id is None: thread_id = threading.get_ident() self.name: str = name self.span: Span = Span(start_ns, end_ns) self.category: str = category self.origin: Origin = Origin(process_id, thread_id) self.args = kwargs self.phase: str = phase def update_end_ns(self, end_ns: int) -> None: """Update our end_ns with this new end_ns. Attributes ---------- end_ns : int The new ending time in nanoseconds. """ self.span = Span(self.span.start_ns, end_ns) @property def start_us(self): """Start time in microseconds.""" return self.span.start_ns / 1e3 @property def start_ms(self): """Start time in milliseconds.""" return self.span.start_ns / 1e6 @property def duration_ns(self): """Duration in nanoseconds.""" return self.span.end_ns - self.span.start_ns @property def duration_us(self): """Duration in microseconds.""" return self.duration_ns / 1e3 @property def duration_ms(self): """Duration in milliseconds.""" return self.duration_ns / 1e6 napari-0.5.0a1/napari/utils/perf/_patcher.py000066400000000000000000000151631437041365600207340ustar00rootroot00000000000000"""Patch callables (functions and methods). Our perfmon system using this to patch in perf_timers, but this can be used for any type of patching. See patch_callables() below as the main entrypoint. """ import types from importlib import import_module from typing import Callable, List, Set, Tuple, Union from napari.utils.translations import trans # The parent of a callable is a module or a class, class is of type "type". CallableParent = Union[types.ModuleType, type] # An example PatchFunction is: # def _patch_perf_timer(parent, callable: str, label: str) -> None PatchFunction = Callable[[CallableParent, str, str], None] class PatchError(Exception): """Failed to patch target, config file error?""" def __init__(self, message) -> None: self.message = message def _patch_attribute( module: types.ModuleType, attribute_str: str, patch_func: PatchFunction ): """Patch the module's callable pointed to by the attribute string. Parameters ---------- module : types.ModuleType The module to patch. attribute_str : str An attribute in the module like "function" or "class.method". patch_func : PatchFunction This function is called to perform the patch. """ # We expect attribute_str is or .. We could # allow nested classes and functions if we wanted to extend this some. if attribute_str.count('.') > 1: raise PatchError( trans._( "Nested attribute not found: {attribute_str}", deferred=True, attribute_str=attribute_str, ) ) if '.' in attribute_str: # Assume attribute_str is . class_str, callable_str = attribute_str.split('.') try: parent = getattr(module, class_str) except AttributeError as e: raise PatchError( trans._( "Module {module_name} has no attribute {attribute_str}", deferred=True, module_name=module.__name__, attribute_str=attribute_str, ) ) from e parent_str = class_str else: # Assume attribute_str is . class_str = None parent = module parent_str = module.__name__ callable_str = attribute_str try: getattr(parent, callable_str) except AttributeError as e: raise PatchError( trans._( "Parent {parent_str} has no attribute {callable_str}", deferred=True, parent_str=parent_str, callable_str=callable_str, ) ) from e label = ( callable_str if class_str is None else f"{class_str}.{callable_str}" ) # Patch it with the user-provided patch_func. print(f"Patcher: patching {module.__name__}.{label}") patch_func(parent, callable_str, label) def _import_module(target_str: str) -> Tuple[types.ModuleType, str]: """Import the module portion of this target string. Try importing successively longer segments of the target_str. For example: napari.components.experimental.chunk._loader.ChunkLoader.load_chunk will import: napari (success) napari.components (success) napari.components.experimental (success) napari.components.experimental.chunk (success) napari.components.experimental.chunk._loader (success) napari.components.experimental.chunk._loader.ChunkLoader (failure, not a module) The last one fails because ChunkLoader is a class not a module. Parameters ---------- target_str : str The fully qualified callable such as "module1.module2.function". Returns ------- Tuple[types.ModuleType, str] Where the module is the inner most imported module, and the string is the rest of target_str that was not modules. """ parts = target_str.split('.') module = None # Inner-most module imported so far. # Progressively try to import longer and longer segments of the path. for i in range(1, len(target_str)): module_path = '.'.join(parts[:i]) try: module = import_module(module_path) except ModuleNotFoundError as e: if module is None: # The very first top-level module import failed! raise PatchError( trans._( "Module not found: {module_path}", deferred=True, module_path=module_path, ) ) from e # We successfully imported part of the target_str but then # we got a failure. Usually this is because we tried # importing a class or function. Return the inner-most # module we did successfuly import. And return the rest of # the module_path we didn't use. attribute_str = '.'.join(parts[i - 1 :]) return module, attribute_str def patch_callables(callables: List[str], patch_func: PatchFunction) -> None: """Patch the given list of callables. Parameters ---------- callables : List[str] Patch all of these callables (functions or methods). patch_func : PatchFunction Called on every callable to patch it. Notes ----- The callables list should look like: [ "module.module.ClassName.method_name", "module.function_name" ... ] Nested classes and methods not allowed, but support could be added. An example patch_func is:: import wrapt def _my_patcher(parent: CallableParent, callable: str, label: str): @wrapt.patch_function_wrapper(parent, callable) def my_announcer(wrapped, instance, args, kwargs): print(f"Announce {label}") return wrapped(*args, **kwargs) """ patched: Set[str] = set() for target_str in callables: if target_str in patched: # Ignore duplicated targets in the config file. print(f"Patcher: [WARN] skipping duplicate {target_str}") continue # Patch the target and note that we did. try: module, attribute_str = _import_module(target_str) _patch_attribute(module, attribute_str, patch_func) except PatchError as exc: # We don't stop on error because if you switch around branches # but keep the same config file, it's easy for your config # file to contain targets that aren't in the code. print(f"Patcher: [ERROR] {exc}") patched.add(target_str) napari-0.5.0a1/napari/utils/perf/_stat.py000066400000000000000000000023501437041365600202530ustar00rootroot00000000000000"""Stat class. """ class Stat: """Keep min/max/average on an integer value. Parameters ---------- value : int The first value to keep statistics on. Attributes ---------- min : int Minimum value so far. max : int Maximum value so far. sum : int Sum of all values seen. count : int How many values we've seen. """ def __init__(self, value: int) -> None: """Create Stat with an initial value. Parameters ---------- value : int Initial value. """ self.min = value self.max = value self.sum = value self.count = 1 def add(self, value: int) -> None: """Add a new value. Parameters ---------- value : int The new value. """ self.sum += value self.count += 1 self.max = max(self.max, value) self.min = min(self.min, value) @property def average(self) -> int: """Average value. Returns ------- average value : int. """ if self.count > 0: return self.sum / self.count raise ValueError("no values") # impossible for us napari-0.5.0a1/napari/utils/perf/_timers.py000066400000000000000000000156541437041365600206160ustar00rootroot00000000000000"""PerfTimers class and global instance. """ import contextlib import os from time import perf_counter_ns from typing import Dict, Optional from napari.utils.perf._event import PerfEvent from napari.utils.perf._stat import Stat from napari.utils.perf._trace_file import PerfTraceFile USE_PERFMON = os.getenv("NAPARI_PERFMON", "0") != "0" class PerfTimers: """Timers for performance monitoring. Timers are best added using the perfmon config file, which will monkey-patch the timers into the code at startup. See napari.utils.perf._config for details. The collecting timing information can be used in two ways: 1) Writing a JSON trace file in Chrome's Tracing format. 2) Napari's real-time QtPerformance widget. Attributes ---------- timers : Dict[str, Stat] Statistics are kept on each timer. trace_file : Optional[PerfTraceFile] The tracing file we are writing to if any. Notes ----- Chrome deduces nesting based on the start and end times of each timer. The chrome://tracing GUI shows the nesting as stacks of colored rectangles. However our self.timers dictionary and thus our QtPerformance widget do not currently understand nesting. So if they say two timers each took 1ms, you can't tell if one called the other or not. Despite this limitation when the QtPerformance widget reports slow timers it still gives you a good idea what was slow. And then you can use the chrome://tracing GUI to see the full story. """ def __init__(self) -> None: """Create PerfTimers.""" # Maps a timer name to one Stat object. self.timers: Dict[str, Stat] = {} # Menu item "Debug -> Record Trace File..." starts a trace. self.trace_file: Optional[PerfTraceFile] = None def add_event(self, event: PerfEvent) -> None: """Add one performance event. Parameters ---------- event : PerfEvent Add this event. """ # Add event if tracing. if self.trace_file is not None: self.trace_file.add_event(event) if event.phase == "X": # Complete Event # Update our self.timers (in milliseconds). name = event.name duration_ms = event.duration_ms if name in self.timers: self.timers[name].add(duration_ms) else: self.timers[name] = Stat(duration_ms) def add_instant_event(self, name: str, **kwargs) -> None: """Add one instant event. Parameters ---------- name : PerfEvent Add this event. **kwargs Arguments to display in the Args section of the Tracing GUI. """ now = perf_counter_ns() self.add_event(PerfEvent(name, now, now, phase="I", **kwargs)) def add_counter_event(self, name: str, **kwargs: Dict[str, float]) -> None: """Add one counter event. Parameters ---------- name : str The name of this event like "draw". **kwargs : Dict[str, float] The individual counters for this event. Notes ----- For example add_counter_event("draw", triangles=5, squares=10). """ now = perf_counter_ns() self.add_event(PerfEvent(name, now, now, phase="C", **kwargs)) def clear(self): """Clear all timers.""" # After the GUI displays timing information it clears the timers # so that we start accumulating fresh information. self.timers.clear() def start_trace_file(self, path: str) -> None: """Start recording a trace file to disk. Parameters ---------- path : str Write the trace to this path. """ self.trace_file = PerfTraceFile(path) def stop_trace_file(self) -> None: """Stop recording a trace file.""" if self.trace_file is not None: self.trace_file.close() self.trace_file = None @contextlib.contextmanager def block_timer( name: str, category: Optional[str] = None, print_time: bool = False, **kwargs, ): """Time a block of code. block_timer can be used when perfmon is disabled. Use perf_timer instead if you want your timer to do nothing when perfmon is disabled. Notes ----- Most of the time you should use the perfmon config file to monkey-patch perf_timer's into methods and functions. Then you do not need to use block_timer or perf_timer context objects explicitly at all. Parameters ---------- name : str The name of this timer. category : str Comma separated categories such has "render,update". print_time : bool Print the duration of the timer when it finishes. **kwargs : dict Additional keyword arguments for the "args" field of the event. Examples -------- .. code-block:: python with block_timer("draw") as event: draw_stuff() print(f"The timer took {event.duration_ms} milliseconds.") """ start_ns = perf_counter_ns() # Pass in start_ns for start and end, we call update_end_ns # once the block as finished. event = PerfEvent(name, start_ns, start_ns, category, **kwargs) yield event # Update with the real end time. event.update_end_ns(perf_counter_ns()) if timers: timers.add_event(event) if print_time: print(f"{name} {event.duration_ms:.3f}ms") def _create_timer(): # The one global instance timers = PerfTimers() # perf_timer is enabled perf_timer = block_timer def add_instant_event(name: str, **kwargs): """Add one instant event. Parameters ---------- name : PerfEvent Add this event. **kwargs Arguments to display in the Args section of the Chrome Tracing GUI. """ timers.add_instant_event(name, **kwargs) def add_counter_event(name: str, **kwargs: Dict[str, float]): """Add one counter event. Parameters ---------- name : str The name of this event like "draw". **kwargs : Dict[str, float] The individual counters for this event. Notes ----- For example add_counter_event("draw", triangles=5, squares=10). """ timers.add_counter_event(name, **kwargs) return timers, perf_timer, add_instant_event, add_counter_event if USE_PERFMON: timers, perf_timer, add_instant_event, add_counter_event = _create_timer() else: # Make sure no one accesses the timers when they are disabled. timers = None def add_instant_event(name: str, **kwargs) -> None: pass def add_counter_event(name: str, **kwargs: Dict[str, float]) -> None: pass # perf_timer is disabled. Using contextlib.nullcontext did not work. @contextlib.contextmanager def perf_timer(name: str, category: Optional[str] = None, **kwargs): yield napari-0.5.0a1/napari/utils/perf/_trace_file.py000066400000000000000000000057241437041365600214050ustar00rootroot00000000000000"""PerfTraceFile class to write the chrome://tracing file format (JSON) """ import json from time import perf_counter_ns from typing import TYPE_CHECKING, List if TYPE_CHECKING: from napari.utils.perf._event import PerfEvent class PerfTraceFile: """Writes a chrome://tracing formatted JSON file. Stores PerfEvents in memory, writes the JSON file in PerfTraceFile.close(). Parameters ---------- output_path : str Write the trace file to this path. Attributes ---------- output_path : str Write the trace file to this path. zero_ns : int perf_counter_ns() time when we started the trace. events : List[PerfEvent] Process ID. outf : file handle JSON file we are writing to. Notes ----- See the "trace_event format" Google Doc for details: https://chromium.googlesource.com/catapult/+/HEAD/tracing/README.md """ def __init__(self, output_path: str) -> None: """Store events in memory and write to the file when done.""" self.output_path = output_path # So the events we write start at t=0. self.zero_ns = perf_counter_ns() # Accumulate events in a list and only write at the end so the cost # of writing to a file does not bloat our timings. self.events: List["PerfEvent"] = [] def add_event(self, event: "PerfEvent") -> None: """Add one perf event to our in-memory list. Parameters ---------- event : PerfEvent Event to add """ self.events.append(event) def close(self): """Close the trace file, write all events to disk.""" event_data = [self._get_event_data(x) for x in self.events] with open(self.output_path, "w") as outf: json.dump(event_data, outf) def _get_event_data(self, event: "PerfEvent") -> dict: """Return the data for one perf event. Parameters ---------- event : PerfEvent Event to write. Returns ------- dict The data to be written to JSON. """ category = "none" if event.category is None else event.category data = { "pid": event.origin.process_id, "tid": event.origin.thread_id, "name": event.name, "cat": category, "ph": event.phase, "ts": event.start_us, "args": event.args, } # The three phase types we support. assert event.phase in ["X", "I", "C"] if event.phase == "X": # "X" is a Complete Event, it has a duration. data["dur"] = event.duration_us elif event.phase == "I": # "I is an Instant Event, it has a "scope" one of: # "g" - global # "p" - process # "t" - thread # We hard code "process" right now because that's all we've needed. data["s"] = "p" return data napari-0.5.0a1/napari/utils/progress.py000066400000000000000000000110741437041365600200540ustar00rootroot00000000000000from typing import Iterable, Optional from tqdm import tqdm from napari.utils.events.containers import EventedSet from napari.utils.events.event import EmitterGroup, Event from napari.utils.translations import trans class progress(tqdm): """This class inherits from tqdm and provides an interface for progress bars in the napari viewer. Progress bars can be created directly by wrapping an iterable or by providing a total number of expected updates. While this interface is primarily designed to be displayed in the viewer, it can also be used without a viewer open, in which case it behaves identically to tqdm and produces the progress bar in the terminal. See tqdm.tqdm API for valid args and kwargs: https://tqdm.github.io/docs/tqdm/ Examples -------- >>> def long_running(steps=10, delay=0.1): ... for i in progress(range(steps)): ... sleep(delay) it can also be used as a context manager: >>> def long_running(steps=10, repeats=4, delay=0.1): ... with progress(range(steps)) as pbr: ... for i in pbr: ... sleep(delay) or equivalently, using the `progrange` shorthand .. code-block:: python with progrange(steps) as pbr: for i in pbr: sleep(delay) For manual updates: >>> def manual_updates(total): ... pbr = progress(total=total) ... sleep(10) ... pbr.set_description("Step 1 Complete") ... pbr.update(1) ... # must call pbr.close() when using outside for loop ... # or context manager ... pbr.close() """ monitor_interval = 0 # set to 0 to disable the thread # to give us a way to hook into the creation and update of progress objects # without progress knowing anything about a Viewer, we track all instances in # this evented *class* attribute, accessed through `progress._all_instances` # this allows the ActivityDialog to find out about new progress objects and # hook up GUI progress bars to its update events _all_instances: EventedSet['progress'] = EventedSet() def __init__( self, iterable: Optional[Iterable] = None, desc: Optional[str] = None, total: Optional[int] = None, nest_under: Optional['progress'] = None, *args, **kwargs, ) -> None: self.events = EmitterGroup( value=Event, description=Event, overflow=Event, eta=Event, total=Event, ) self.nest_under = nest_under self.is_init = True super().__init__(iterable, desc, total, *args, **kwargs) if not self.desc: self.set_description(trans._("progress")) progress._all_instances.add(self) self.is_init = False def __repr__(self) -> str: return self.desc @property def total(self): return self._total @total.setter def total(self, total): self._total = total self.events.total(value=self.total) def display(self, msg: str = None, pos: int = None) -> None: """Update the display and emit eta event.""" # just plain tqdm if we don't have gui if not self.gui and not self.is_init: super().display(msg, pos) return # TODO: This could break if user is formatting their own terminal tqdm etas = str(self).split('|')[-1] if self.total != 0 else "" self.events.eta(value=etas) def update(self, n=1): """Update progress value by n and emit value event""" super().update(n) self.events.value(value=self.n) def increment_with_overflow(self): """Update if not exceeding total, else set indeterminate range.""" if self.n == self.total: self.total = 0 self.events.overflow() else: self.update(1) def set_description(self, desc): """Update progress description and emit description event.""" super().set_description(desc, refresh=True) self.events.description(value=desc) def close(self): """Close progress object and emit event.""" if self.disable: return progress._all_instances.remove(self) super().close() def progrange(*args, **kwargs): """Shorthand for ``progress(range(*args), **kwargs)``. Adds tqdm based progress bar to napari viewer, if it exists, and returns the wrapped range object. Returns ------- progress wrapped range object """ return progress(range(*args), **kwargs) napari-0.5.0a1/napari/utils/settings/000077500000000000000000000000001437041365600174735ustar00rootroot00000000000000napari-0.5.0a1/napari/utils/settings/__init__.py000066400000000000000000000005131437041365600216030ustar00rootroot00000000000000import warnings from napari.settings import * # noqa: F403 from napari.utils.translations import trans warnings.warn( trans._( "'napari.utils.settings' has moved to 'napari.settings' in 0.4.11. This will raise an ImportError in a future version", deferred=True, ), FutureWarning, stacklevel=2, ) napari-0.5.0a1/napari/utils/shortcuts.py000066400000000000000000000076431437041365600202550ustar00rootroot00000000000000from app_model.types import KeyBinding, KeyCode, KeyMod default_shortcuts = { # viewer 'napari:toggle_console_visibility': [ KeyMod.CtrlCmd | KeyMod.Shift | KeyCode.KeyC ], 'napari:reset_scroll_progress': [KeyCode.Ctrl], 'napari:toggle_ndisplay': [KeyMod.CtrlCmd | KeyCode.KeyY], 'napari:toggle_theme': [KeyMod.CtrlCmd | KeyMod.Shift | KeyCode.KeyT], 'napari:reset_view': [KeyMod.CtrlCmd | KeyCode.KeyR], 'napari:show_shortcuts': [KeyMod.CtrlCmd | KeyMod.Alt | KeyCode.Slash], 'napari:increment_dims_left': [KeyCode.LeftArrow], 'napari:increment_dims_right': [KeyCode.RightArrow], 'napari:focus_axes_up': [KeyMod.Alt | KeyCode.UpArrow], 'napari:focus_axes_down': [KeyMod.Alt | KeyCode.DownArrow], 'napari:roll_axes': [KeyMod.CtrlCmd | KeyCode.KeyE], 'napari:transpose_axes': [KeyMod.CtrlCmd | KeyCode.KeyT], 'napari:toggle_grid': [KeyMod.CtrlCmd | KeyCode.KeyG], 'napari:toggle_selected_visibility': [KeyCode.KeyG], # labels 'napari:activate_labels_erase_mode': [KeyCode.Digit1], 'napari:activate_labels_paint_mode': [KeyCode.Digit2], 'napari:activate_labels_fill_mode': [KeyCode.Digit3], 'napari:activate_labels_picker_mode': [KeyCode.Digit4], 'napari:activate_labels_pan_zoom_mode': [KeyCode.Digit5], 'napari:activate_labels_transform_mode': [KeyCode.Digit6], 'napari:new_label': [KeyCode.KeyM], 'napari:decrease_label_id': [KeyCode.Minus], 'napari:increase_label_id': [KeyCode.Equal], 'napari:decrease_brush_size': [KeyCode.BracketLeft], 'napari:increase_brush_size': [KeyCode.BracketRight], 'napari:toggle_preserve_labels': [KeyCode.KeyP], # points 'napari:activate_points_add_mode': [KeyCode.Digit2], 'napari:activate_points_select_mode': [KeyCode.Digit3], 'napari:activate_points_pan_zoom_mode': [KeyCode.Digit4], 'napari:activate_points_transform_mode': [KeyCode.Digit5], 'napari:select_all_in_slice': [ KeyCode.KeyA, KeyMod.CtrlCmd | KeyCode.KeyA, ], 'napari:select_all_data': [KeyMod.Shift | KeyCode.KeyA], 'napari:delete_selected_points': [ KeyCode.Backspace, KeyCode.Delete, KeyCode.Digit1, ], # shapes 'napari:activate_add_rectangle_mode': [KeyCode.KeyR], 'napari:activate_add_ellipse_mode': [KeyCode.KeyE], 'napari:activate_add_line_mode': [KeyCode.KeyL], 'napari:activate_add_path_mode': [KeyCode.KeyT], 'napari:activate_add_polygon_mode': [KeyCode.KeyP], 'napari:activate_direct_mode': [KeyCode.Digit4], 'napari:activate_select_mode': [KeyCode.Digit5], 'napari:activate_shapes_pan_zoom_mode': [KeyCode.Digit6], 'napari:activate_shapes_transform_mode': [KeyCode.Digit2], 'napari:activate_vertex_insert_mode': [KeyCode.Digit2], 'napari:activate_vertex_remove_mode': [KeyCode.Digit1], 'napari:copy_selected_shapes': [KeyMod.CtrlCmd | KeyCode.KeyC], 'napari:paste_shape': [KeyMod.CtrlCmd | KeyCode.KeyV], 'napari:move_shapes_selection_to_front': [KeyCode.KeyF], 'napari:move_shapes_selection_to_back': [KeyCode.KeyB], 'napari:select_all_shapes': [KeyCode.KeyA], 'napari:delete_selected_shapes': [ KeyCode.Backspace, KeyCode.Delete, KeyCode.Digit3, ], 'napari:finish_drawing_shape': [KeyCode.Escape], # image 'napari:activate_image_pan_zoom_mode': [KeyCode.Digit1], 'napari:activate_image_transform_mode': [KeyCode.Digit2], # vectors 'napari:activate_vectors_pan_zoom_mode': [KeyCode.Digit1], 'napari:activate_vectors_transform_mode': [KeyCode.Digit2], # tracks 'napari:activate_tracks_pan_zoom_mode': [KeyCode.Digit1], 'napari:activate_tracks_transform_mode': [KeyCode.Digit2], # surface 'napari:activate_surface_pan_zoom_mode': [KeyCode.Digit1], 'napari:activate_surface_transform_mode': [KeyCode.Digit2], } default_shortcuts = { name: [KeyBinding.from_int(kb) for kb in value] for name, value in default_shortcuts.items() } napari-0.5.0a1/napari/utils/status_messages.py000066400000000000000000000054661437041365600214320ustar00rootroot00000000000000from collections.abc import Iterable import numpy as np def format_float(value): """Nice float formatting into strings.""" return f'{value:0.3g}' def status_format(value): """Return a "nice" string representation of a value. Parameters ---------- value : Any The value to be printed. Returns ------- formatted : str The string resulting from formatting. Examples -------- >>> values = np.array([1, 10, 100, 1000, 1e6, 6.283, 123.932021, ... 1123.9392001, 2 * np.pi, np.exp(1)]) >>> status_format(values) '[1, 10, 100, 1e+03, 1e+06, 6.28, 124, 1.12e+03, 6.28, 2.72]' """ if isinstance(value, str): return value if isinstance(value, Iterable): # FIMXE: use an f-string? return '[' + str.join(', ', [status_format(v) for v in value]) + ']' if value is None: return '' if isinstance(value, float) or np.issubdtype(type(value), np.floating): return format_float(value) elif isinstance(value, int) or np.issubdtype(type(value), np.integer): return str(value) else: return str(value) def generate_layer_coords_status(position, value): if position is not None: full_coord = map(str, np.round(position).astype(int)) msg = f" [{' '.join(full_coord)}]" else: msg = "" if value is not None: if isinstance(value, tuple) and value != (None, None): # it's a multiscale -> value = (data_level, value) msg += f': {status_format(value[0])}' if value[1] is not None: msg += f', {status_format(value[1])}' else: # it's either a grayscale or rgb image (scalar or list) msg += f': {status_format(value)}' return msg def generate_layer_status(name, position, value): """Generate a status message based on the coordinates and value Parameters ---------- name : str Name of the layer. position : tuple or list List of coordinates, say of the cursor. value : Any The value to be printed. Returns ------- msg : string String containing a message that can be used as a status update. """ if position is not None: full_coord = map(str, np.round(position).astype(int)) msg = f"{name} [{' '.join(full_coord)}]" else: msg = f"{name}" if value is not None: if isinstance(value, tuple) and value != (None, None): # it's a multiscale -> value = (data_level, value) msg += f': {status_format(value[0])}' if value[1] is not None: msg += f', {status_format(value[1])}' else: # it's either a grayscale or rgb image (scalar or list) msg += f': {status_format(value)}' return msg napari-0.5.0a1/napari/utils/stubgen.py000066400000000000000000000150521437041365600176570ustar00rootroot00000000000000"""This module provides helper functions for autogenerating type stubs. It is intentended to be run as a script or module as follows: python -m napari.utils.stubgen module.a module.b ... where `module.a` and `module.b` are the module names for which you'd like to generate type stubs. Stubs will be written to a `.pyi` with the same name and directory as the input module(s). Example ------- python -m napari.utils.stubgen napari.view_layers # outputs a file to: `napari/view_layers.pyi` Note ---- If you want to limit the objects in the module for which stubs are created, define an __all__ = [...] attribute in the module. Otherwise, all non-private callable methods will be stubbed. """ import importlib import inspect import subprocess import textwrap import typing import warnings from types import ModuleType from typing import Any, Iterator, List, Set, Tuple, Type, Union, get_type_hints from typing_extensions import get_args, get_origin PYI_TEMPLATE = """ # THIS FILE IS AUTOGENERATED BY napari.utils.stubgen # DO NOT EDIT from typing import List, Union, Mapping, Sequence, Tuple, Dict, Set, Any {imports} {body} """ def _format_module_str(text: str, is_pyi=False) -> str: """Apply black and isort formatting to text.""" from black import FileMode, format_str from isort.api import sort_code_string text = sort_code_string(text, profile="black", float_to_top=True) text = format_str(text, mode=FileMode(line_length=79, is_pyi=is_pyi)) return text.replace("NoneType", "None") def _guess_exports(module, exclude=()) -> List[str]: """If __all__ wasn't provided, this function guesses what to stub.""" return [ k for k, v in vars(module).items() if callable(v) and not k.startswith("_") and k not in exclude ] def _iter_imports(hint) -> Iterator[str]: """Get all imports necessary for `hint`""" # inspect.formatannotation strips "typing." from type annotations # so our signatures won't have it in there if not repr(hint).startswith("typing."): if orig := get_origin(hint): yield orig.__module__ for arg in get_args(hint): yield from _iter_imports(arg) if isinstance(hint, list): for i in hint: yield from _iter_imports(i) elif getattr(hint, '__module__', None) != 'builtins': yield hint.__module__ def generate_function_stub(func) -> Tuple[Set[str], str]: """Generate a stub and imports for a function.""" sig = inspect.signature(func) if hasattr(func, "__wrapped__"): # unwrap @wraps decorated functions func = func.__wrapped__ globalns = {**getattr(func, '__globals__', {})} globalns.update(vars(typing)) globalns.update(getattr(func, '_forwardrefns_', {})) hints = get_type_hints(func, globalns) sig = sig.replace( parameters=[ p.replace(annotation=hints.get(p.name, p.empty)) for p in sig.parameters.values() ], return_annotation=hints.get('return', inspect.Parameter.empty), ) imports = set() for hint in hints.values(): imports.update(set(_iter_imports(hint))) imports -= {'typing'} doc = f'"""{func.__doc__}"""' if func.__doc__ else '...' return imports, f'def {func.__name__}{sig}:\n {doc}\n' def _get_subclass_methods(cls: Type[Any]) -> Set[str]: """Return the set of method names defined (only) on a subclass.""" all_methods = set(dir(cls)) base_methods = (dir(base()) for base in cls.__bases__) return all_methods.difference(*base_methods) def generate_class_stubs(cls: Type) -> Tuple[Set[str], str]: """Generate a stub and imports for a class.""" bases = ", ".join(f'{b.__module__}.{b.__name__}' for b in cls.__bases__) methods = [] attrs = [] imports = set() local_names = set(cls.__dict__).union(set(cls.__annotations__)) for sup in cls.mro()[1:]: local_names.difference_update(set(sup.__dict__)) for methname in sorted(_get_subclass_methods(cls)): method = getattr(cls, methname) if not callable(method): continue _imports, stub = generate_function_stub(method) imports.update(_imports) methods.append(stub) hints = get_type_hints(cls) for name, type_ in hints.items(): if name not in local_names: continue if hasattr(type_, '__name__'): hint = f'{type_.__module__}.{type_.__name__}' else: hint = repr(type_).replace('typing.', '') attrs.append(f'{name}: {hint.replace("builtins.", "")}') imports.update(set(_iter_imports(type_))) doc = f'"""{cls.__doc__.lstrip()}"""' if cls.__doc__ else '...' stub = f'class {cls.__name__}({bases}):\n {doc}\n' stub += textwrap.indent("\n".join(attrs), ' ') stub += "\n" + textwrap.indent("\n".join(methods), ' ') return imports, stub def generate_module_stub(module: Union[str, ModuleType], save=True) -> str: """Generate a pyi stub for a module. By default saves to .pyi file with the same name as the module. """ if isinstance(module, str): module = importlib.import_module(module) # try to use __all__, or guess exports names = getattr(module, '__all__', None) if not names: names = _guess_exports(module) warnings.warn( f'Module {module.__name__} does not provide `__all__`. ' 'Guessing exports.' ) # For each object, create a stub and gather imports for the top of the file imports = set() stubs = [] for name in names: obj = getattr(module, name) if isinstance(obj, type): _imports, stub = generate_class_stubs(obj) else: _imports, stub = generate_function_stub(obj) imports.update(_imports) stubs.append(stub) # build the final file string importstr = "\n".join(f'import {n}' for n in imports) body = '\n'.join(stubs) pyi = PYI_TEMPLATE.format(imports=importstr, body=body) # format with black and isort # pyi = _format_module_str(pyi) pyi = pyi.replace("NoneType", "None") if save: print("Writing stub:", module.__file__.replace(".py", ".pyi")) file_path = module.__file__.replace(".py", ".pyi") with open(file_path, 'w') as f: f.write(pyi) subprocess.run(["ruff", file_path]) subprocess.run(["black", file_path]) return pyi if __name__ == '__main__': import sys default_modules = ['napari.view_layers', 'napari.components.viewer_model'] for mod in sys.argv[1:] or default_modules: generate_module_stub(mod) napari-0.5.0a1/napari/utils/theme.py000066400000000000000000000257031437041365600173160ustar00rootroot00000000000000# syntax_style for the console must be one of the supported styles from # pygments - see here for examples https://help.farbox.com/pygments.html import re import warnings from ast import literal_eval from contextlib import suppress from typing import Union import npe2 from pydantic import validator from pydantic.color import Color from napari._vendor import darkdetect from napari.resources._icons import ( PLUGIN_FILE_NAME, _theme_path, build_theme_svgs, ) from napari.utils.events import EventedModel from napari.utils.events.containers._evented_dict import EventedDict from napari.utils.translations import trans try: from qtpy import QT_VERSION major, minor, *_ = QT_VERSION.split('.') use_gradients = (int(major) >= 5) and (int(minor) >= 12) del major, minor, QT_VERSION except ImportError: use_gradients = False class Theme(EventedModel): """Theme model. Attributes ---------- id : str id of the theme and name of the virtual folder where icons will be saved to. label : str Name of the theme as it should be shown in the ui. syntax_style : str Name of the console style. See for more details: https://pygments.org/docs/styles/ canvas : Color Background color of the canvas. background : Color Color of the application background. foreground : Color Color to contrast with the background. primary : Color Color used to make part of a widget more visible. secondary : Color Alternative color used to make part of a widget more visible. highlight : Color Color used to highlight visual element. text : Color Color used to display text. warning : Color Color used to indicate something needs attention. error : Color Color used to indicate something is wrong or could stop functionality. current : Color Color used to highlight Qt widget. """ id: str label: str syntax_style: str canvas: Color console: Color background: Color foreground: Color primary: Color secondary: Color highlight: Color text: Color icon: Color warning: Color error: Color current: Color @validator("syntax_style", pre=True) def _ensure_syntax_style(value: str) -> str: from pygments.styles import STYLE_MAP assert value in STYLE_MAP, trans._( "Incorrect `syntax_style` value provided. Please use one of the following: {syntax_style}", deferred=True, syntax_style=f" {', '.join(STYLE_MAP)}", ) return value gradient_pattern = re.compile(r'([vh])gradient\((.+)\)') darken_pattern = re.compile(r'{{\s?darken\((\w+),?\s?([-\d]+)?\)\s?}}') lighten_pattern = re.compile(r'{{\s?lighten\((\w+),?\s?([-\d]+)?\)\s?}}') opacity_pattern = re.compile(r'{{\s?opacity\((\w+),?\s?([-\d]+)?\)\s?}}') def darken(color: Union[str, Color], percentage=10): if isinstance(color, str) and color.startswith('rgb('): color = literal_eval(color.lstrip('rgb(').rstrip(')')) else: color = color.as_rgb_tuple() ratio = 1 - float(percentage) / 100 red, green, blue = color red = min(max(int(red * ratio), 0), 255) green = min(max(int(green * ratio), 0), 255) blue = min(max(int(blue * ratio), 0), 255) return f'rgb({red}, {green}, {blue})' def lighten(color: Union[str, Color], percentage=10): if isinstance(color, str) and color.startswith('rgb('): color = literal_eval(color.lstrip('rgb(').rstrip(')')) else: color = color.as_rgb_tuple() ratio = float(percentage) / 100 red, green, blue = color red = min(max(int(red + (255 - red) * ratio), 0), 255) green = min(max(int(green + (255 - green) * ratio), 0), 255) blue = min(max(int(blue + (255 - blue) * ratio), 0), 255) return f'rgb({red}, {green}, {blue})' def opacity(color: Union[str, Color], value=255): if isinstance(color, str) and color.startswith('rgb('): color = literal_eval(color.lstrip('rgb(').rstrip(')')) else: color = color.as_rgb_tuple() red, green, blue = color return f'rgba({red}, {green}, {blue}, {max(min(int(value), 255), 0)})' def gradient(stops, horizontal=True): if not use_gradients: return stops[-1] if horizontal: grad = 'qlineargradient(x1: 0, y1: 0, x2: 1, y2: 0, ' else: grad = 'qlineargradient(x1: 0, y1: 0, x2: 0, y2: 1, ' _stops = [f'stop: {n} {stop}' for n, stop in enumerate(stops)] grad += ", ".join(_stops) + ")" return grad def template(css: str, **theme): def darken_match(matchobj): color, percentage = matchobj.groups() return darken(theme[color], percentage) def lighten_match(matchobj): color, percentage = matchobj.groups() return lighten(theme[color], percentage) def opacity_match(matchobj): color, percentage = matchobj.groups() return opacity(theme[color], percentage) def gradient_match(matchobj): horizontal = matchobj.groups()[1] == 'h' stops = [i.strip() for i in matchobj.groups()[1].split('-')] return gradient(stops, horizontal) for k, v in theme.items(): css = gradient_pattern.sub(gradient_match, css) css = darken_pattern.sub(darken_match, css) css = lighten_pattern.sub(lighten_match, css) css = opacity_pattern.sub(opacity_match, css) if isinstance(v, Color): v = v.as_rgb() css = css.replace('{{ %s }}' % k, v) return css def get_system_theme() -> str: """Return the system default theme, either 'dark', or 'light'.""" try: id_ = darkdetect.theme().lower() except AttributeError: id_ = "dark" return id_ def get_theme(id, as_dict=None): """Get a copy of theme based on it's id. If you get a copy of the theme, changes to the theme model will not be reflected in the UI unless you replace or add the modified theme to the `_themes` container. Parameters ---------- id : str ID of requested theme. as_dict : bool Flag to indicate that the old-style dictionary should be returned. This will emit deprecation warning. Returns ------- theme: dict of str: str Theme mapping elements to colors. A copy is created so that manipulating this theme can be done without side effects. """ if id == "system": id = get_system_theme() if id not in _themes: raise ValueError( trans._( "Unrecognized theme {id}. Available themes are {themes}", deferred=True, id=id, themes=available_themes(), ) ) theme = _themes[id] _theme = theme.copy() if as_dict is None: warnings.warn( trans._( "The `as_dict` kwarg default to False` since Napari 0.4.17, " "and will become a mandatory parameter in the future.", deferred=True, ), category=FutureWarning, stacklevel=2, ) as_dict = False if as_dict: _theme = _theme.dict() _theme = { k: v if not isinstance(v, Color) else v.as_rgb() for (k, v) in _theme.items() } return _theme return _theme _themes: EventedDict[str, Theme] = EventedDict(basetype=Theme) def register_theme(id, theme, source): """Register a new or updated theme. Parameters ---------- id : str id of requested theme. theme : dict of str: str, Theme Theme mapping elements to colors. source : str Source plugin of theme """ if isinstance(theme, dict): theme = Theme(**theme) assert isinstance(theme, Theme) _themes[id] = theme build_theme_svgs(id, source) def unregister_theme(id): """Remove existing theme. Parameters ---------- id : str id of the theme to be removed. """ _themes.pop(id, None) def available_themes(): """List available themes. Returns ------- list of str ids of available themes. """ return tuple(_themes) + ("system",) def is_theme_available(id): """Check if a theme is available. Parameters ---------- id : str id of requested theme. Returns ------- bool True if the theme is available, False otherwise. """ if id == "system": return True if id not in _themes and _theme_path(id).exists(): plugin_name_file = _theme_path(id) / PLUGIN_FILE_NAME if not plugin_name_file.exists(): return False plugin_name = plugin_name_file.read_text() with suppress(ModuleNotFoundError): npe2.PluginManager.instance().register(plugin_name) _install_npe2_themes(_themes) return id in _themes def rebuild_theme_settings(): """update theme information in settings. here we simply update the settings to reflect current list of available themes. """ from napari.settings import get_settings settings = get_settings() settings.appearance.refresh_themes() DARK = Theme( id='dark', label='Default Dark', background='rgb(38, 41, 48)', foreground='rgb(65, 72, 81)', primary='rgb(90, 98, 108)', secondary='rgb(134, 142, 147)', highlight='rgb(106, 115, 128)', text='rgb(240, 241, 242)', icon='rgb(209, 210, 212)', warning='rgb(227, 182, 23)', error='rgb(153, 18, 31)', current='rgb(0, 122, 204)', syntax_style='native', console='rgb(18, 18, 18)', canvas='black', ) LIGHT = Theme( id='light', label='Default Light', background='rgb(239, 235, 233)', foreground='rgb(214, 208, 206)', primary='rgb(188, 184, 181)', secondary='rgb(150, 146, 144)', highlight='rgb(163, 158, 156)', text='rgb(59, 58, 57)', icon='rgb(107, 105, 103)', warning='rgb(227, 182, 23)', error='rgb(255, 18, 31)', current='rgb(253, 240, 148)', syntax_style='default', console='rgb(255, 255, 255)', canvas='white', ) register_theme('dark', DARK, "builtin") register_theme('light', LIGHT, "builtin") # this function here instead of plugins._npe2 to avoid circular import def _install_npe2_themes(themes=None): if themes is None: themes = _themes import npe2 for manifest in npe2.PluginManager.instance().iter_manifests( disabled=False ): for theme in manifest.contributions.themes or (): # get fallback values theme_dict = themes[theme.type].dict() # update available values theme_info = theme.dict(exclude={'colors'}, exclude_unset=True) theme_colors = theme.colors.dict(exclude_unset=True) theme_dict.update(theme_info) theme_dict.update(theme_colors) register_theme(theme.id, theme_dict, manifest.name) _install_npe2_themes(_themes) _themes.events.added.connect(rebuild_theme_settings) _themes.events.removed.connect(rebuild_theme_settings) napari-0.5.0a1/napari/utils/transforms/000077500000000000000000000000001437041365600200315ustar00rootroot00000000000000napari-0.5.0a1/napari/utils/transforms/__init__.py000066400000000000000000000005471437041365600221500ustar00rootroot00000000000000from napari.utils.transforms.transform_utils import shear_matrix_from_angle from napari.utils.transforms.transforms import ( Affine, CompositeAffine, ScaleTranslate, Transform, TransformChain, ) __all__ = [ "shear_matrix_from_angle", "Affine", "CompositeAffine", "ScaleTranslate", "Transform", "TransformChain", ] napari-0.5.0a1/napari/utils/transforms/_tests/000077500000000000000000000000001437041365600213325ustar00rootroot00000000000000napari-0.5.0a1/napari/utils/transforms/_tests/test_transform_chain.py000066400000000000000000000066031437041365600261250ustar00rootroot00000000000000import numpy.testing as npt import pytest from napari.utils.transforms import ( Affine, CompositeAffine, ScaleTranslate, TransformChain, ) transform_types = [Affine, CompositeAffine, ScaleTranslate] @pytest.mark.parametrize('Transform', transform_types) def test_transform_chain(Transform): coord = [10, 13] transform_a = Transform(scale=[2, 3], translate=[8, -5]) transform_b = Transform(scale=[0.3, 1.4], translate=[-2.2, 3]) transform_c = transform_b.compose(transform_a) transform_chain = TransformChain([transform_a, transform_b]) new_coord_1 = transform_c(coord) new_coord_2 = transform_chain(coord) npt.assert_allclose(new_coord_1, new_coord_2) @pytest.mark.parametrize('Transform', transform_types) def test_transform_chain_simplified(Transform): coord = [10, 13] transform_a = Transform(scale=[2, 3], translate=[8, -5]) transform_b = Transform(scale=[0.3, 1.4], translate=[-2.2, 3]) transform_chain = TransformChain([transform_a, transform_b]) transform_c = transform_chain.simplified new_coord_1 = transform_c(coord) new_coord_2 = transform_chain(coord) npt.assert_allclose(new_coord_1, new_coord_2) @pytest.mark.parametrize('Transform', transform_types) def test_transform_chain_inverse(Transform): coord = [10, 13] transform_a = Transform(scale=[2, 3], translate=[8, -5]) transform_b = Transform(scale=[0.3, 1.4], translate=[-2.2, 3]) transform_chain = TransformChain([transform_a, transform_b]) transform_chain_inverse = transform_chain.inverse new_coord = transform_chain(coord) orig_coord = transform_chain_inverse(new_coord) npt.assert_allclose(coord, orig_coord) @pytest.mark.parametrize('Transform', transform_types) def test_transform_chain_slice(Transform): coord = [10, 13] transform_a = Transform(scale=[2, 3, 3], translate=[8, 2, -5]) transform_b = Transform(scale=[0.3, 1, 1.4], translate=[-2.2, 4, 3]) transform_c = Transform(scale=[2, 3], translate=[8, -5]) transform_d = Transform(scale=[0.3, 1.4], translate=[-2.2, 3]) transform_chain_a = TransformChain([transform_a, transform_b]) transform_chain_b = TransformChain([transform_c, transform_d]) transform_chain_sliced = transform_chain_a.set_slice([0, 2]) new_coord_1 = transform_chain_sliced(coord) new_coord_2 = transform_chain_b(coord) npt.assert_allclose(new_coord_1, new_coord_2) @pytest.mark.parametrize('Transform', transform_types) def test_transform_chain_expanded(Transform): coord = [10, 3, 13] transform_a = Transform(scale=[2, 1, 3], translate=[8, 0, -5]) transform_b = Transform(scale=[0.3, 1, 1.4], translate=[-2.2, 0, 3]) transform_c = Transform(scale=[2, 3], translate=[8, -5]) transform_d = Transform(scale=[0.3, 1.4], translate=[-2.2, 3]) transform_chain_a = TransformChain([transform_a, transform_b]) transform_chain_b = TransformChain([transform_c, transform_d]) transform_chain_expandded = transform_chain_b.expand_dims([1]) new_coord_2 = transform_chain_a(coord) new_coord_1 = transform_chain_expandded(coord) npt.assert_allclose(new_coord_1, new_coord_2) def test_base_transform_init_is_called(): # TransformChain() was not calling Transform.__init__() at one point. # So below would fail with AttributeError: 'TransformChain' object has # no attribute 'name'. chain = TransformChain() assert chain.name is None napari-0.5.0a1/napari/utils/transforms/_tests/test_transform_utils.py000066400000000000000000000057341437041365600262070ustar00rootroot00000000000000import numpy as np import pytest from napari.utils.transforms.transform_utils import ( compose_linear_matrix, decompose_linear_matrix, is_diagonal, is_matrix_lower_triangular, is_matrix_triangular, is_matrix_upper_triangular, shear_matrix_from_angle, ) @pytest.mark.parametrize('upper_triangular', [True, False]) def test_decompose_linear_matrix(upper_triangular): """Test composing and decomposing a linear matrix.""" np.random.seed(0) # Decompose linear matrix A = np.random.random((2, 2)) rotate, scale, shear = decompose_linear_matrix( A, upper_triangular=upper_triangular ) # Compose linear matrix and check it matches B = compose_linear_matrix(rotate, scale, shear) np.testing.assert_almost_equal(A, B) # Decompose linear matrix and check it matches rotate_B, scale_B, shear_B = decompose_linear_matrix( B, upper_triangular=upper_triangular ) np.testing.assert_almost_equal(rotate, rotate_B) np.testing.assert_almost_equal(scale, scale_B) np.testing.assert_almost_equal(shear, shear_B) # Compose linear matrix and check it matches C = compose_linear_matrix(rotate_B, scale_B, shear_B) np.testing.assert_almost_equal(B, C) def test_composition_order(): """Test composition order.""" # Order is rotate, shear, scale rotate = np.array([[0, -1], [1, 0]]) shear = np.array([[1, 3], [0, 1]]) scale = [2, 5] matrix = compose_linear_matrix(rotate, scale, shear) np.testing.assert_almost_equal(matrix, rotate @ shear @ np.diag(scale)) def test_shear_matrix_from_angle(): """Test creating a shear matrix from an angle.""" matrix = shear_matrix_from_angle(35) np.testing.assert_almost_equal(np.diag(matrix), [1] * 3) np.testing.assert_almost_equal(matrix[-1, 0], np.tan(np.deg2rad(55))) upper = np.array([[1, 1], [0, 1]]) lower = np.array([[1, 0], [1, 1]]) full = np.array([[1, 1], [1, 1]]) def test_is_matrix_upper_triangular(): """Test if a matrix is upper triangular.""" assert is_matrix_upper_triangular(upper) assert not is_matrix_upper_triangular(lower) assert not is_matrix_upper_triangular(full) def test_is_matrix_lower_triangular(): """Test if a matrix is lower triangular.""" assert not is_matrix_lower_triangular(upper) assert is_matrix_lower_triangular(lower) assert not is_matrix_lower_triangular(full) def test_is_matrix_triangular(): """Test if a matrix is triangular.""" assert is_matrix_triangular(upper) assert is_matrix_triangular(lower) assert not is_matrix_triangular(full) def test_is_diagonal(): assert is_diagonal(np.eye(3)) assert not is_diagonal(np.asarray([[0, 1, 0], [1, 0, 0], [0, 0, 1]])) # affine with tiny off-diagonal elements will be considered diagonal m = np.full((3, 3), 1e-10) m[0, 0] = 1 m[1, 1] = 1 m[2, 2] = 1 assert is_diagonal(m) # will be considered non-diagonal with stricter tolerance assert not is_diagonal(m, tol=1e-12) napari-0.5.0a1/napari/utils/transforms/_tests/test_transforms.py000066400000000000000000000272651437041365600251550ustar00rootroot00000000000000import numpy as np import numpy.testing as npt import pytest from scipy.stats import special_ortho_group from napari.utils.transforms import Affine, CompositeAffine, ScaleTranslate transform_types = [Affine, CompositeAffine, ScaleTranslate] @pytest.mark.parametrize('Transform', transform_types) def test_scale_translate(Transform): coord = [10, 13] transform = Transform(scale=[2, 3], translate=[8, -5], name='st') assert transform._is_diagonal new_coord = transform(coord) target_coord = [2 * 10 + 8, 3 * 13 - 5] assert transform.name == 'st' npt.assert_allclose(new_coord, target_coord) @pytest.mark.parametrize('Transform', [Affine, CompositeAffine]) def test_affine_is_diagonal(Transform): transform = Transform(scale=[2, 3], translate=[8, -5], name='st') assert transform._is_diagonal transform.rotate = 5.0 assert not transform._is_diagonal # Rotation back to 0.0 will result in tiny non-zero off-diagonal values. # _is_diagonal assumes values below 1e-8 are equivalent to 0. transform.rotate = 0.0 assert transform._is_diagonal def test_diagonal_scale_setter(): diag_transform = Affine(scale=[2, 3], name='st') assert diag_transform._is_diagonal diag_transform.scale = [1] npt.assert_allclose(diag_transform.scale, [1.0, 1.0]) @pytest.mark.parametrize('Transform', transform_types) def test_scale_translate_broadcast_scale(Transform): coord = [1, 10, 13] transform = Transform(scale=[4, 2, 3], translate=[8, -5], name='st') new_coord = transform(coord) target_coord = [4, 2 * 10 + 8, 3 * 13 - 5] assert transform.name == 'st' npt.assert_allclose(transform.scale, [4, 2, 3]) npt.assert_allclose(transform.translate, [0, 8, -5]) npt.assert_allclose(new_coord, target_coord) @pytest.mark.parametrize('Transform', transform_types) def test_scale_translate_broadcast_translate(Transform): coord = [1, 10, 13] transform = Transform(scale=[2, 3], translate=[5, 8, -5], name='st') new_coord = transform(coord) target_coord = [6, 2 * 10 + 8, 3 * 13 - 5] assert transform.name == 'st' npt.assert_allclose(transform.scale, [1, 2, 3]) npt.assert_allclose(transform.translate, [5, 8, -5]) npt.assert_allclose(new_coord, target_coord) @pytest.mark.parametrize('Transform', transform_types) def test_scale_translate_inverse(Transform): coord = [10, 13] transform = Transform(scale=[2, 3], translate=[8, -5]) new_coord = transform(coord) target_coord = [2 * 10 + 8, 3 * 13 - 5] npt.assert_allclose(new_coord, target_coord) inverted_new_coord = transform.inverse(new_coord) npt.assert_allclose(inverted_new_coord, coord) @pytest.mark.parametrize('Transform', transform_types) def test_scale_translate_compose(Transform): coord = [10, 13] transform_a = Transform(scale=[2, 3], translate=[8, -5]) transform_b = Transform(scale=[0.3, 1.4], translate=[-2.2, 3]) transform_c = transform_b.compose(transform_a) new_coord_1 = transform_c(coord) new_coord_2 = transform_b(transform_a(coord)) npt.assert_allclose(new_coord_1, new_coord_2) @pytest.mark.parametrize('Transform', transform_types) def test_scale_translate_slice(Transform): transform_a = Transform(scale=[2, 3], translate=[8, -5]) transform_b = Transform(scale=[2, 1, 3], translate=[8, 3, -5], name='st') npt.assert_allclose(transform_b.set_slice([0, 2]).scale, transform_a.scale) npt.assert_allclose( transform_b.set_slice([0, 2]).translate, transform_a.translate ) assert transform_b.set_slice([0, 2]).name == 'st' @pytest.mark.parametrize('Transform', transform_types) def test_scale_translate_expand_dims(Transform): transform_a = Transform(scale=[2, 3], translate=[8, -5], name='st') transform_b = Transform(scale=[2, 1, 3], translate=[8, 0, -5]) npt.assert_allclose(transform_a.expand_dims([1]).scale, transform_b.scale) npt.assert_allclose( transform_a.expand_dims([1]).translate, transform_b.translate ) assert transform_a.expand_dims([1]).name == 'st' @pytest.mark.parametrize('Transform', transform_types) def test_scale_translate_identity_default(Transform): coord = [10, 13] transform = Transform() new_coord = transform(coord) npt.assert_allclose(new_coord, coord) def test_affine_properties(): transform = Affine(scale=[2, 3], translate=[8, -5], rotate=90, shear=[1]) npt.assert_allclose(transform.translate, [8, -5]) npt.assert_allclose(transform.scale, [2, 3]) npt.assert_almost_equal(transform.rotate, [[0, -1], [1, 0]]) npt.assert_almost_equal(transform.shear, [1]) def test_affine_properties_setters(): transform = Affine() transform.translate = [8, -5] npt.assert_allclose(transform.translate, [8, -5]) transform.scale = [2, 3] npt.assert_allclose(transform.scale, [2, 3]) transform.rotate = 90 npt.assert_almost_equal(transform.rotate, [[0, -1], [1, 0]]) transform.shear = [1] npt.assert_almost_equal(transform.shear, [1]) def test_rotate(): coord = [10, 13] transform = Affine(rotate=90) new_coord = transform(coord) # As rotate by 90 degrees, can use [-y, x] target_coord = [-coord[1], coord[0]] npt.assert_allclose(new_coord, target_coord) def test_scale_translate_rotate(): coord = [10, 13] transform = Affine(scale=[2, 3], translate=[8, -5], rotate=90) new_coord = transform(coord) post_scale = np.multiply(coord, [2, 3]) # As rotate by 90 degrees, can use [-y, x] post_rotate = [-post_scale[1], post_scale[0]] target_coord = np.add(post_rotate, [8, -5]) npt.assert_allclose(new_coord, target_coord) def test_scale_translate_rotate_inverse(): coord = [10, 13] transform = Affine(scale=[2, 3], translate=[8, -5], rotate=90) new_coord = transform(coord) post_scale = np.multiply(coord, [2, 3]) # As rotate by 90 degrees, can use [-y, x] post_rotate = [-post_scale[1], post_scale[0]] target_coord = np.add(post_rotate, [8, -5]) npt.assert_allclose(new_coord, target_coord) inverted_new_coord = transform.inverse(new_coord) npt.assert_allclose(inverted_new_coord, coord) def test_scale_translate_rotate_compose(): coord = [10, 13] transform_a = Affine(scale=[2, 3], translate=[8, -5], rotate=25) transform_b = Affine(scale=[0.3, 1.4], translate=[-2.2, 3], rotate=65) transform_c = transform_b.compose(transform_a) new_coord_1 = transform_c(coord) new_coord_2 = transform_b(transform_a(coord)) npt.assert_allclose(new_coord_1, new_coord_2) def test_scale_translate_rotate_shear_compose(): coord = [10, 13] transform_a = Affine(scale=[2, 3], translate=[8, -5], rotate=25, shear=[1]) transform_b = Affine( scale=[0.3, 1.4], translate=[-2.2, 3], rotate=65, shear=[-0.5], ) transform_c = transform_b.compose(transform_a) new_coord_1 = transform_c(coord) new_coord_2 = transform_b(transform_a(coord)) npt.assert_allclose(new_coord_1, new_coord_2) @pytest.mark.parametrize('dimensionality', [2, 3]) def test_affine_matrix(dimensionality): np.random.seed(0) N = dimensionality A = np.eye(N + 1) A[:-1, :-1] = np.random.random((N, N)) A[:-1, -1] = np.random.random(N) # Create transform transform = Affine(affine_matrix=A) # Check affine was passed correctly np.testing.assert_almost_equal(transform.affine_matrix, A) # Create input vector x = np.ones(N + 1) x[:-1] = np.random.random(N) # Apply transform and direct matrix multiplication result_transform = transform(x[:-1]) result_mat_multiply = (A @ x)[:-1] np.testing.assert_almost_equal(result_transform, result_mat_multiply) @pytest.mark.parametrize('dimensionality', [2, 3]) def test_affine_matrix_compose(dimensionality): np.random.seed(0) N = dimensionality A = np.eye(N + 1) A[:-1, :-1] = np.random.random((N, N)) A[:-1, -1] = np.random.random(N) B = np.eye(N + 1) B[:-1, :-1] = np.random.random((N, N)) B[:-1, -1] = np.random.random(N) # Create transform transform_A = Affine(affine_matrix=A) transform_B = Affine(affine_matrix=B) # Check affine was passed correctly np.testing.assert_almost_equal(transform_A.affine_matrix, A) np.testing.assert_almost_equal(transform_B.affine_matrix, B) # Compose tranform and directly matrix multiply transform_C = transform_B.compose(transform_A) C = B @ A np.testing.assert_almost_equal(transform_C.affine_matrix, C) @pytest.mark.parametrize('dimensionality', [2, 3]) def test_numpy_array_protocol(dimensionality): N = dimensionality A = np.eye(N + 1) A[:-1] = np.random.random((N, N + 1)) transform = Affine(affine_matrix=A) np.testing.assert_almost_equal(transform.affine_matrix, A) np.testing.assert_almost_equal(np.asarray(transform), A) coords = np.random.random((20, N + 1)) * 20 coords[:, -1] = 1 np.testing.assert_almost_equal( (transform @ coords.T).T[:, :-1], transform(coords[:, :-1]) ) @pytest.mark.parametrize('dimensionality', [2, 3]) def test_affine_matrix_inverse(dimensionality): np.random.seed(0) N = dimensionality A = np.eye(N + 1) A[:-1, :-1] = np.random.random((N, N)) A[:-1, -1] = np.random.random(N) # Create transform transform = Affine(affine_matrix=A) # Check affine was passed correctly np.testing.assert_almost_equal(transform.affine_matrix, A) # Check inverse is create correctly np.testing.assert_almost_equal( transform.inverse.affine_matrix, np.linalg.inv(A) ) def test_repeat_shear_setting(): """Test repeatedly setting shear with a lower triangular matrix.""" # Note this test is needed to check lower triangular # decomposition of shear is working mat = np.eye(3) mat[2, 0] = 0.5 transform = Affine(shear=mat.copy()) # Check shear decomposed into lower triangular np.testing.assert_almost_equal(mat, transform.shear) # Set shear to same value transform.shear = mat.copy() # Check shear still decomposed into lower triangular np.testing.assert_almost_equal(mat, transform.shear) # Set shear to same value transform.shear = mat.copy() # Check shear still decomposed into lower triangular np.testing.assert_almost_equal(mat, transform.shear) @pytest.mark.parametrize('dimensionality', [2, 3]) def test_composite_affine_equiv_to_affine(dimensionality): np.random.seed(0) translate = np.random.randn(dimensionality) scale = np.random.randn(dimensionality) rotate = special_ortho_group.rvs(dimensionality) shear = np.random.randn((dimensionality * (dimensionality - 1)) // 2) composite = CompositeAffine( translate=translate, scale=scale, rotate=rotate, shear=shear ) affine = Affine( translate=translate, scale=scale, rotate=rotate, shear=shear ) np.testing.assert_almost_equal( composite.affine_matrix, affine.affine_matrix ) def test_replace_slice_independence(): affine = Affine(ndim=6) a = Affine(translate=(3, 8), rotate=33, scale=(0.75, 1.2), shear=[-0.5]) b = Affine(translate=(2, 5), rotate=-10, scale=(1.0, 2.3), shear=[-0.0]) c = Affine(translate=(0, 0), rotate=45, scale=(3.33, 0.9), shear=[1.5]) affine = affine.replace_slice([1, 2], a) affine = affine.replace_slice([3, 4], b) affine = affine.replace_slice([0, 5], c) np.testing.assert_almost_equal( a.affine_matrix, affine.set_slice([1, 2]).affine_matrix ) np.testing.assert_almost_equal( b.affine_matrix, affine.set_slice([3, 4]).affine_matrix ) np.testing.assert_almost_equal( c.affine_matrix, affine.set_slice([0, 5]).affine_matrix ) def test_replace_slice_num_dimensions(): with pytest.raises(ValueError): Affine().replace_slice([0], Affine()) napari-0.5.0a1/napari/utils/transforms/transform_utils.py000066400000000000000000000364761437041365600236560ustar00rootroot00000000000000import numpy as np import scipy.linalg from napari.utils.translations import trans def compose_linear_matrix(rotate, scale, shear) -> np.array: """Compose linear transform matrix from rotate, shear, scale. Parameters ---------- rotate : float, 3-tuple of float, or n-D array. If a float convert into a 2D rotation matrix using that value as an angle. If 3-tuple convert into a 3D rotation matrix, using a yaw, pitch, roll convention. Otherwise assume an nD rotation. Angles are assumed to be in degrees. They can be converted from radians with np.degrees if needed. scale : 1-D array A 1-D array of factors to scale each axis by. Scale is broadcast to 1 in leading dimensions, so that, for example, a scale of [4, 18, 34] in 3D can be used as a scale of [1, 4, 18, 34] in 4D without modification. An empty translation vector implies no scaling. shear : 1-D array or n-D array Either a vector of upper triangular values, or an nD shear matrix with ones along the main diagonal. Returns ------- matrix : array nD array representing the composed linear transform. """ rotate_mat = _make_rotate_mat(rotate) scale_mat = np.diag(scale) shear_mat = _make_shear_mat(shear) ndim = max(mat.shape[0] for mat in (rotate_mat, scale_mat, shear_mat)) full_scale = embed_in_identity_matrix(scale_mat, ndim) full_rotate = embed_in_identity_matrix(rotate_mat, ndim) full_shear = embed_in_identity_matrix(shear_mat, ndim) return full_rotate @ full_shear @ full_scale def infer_ndim( *, scale=None, translate=None, rotate=None, shear=None, linear_matrix=None ): """Infer the dimensionality of a transformation from its input components. This is most useful when the dimensions of the inputs do not match, either when broadcasting is desired or when an input represents a parameterization of a transform component (e.g. rotate as an angle of a 2D rotation). Parameters ---------- rotate : float, 3-tuple of float, or n-D array. If a float convert into a 2D rotation matrix using that value as an angle. If 3-tuple convert into a 3D rotation matrix, using a yaw, pitch, roll convention. Otherwise assume an nD rotation. Angles are assumed to be in degrees. They can be converted from radians with np.degrees if needed. translate : 1-D array A 1-D array of factors to shift each axis by. Translation is broadcast to 0 in leading dimensions, so that, for example, a translation of [4, 18, 34] in 3D can be used as a translation of [0, 4, 18, 34] in 4D without modification. An empty translation vector implies no translation. scale : 1-D array A 1-D array of factors to scale each axis by. Scale is broadcast to 1 in leading dimensions, so that, for example, a scale of [4, 18, 34] in 3D can be used as a scale of [1, 4, 18, 34] in 4D without modification. An empty translation vector implies no scaling. shear : 1-D array or n-D array Either a vector of upper triangular values, or an nD shear matrix with ones along the main diagonal. Returns ------- ndim : int The maximum dimensionality of the component inputs. """ ndim = 0 if scale is not None: ndim = max(ndim, len(scale)) if translate is not None: ndim = max(ndim, len(translate)) if rotate is not None: ndim = max(ndim, _make_rotate_mat(rotate).shape[0]) if shear is not None: ndim = max(ndim, _make_shear_mat(shear).shape[0]) return ndim def translate_to_vector(translate, *, ndim): """Convert a translate input into an n-dimensional transform component. Parameters ---------- translate : 1-D array A 1-D array of factors to shift each axis by. Translation is padded with 0 in leading dimensions, so that, for example, a translation of [4, 18, 34] in 3D can be used as a translation of [0, 4, 18, 34] in 4D without modification. An empty vector implies no translation. ndim : int The desired dimensionality of the output transform component. Returns ------- np.ndarray The translate component as a 1D numpy array of length ndim. """ translate_arr = np.zeros(ndim) if translate is not None: translate_arr[-len(translate) :] = translate return translate_arr def scale_to_vector(scale, *, ndim): """Convert a scale input into an n-dimensional transform component. Parameters ---------- scale : 1-D array A 1-D array of factors to scale each axis by. Scale is padded with 1 in leading dimensions, so that, for example, a scale of [4, 18, 34] in 3D can be used as a scale of [1, 4, 18, 34] in 4D without modification. An empty vector implies no scaling. ndim : int The desired dimensionality of the output transform component. Returns ------- np.ndarray The scale component as a 1D numpy array of length ndim. """ scale_arr = np.ones(ndim) if scale is not None: scale_arr[-len(scale) :] = scale return scale_arr def rotate_to_matrix(rotate, *, ndim): """Convert a rotate input into an n-dimensional transform component. Parameters ---------- rotate : float, 3-tuple of float, or n-D array. If a float convert into a 2D rotation matrix using that value as an angle. If 3-tuple convert into a 3D rotation matrix, using a yaw, pitch, roll convention. Otherwise assume an nD rotation. Angles are assumed to be in degrees. They can be converted from radians with np.degrees if needed. ndim : int The desired dimensionality of the output transform component. Returns ------- np.ndarray The rotate component as a 2D numpy array of size ndim. """ full_rotate_mat = np.eye(ndim) if rotate is not None: rotate_mat = _make_rotate_mat(rotate) rotate_mat_ndim = rotate_mat.shape[0] full_rotate_mat[-rotate_mat_ndim:, -rotate_mat_ndim:] = rotate_mat return full_rotate_mat def _make_rotate_mat(rotate): if np.isscalar(rotate): return _make_2d_rotation(rotate) elif np.array(rotate).ndim == 1 and len(rotate) == 3: return _make_3d_rotation(*rotate) return np.array(rotate) def _make_2d_rotation(theta_degrees): """Makes a 2D rotation matrix from an angle in degrees.""" cos_theta, sin_theta = _cos_sin_degrees(theta_degrees) return np.array([[cos_theta, -sin_theta], [sin_theta, cos_theta]]) def _make_3d_rotation(alpha_degrees, beta_degrees, gamma_degrees): """Makes a 3D rotation matrix from roll, pitch, and yaw in degrees. For more details, see: https://en.wikipedia.org/wiki/Rotation_matrix#General_rotations """ cos_alpha, sin_alpha = _cos_sin_degrees(alpha_degrees) R_alpha = np.array( [ [cos_alpha, -sin_alpha, 0], [sin_alpha, cos_alpha, 0], [0, 0, 1], ] ) cos_beta, sin_beta = _cos_sin_degrees(beta_degrees) R_beta = np.array( [ [cos_beta, 0, sin_beta], [0, 1, 0], [-sin_beta, 0, cos_beta], ] ) cos_gamma, sin_gamma = _cos_sin_degrees(gamma_degrees) R_gamma = np.array( [ [1, 0, 0], [0, cos_gamma, -sin_gamma], [0, sin_gamma, cos_gamma], ] ) return R_alpha @ R_beta @ R_gamma def _cos_sin_degrees(angle_degrees): angle_radians = np.deg2rad(angle_degrees) return np.cos(angle_radians), np.sin(angle_radians) def shear_to_matrix(shear, *, ndim): """Convert a shear input into an n-dimensional transform component. Parameters ---------- shear : 1-D array or n-D array Either a vector of upper triangular values, or an nD shear matrix with ones along the main diagonal. ndim : int The desired dimensionality of the output transform matrix. Returns ------- np.ndarray The shear component as a triangular 2D numpy array of size ndim. """ full_shear_mat = np.eye(ndim) if shear is not None: shear_mat = _make_shear_mat(shear) shear_mat_ndim = shear_mat.shape[0] full_shear_mat[-shear_mat_ndim:, -shear_mat_ndim:] = shear_mat return full_shear_mat def _make_shear_mat(shear): # Check if an upper-triangular representation of shear or # a full nD shear matrix has been passed if np.isscalar(shear): raise ValueError( trans._( 'Scalars are not valid values for shear. Shear must be an upper triangular vector or square matrix with ones along the main diagonal.', deferred=True, ) ) if np.array(shear).ndim == 1: return expand_upper_triangular(shear) if not is_matrix_triangular(shear): raise ValueError( trans._( 'Only upper triangular or lower triangular matrices are accepted for shear, got {shear}. For other matrices, set the affine_matrix or linear_matrix directly.', deferred=True, shear=shear, ) ) return np.array(shear) def expand_upper_triangular(vector): """Expand a vector into an upper triangular matrix. Decomposition is based on code from https://github.com/matthew-brett/transforms3d. In particular, the `striu2mat` function in the `shears` module. https://github.com/matthew-brett/transforms3d/blob/0.3.1/transforms3d/shears.py#L30-L77. Parameters ---------- vector : np.array 1D vector of length M Returns ------- upper_tri : np.array shape (N, N) Upper triangular matrix. """ n = len(vector) N = ((-1 + np.sqrt(8 * n + 1)) / 2.0) + 1 # n+1 th root if N != np.floor(N): raise ValueError( trans._( '{number} is a strange number of shear elements', deferred=True, number=n, ) ) N = int(N) inds = np.triu(np.ones((N, N)), 1).astype(bool) upper_tri = np.eye(N) upper_tri[inds] = vector return upper_tri def embed_in_identity_matrix(matrix, ndim): """Embed an MxM matrix bottom right of larger NxN identity matrix. Parameters ---------- matrix : np.array 2D square matrix, MxM. ndim : int Integer with N >= M. Returns ------- full_matrix : np.array shape (N, N) Larger matrix. """ if matrix.ndim != 2 or matrix.shape[0] != matrix.shape[1]: raise ValueError( trans._( 'Improper transform matrix {matrix}', deferred=True, matrix=matrix, ) ) if matrix.shape[0] == ndim: return matrix else: full_matrix = np.eye(ndim) full_matrix[-matrix.shape[0] :, -matrix.shape[1] :] = matrix return full_matrix def decompose_linear_matrix( matrix, upper_triangular=True ) -> (np.array, np.array, np.array): """Decompose linear transform matrix into rotate, scale, shear. Decomposition is based on code from https://github.com/matthew-brett/transforms3d. In particular, the `decompose` function in the `affines` module. https://github.com/matthew-brett/transforms3d/blob/0.3.1/transforms3d/affines.py#L156-L246. Parameters ---------- matrix : np.array shape (N, N) nD array representing the composed linear transform. upper_triangular : bool Whether to decompose shear into an upper triangular or lower triangular matrix. Returns ------- rotate : float, 3-tuple of float, or n-D array. If a float convert into a 2D rotation matrix using that value as an angle. If 3-tuple convert into a 3D rotation matrix, using a yaw, pitch, roll convention. Otherwise assume an nD rotation. Angles are assumed to be in degrees. They can be converted from radians with np.degrees if needed. scale : 1-D array A 1-D array of factors to scale each axis by. Scale is broadcast to 1 in leading dimensions, so that, for example, a scale of [4, 18, 34] in 3D can be used as a scale of [1, 4, 18, 34] in 4D without modification. An empty translation vector implies no scaling. shear : 1-D array or n-D array 1-D array of upper triangular values or an n-D matrix if lower triangular. """ n = matrix.shape[0] if upper_triangular: rotate, tri = scipy.linalg.qr(matrix) else: upper_tri, rotate = scipy.linalg.rq(matrix.T) rotate = rotate.T tri = upper_tri.T scale = np.diag(tri).copy() # Take any reflection into account if np.linalg.det(rotate) < 0: scale[0] *= -1 tri[0] *= -1 rotate = matrix @ np.linalg.inv(tri) tri_normalized = tri @ np.linalg.inv(np.diag(scale)) if upper_triangular: shear = tri_normalized[np.triu(np.ones((n, n)), 1).astype(bool)] else: shear = tri_normalized return rotate, scale, shear def shear_matrix_from_angle(angle, ndim=3, axes=(-1, 0)): """Create a shear matrix from an angle. Parameters ---------- angle : float Angle in degrees. ndim : int Dimensionality of the shear matrix axes : 2-tuple of int Location of the angle in the shear matrix. Default is the lower left value. Returns ------- matrix : np.ndarray Shear matrix with ones along the main diagonal """ matrix = np.eye(ndim) matrix[axes] = np.tan(np.deg2rad(90 - angle)) return matrix def is_matrix_upper_triangular(matrix): """Check if a matrix is upper triangular. Parameters ---------- matrix : np.ndarray Matrix to be checked. Returns ------- bool Whether matrix is upper triangular or not. """ return np.allclose(matrix, np.triu(matrix)) def is_matrix_lower_triangular(matrix): """Check if a matrix is lower triangular. Parameters ---------- matrix : np.ndarray Matrix to be checked. Returns ------- bool Whether matrix is lower triangular or not. """ return np.allclose(matrix, np.tril(matrix)) def is_matrix_triangular(matrix): """Check if a matrix is triangular. Parameters ---------- matrix : np.ndarray Matrix to be checked. Returns ------- bool Whether matrix is triangular or not. """ return is_matrix_upper_triangular(matrix) or is_matrix_lower_triangular( matrix ) def is_diagonal(matrix, tol=1e-8): """Determine whether a matrix is diagonal up to some tolerance. Parameters ---------- matrix : 2-D array The matrix to test. tol : float, optional Consider any entries with magnitude < `tol` as 0. Returns ------- is_diag : bool True if matrix is diagonal, False otherwise. """ if matrix.ndim != 2 or matrix.shape[0] != matrix.shape[1]: raise ValueError( trans._( "matrix must be square, but shape={shape}", deferred=True, shape=matrix.shape, ) ) non_diag = matrix[~np.eye(matrix.shape[0], dtype=bool)] if tol == 0: return np.count_nonzero(non_diag) == 0 else: return np.max(np.abs(non_diag)) <= tol napari-0.5.0a1/napari/utils/transforms/transforms.py000066400000000000000000000665451437041365600226210ustar00rootroot00000000000000from functools import cached_property from typing import Sequence import numpy as np import toolz as tz from napari.utils.events import EventedList from napari.utils.transforms.transform_utils import ( compose_linear_matrix, decompose_linear_matrix, embed_in_identity_matrix, infer_ndim, is_diagonal, is_matrix_triangular, is_matrix_upper_triangular, rotate_to_matrix, scale_to_vector, shear_to_matrix, translate_to_vector, ) from napari.utils.translations import trans class Transform: """Base transform class. Defaults to the identity transform. Parameters ---------- func : callable, Coords -> Coords A function converting an NxD array of coordinates to NxD'. name : string A string name for the transform. """ def __init__(self, func=tz.identity, inverse=None, name=None) -> None: self.func = func self._inverse_func = inverse self.name = name if func is tz.identity: self._inverse_func = tz.identity def __call__(self, coords): """Transform input coordinates to output.""" return self.func(coords) @property def inverse(self) -> 'Transform': if self._inverse_func is not None: return Transform(self._inverse_func, self.func) else: raise ValueError( trans._('Inverse function was not provided.', deferred=True) ) def compose(self, transform: 'Transform') -> 'Transform': """Return the composite of this transform and the provided one.""" return TransformChain([self, transform]) def set_slice(self, axes: Sequence[int]) -> 'Transform': """Return a transform subset to the visible dimensions. Parameters ---------- axes : Sequence[int] Axes to subset the current transform with. Returns ------- Transform Resulting transform. """ raise NotImplementedError( trans._('Cannot subset arbitrary transforms.', deferred=True) ) def expand_dims(self, axes: Sequence[int]) -> 'Transform': """Return a transform with added axes for non-visible dimensions. Parameters ---------- axes : Sequence[int] Location of axes to expand the current transform with. Passing a list allows expansion to occur at specific locations and for expand_dims to be like an inverse to the set_slice method. Returns ------- Transform Resulting transform. """ raise NotImplementedError( trans._('Cannot subset arbitrary transforms.', deferred=True) ) @property def _is_diagonal(self): """Indicate when a transform does not mix or permute dimensions. Can be overriden in subclasses to enable performance optimizations that are specific to this case. """ return False def _clean_cache(self): cached_properties = ('_is_diagonal',) [self.__dict__.pop(p, None) for p in cached_properties] class TransformChain(EventedList, Transform): def __init__(self, transforms=None) -> None: if transforms is None: transforms = [] super().__init__( data=transforms, basetype=Transform, lookup={str: lambda x: x.name}, ) # The above super().__init__() will not call Transform.__init__(). # For that to work every __init__() called using super() needs to # in turn call super().__init__(). So we call it explicitly here. Transform.__init__(self) def __call__(self, coords): return tz.pipe(coords, *self) def __newlike__(self, iterable): return TransformChain(iterable) @property def inverse(self) -> 'TransformChain': """Return the inverse transform chain.""" return TransformChain([tf.inverse for tf in self[::-1]]) @property def _is_diagonal(self): if all(getattr(tf, '_is_diagonal', False) for tf in self): return True return getattr(self.simplified, '_is_diagonal', False) @property def simplified(self) -> 'Transform': """Return the composite of the transforms inside the transform chain.""" if len(self) == 0: return None if len(self) == 1: return self[0] else: return tz.pipe(self[0], *[tf.compose for tf in self[1:]]) def set_slice(self, axes: Sequence[int]) -> 'TransformChain': """Return a transform chain subset to the visible dimensions. Parameters ---------- axes : Sequence[int] Axes to subset the current transform chain with. Returns ------- TransformChain Resulting transform chain. """ return TransformChain([tf.set_slice(axes) for tf in self]) def expand_dims(self, axes: Sequence[int]) -> 'Transform': """Return a transform chain with added axes for non-visible dimensions. Parameters ---------- axes : Sequence[int] Location of axes to expand the current transform with. Passing a list allows expansion to occur at specific locations and for expand_dims to be like an inverse to the set_slice method. Returns ------- TransformChain Resulting transform chain. """ return TransformChain([tf.expand_dims(axes) for tf in self]) class ScaleTranslate(Transform): """n-dimensional scale and translation (shift) class. Scaling is always applied before translation. Parameters ---------- scale : 1-D array A 1-D array of factors to scale each axis by. Scale is broadcast to 1 in leading dimensions, so that, for example, a scale of [4, 18, 34] in 3D can be used as a scale of [1, 4, 18, 34] in 4D without modification. An empty translation vector implies no scaling. translate : 1-D array A 1-D array of factors to shift each axis by. Translation is broadcast to 0 in leading dimensions, so that, for example, a translation of [4, 18, 34] in 3D can be used as a translation of [0, 4, 18, 34] in 4D without modification. An empty translation vector implies no translation. name : string A string name for the transform. """ def __init__(self, scale=(1.0,), translate=(0.0,), *, name=None) -> None: super().__init__(name=name) if len(scale) > len(translate): translate = [0] * (len(scale) - len(translate)) + list(translate) if len(translate) > len(scale): scale = [1] * (len(translate) - len(scale)) + list(scale) self.scale = np.array(scale) self.translate = np.array(translate) def __call__(self, coords): coords = np.asarray(coords) append_first_axis = coords.ndim == 1 if append_first_axis: coords = coords[np.newaxis, :] coords_ndim = coords.shape[1] if coords_ndim == len(self.scale): scale = self.scale translate = self.translate else: scale = np.concatenate( ([1.0] * (coords_ndim - len(self.scale)), self.scale) ) translate = np.concatenate( ([0.0] * (coords_ndim - len(self.translate)), self.translate) ) out = scale * coords out += translate if append_first_axis: out = out[0] return out @property def inverse(self) -> 'ScaleTranslate': """Return the inverse transform.""" return ScaleTranslate(1 / self.scale, -1 / self.scale * self.translate) def compose(self, transform: 'Transform') -> 'Transform': """Return the composite of this transform and the provided one.""" if not isinstance(transform, ScaleTranslate): super().compose(transform) scale = self.scale * transform.scale translate = self.translate + self.scale * transform.translate return ScaleTranslate(scale, translate) def set_slice(self, axes: Sequence[int]) -> 'ScaleTranslate': """Return a transform subset to the visible dimensions. Parameters ---------- axes : Sequence[int] Axes to subset the current transform with. Returns ------- Transform Resulting transform. """ return ScaleTranslate( self.scale[axes], self.translate[axes], name=self.name ) def expand_dims(self, axes: Sequence[int]) -> 'ScaleTranslate': """Return a transform with added axes for non-visible dimensions. Parameters ---------- axes : Sequence[int] Location of axes to expand the current transform with. Passing a list allows expansion to occur at specific locations and for expand_dims to be like an inverse to the set_slice method. Returns ------- Transform Resulting transform. """ n = len(axes) + len(self.scale) not_axes = [i for i in range(n) if i not in axes] scale = np.ones(n) scale[not_axes] = self.scale translate = np.zeros(n) translate[not_axes] = self.translate return ScaleTranslate(scale, translate, name=self.name) @property def _is_diagonal(self): """Indicate that this transform does not mix or permute dimensions.""" return True class Affine(Transform): """n-dimensional affine transformation class. The affine transform can be represented as a n+1 dimensional transformation matrix in homogeneous coordinates [1]_, an n dimensional matrix and a length n translation vector, or be composed and decomposed from scale, rotate, and shear transformations defined in the following order: rotate * shear * scale + translate The affine_matrix representation can be used for easy compatibility with other libraries that can generate affine transformations. Parameters ---------- rotate : float, 3-tuple of float, or n-D array. If a float convert into a 2D rotation matrix using that value as an angle. If 3-tuple convert into a 3D rotation matrix, using a yaw, pitch, roll convention. Otherwise assume an nD rotation. Angles are assumed to be in degrees. They can be converted from radians with np.degrees if needed. scale : 1-D array A 1-D array of factors to scale each axis by. Scale is broadcast to 1 in leading dimensions, so that, for example, a scale of [4, 18, 34] in 3D can be used as a scale of [1, 4, 18, 34] in 4D without modification. An empty translation vector implies no scaling. shear : 1-D array or n-D array Either a vector of upper triangular values, or an nD shear matrix with ones along the main diagonal. translate : 1-D array A 1-D array of factors to shift each axis by. Translation is broadcast to 0 in leading dimensions, so that, for example, a translation of [4, 18, 34] in 3D can be used as a translation of [0, 4, 18, 34] in 4D without modification. An empty translation vector implies no translation. linear_matrix : n-D array, optional (N, N) matrix with linear transform. If provided then scale, rotate, and shear values are ignored. affine_matrix : n-D array, optional (N+1, N+1) affine transformation matrix in homogeneous coordinates [1]_. The first (N, N) entries correspond to a linear transform and the final column is a length N translation vector and a 1 or a napari AffineTransform object. If provided then translate, scale, rotate, and shear values are ignored. ndim : int The dimensionality of the transform. If None, this is inferred from the other parameters. name : string A string name for the transform. References ---------- [1] https://en.wikipedia.org/wiki/Homogeneous_coordinates. """ def __init__( self, scale=(1.0, 1.0), translate=( 0.0, 0.0, ), *, rotate=None, shear=None, linear_matrix=None, affine_matrix=None, ndim=None, name=None, ) -> None: super().__init__(name=name) self._upper_triangular = True if ndim is None: ndim = infer_ndim( scale=scale, translate=translate, rotate=rotate, shear=shear ) if affine_matrix is not None: linear_matrix = affine_matrix[:-1, :-1] translate = affine_matrix[:-1, -1] elif linear_matrix is not None: linear_matrix = np.array(linear_matrix) else: if rotate is None: rotate = np.eye(ndim) if shear is None: shear = np.eye(ndim) else: if np.array(shear).ndim == 2: if is_matrix_triangular(shear): self._upper_triangular = is_matrix_upper_triangular( shear ) else: raise ValueError( trans._( 'Only upper triangular or lower triangular matrices are accepted for shear, got {shear}. For other matrices, set the affine_matrix or linear_matrix directly.', deferred=True, shear=shear, ) ) linear_matrix = compose_linear_matrix(rotate, scale, shear) ndim = max(ndim, linear_matrix.shape[0]) self._linear_matrix = embed_in_identity_matrix(linear_matrix, ndim) self._translate = translate_to_vector(translate, ndim=ndim) def __call__(self, coords): coords = np.asarray(coords) append_first_axis = coords.ndim == 1 if append_first_axis: coords = coords[np.newaxis, :] coords_ndim = coords.shape[1] padded_linear_matrix = embed_in_identity_matrix( self._linear_matrix, coords_ndim ) translate = translate_to_vector(self._translate, ndim=coords_ndim) out = coords @ padded_linear_matrix.T out += translate if append_first_axis: out = out[0] return out @property def ndim(self) -> int: """Dimensionality of the transform.""" return self._linear_matrix.shape[0] @property def scale(self) -> np.array: """Return the scale of the transform.""" if self._is_diagonal: return np.diag(self._linear_matrix) else: return decompose_linear_matrix( self._linear_matrix, upper_triangular=self._upper_triangular )[1] @scale.setter def scale(self, scale): """Set the scale of the transform.""" if self._is_diagonal: scale = scale_to_vector(scale, ndim=self.ndim) for i in range(len(scale)): self._linear_matrix[i, i] = scale[i] else: rotate, _, shear = decompose_linear_matrix( self.linear_matrix, upper_triangular=self._upper_triangular ) self._linear_matrix = compose_linear_matrix(rotate, scale, shear) @property def translate(self) -> np.array: """Return the translation of the transform.""" return self._translate @translate.setter def translate(self, translate): """Set the translation of the transform.""" self._translate = translate_to_vector(translate, ndim=self.ndim) @property def rotate(self) -> np.array: """Return the rotation of the transform.""" return decompose_linear_matrix( self.linear_matrix, upper_triangular=self._upper_triangular )[0] @rotate.setter def rotate(self, rotate): """Set the rotation of the transform.""" _, scale, shear = decompose_linear_matrix( self.linear_matrix, upper_triangular=self._upper_triangular ) self._linear_matrix = compose_linear_matrix(rotate, scale, shear) self._clean_cache() @property def shear(self) -> np.array: """Return the shear of the transform.""" if self._is_diagonal: return np.zeros((self.ndim,)) return decompose_linear_matrix( self.linear_matrix, upper_triangular=self._upper_triangular )[2] @shear.setter def shear(self, shear): """Set the shear of the transform.""" shear = np.asarray(shear) if shear.ndim == 2: if is_matrix_triangular(shear): self._upper_triangular = is_matrix_upper_triangular(shear) else: raise ValueError( trans._( 'Only upper triangular or lower triangular matrices are accepted for shear, got {shear}. For other matrices, set the affine_matrix or linear_matrix directly.', deferred=True, shear=shear, ) ) else: self._upper_triangular = True rotate, scale, _ = decompose_linear_matrix( self.linear_matrix, upper_triangular=self._upper_triangular ) self._linear_matrix = compose_linear_matrix(rotate, scale, shear) self._clean_cache() @property def linear_matrix(self) -> np.array: """Return the linear matrix of the transform.""" return self._linear_matrix @linear_matrix.setter def linear_matrix(self, linear_matrix): """Set the linear matrix of the transform.""" self._linear_matrix = embed_in_identity_matrix( linear_matrix, ndim=self.ndim ) self._clean_cache() @property def affine_matrix(self) -> np.array: """Return the affine matrix for the transform.""" matrix = np.eye(self.ndim + 1, self.ndim + 1) matrix[:-1, :-1] = self._linear_matrix matrix[:-1, -1] = self._translate return matrix @affine_matrix.setter def affine_matrix(self, affine_matrix): """Set the affine matrix for the transform.""" self._linear_matrix = affine_matrix[:-1, :-1] self._translate = affine_matrix[:-1, -1] self._clean_cache() def __array__(self, *args, **kwargs): """NumPy __array__ protocol to get the affine transform matrix.""" return self.affine_matrix @property def inverse(self) -> 'Affine': """Return the inverse transform.""" return Affine(affine_matrix=np.linalg.inv(self.affine_matrix)) def compose(self, transform: 'Transform') -> 'Transform': """Return the composite of this transform and the provided one.""" if not isinstance(transform, Affine): return super().compose(transform) affine_matrix = self.affine_matrix @ transform.affine_matrix return Affine(affine_matrix=affine_matrix) def set_slice(self, axes: Sequence[int]) -> 'Affine': """Return a transform subset to the visible dimensions. Parameters ---------- axes : Sequence[int] Axes to subset the current transform with. Returns ------- Affine Resulting transform. """ axes = list(axes) if self._is_diagonal: linear_matrix = np.diag(self.scale[axes]) else: linear_matrix = self.linear_matrix[np.ix_(axes, axes)] return Affine( linear_matrix=linear_matrix, translate=self.translate[axes], ndim=len(axes), name=self.name, ) def replace_slice( self, axes: Sequence[int], transform: 'Affine' ) -> 'Affine': """Returns a transform where the transform at the indicated n dimensions is replaced with another n-dimensional transform Parameters ---------- axes : Sequence[int] Axes where the transform will be replaced transform : Affine The transform that will be inserted. Must have as many dimension as len(axes) Returns ------- Affine Resulting transform. """ if len(axes) != transform.ndim: raise ValueError( trans._( 'Dimensionality of provided axes list and transform differ.', deferred=True, ) ) linear_matrix = np.copy(self.linear_matrix) linear_matrix[np.ix_(axes, axes)] = transform.linear_matrix translate = np.copy(self.translate) translate[axes] = transform.translate return Affine( linear_matrix=linear_matrix, translate=translate, ndim=len(axes), name=self.name, ) def expand_dims(self, axes: Sequence[int]) -> 'Affine': """Return a transform with added axes for non-visible dimensions. Parameters ---------- axes : Sequence[int] Location of axes to expand the current transform with. Passing a list allows expansion to occur at specific locations and for expand_dims to be like an inverse to the set_slice method. Returns ------- Transform Resulting transform. """ n = len(axes) + len(self.scale) not_axes = [i for i in range(n) if i not in axes] linear_matrix = np.eye(n) linear_matrix[np.ix_(not_axes, not_axes)] = self.linear_matrix translate = np.zeros(n) translate[not_axes] = self.translate return Affine( linear_matrix=linear_matrix, translate=translate, ndim=n, name=self.name, ) @cached_property def _is_diagonal(self): """Determine whether linear_matrix is diagonal up to some tolerance. Since only `self.linear_matrix` is checked, affines with a translation component can still be considered diagonal. """ return is_diagonal(self.linear_matrix, tol=1e-8) class CompositeAffine(Affine): """n-dimensional affine transformation composed from more basic components. Composition is in the following order rotate * shear * scale + translate Parameters ---------- rotate : float, 3-tuple of float, or n-D array. If a float convert into a 2D rotation matrix using that value as an angle. If 3-tuple convert into a 3D rotation matrix, using a yaw, pitch, roll convention. Otherwise assume an nD rotation. Angles are assumed to be in degrees. They can be converted from radians with np.degrees if needed. scale : 1-D array A 1-D array of factors to scale each axis by. Scale is broadcast to 1 in leading dimensions, so that, for example, a scale of [4, 18, 34] in 3D can be used as a scale of [1, 4, 18, 34] in 4D without modification. An empty translation vector implies no scaling. shear : 1-D array or n-D array Either a vector of upper triangular values, or an nD shear matrix with ones along the main diagonal. translate : 1-D array A 1-D array of factors to shift each axis by. Translation is broadcast to 0 in leading dimensions, so that, for example, a translation of [4, 18, 34] in 3D can be used as a translation of [0, 4, 18, 34] in 4D without modification. An empty translation vector implies no translation. ndim : int The dimensionality of the transform. If None, this is inferred from the other parameters. name : string A string name for the transform. """ def __init__( self, scale=(1, 1), translate=(0, 0), *, rotate=None, shear=None, ndim=None, name=None, ) -> None: super().__init__( scale, translate, rotate=rotate, shear=shear, ndim=ndim, name=name ) if ndim is None: ndim = infer_ndim( scale=scale, translate=translate, rotate=rotate, shear=shear ) self._translate = translate_to_vector(translate, ndim=ndim) self._scale = scale_to_vector(scale, ndim=ndim) self._rotate = rotate_to_matrix(rotate, ndim=ndim) self._shear = shear_to_matrix(shear, ndim=ndim) self._linear_matrix = self._make_linear_matrix() @property def scale(self) -> np.array: """Return the scale of the transform.""" return self._scale @scale.setter def scale(self, scale): """Set the scale of the transform.""" self._scale = scale_to_vector(scale, ndim=self.ndim) self._linear_matrix = self._make_linear_matrix() @property def rotate(self) -> np.array: """Return the rotation of the transform.""" return self._rotate @rotate.setter def rotate(self, rotate): """Set the rotation of the transform.""" self._rotate = rotate_to_matrix(rotate, ndim=self.ndim) self._linear_matrix = self._make_linear_matrix() self._clean_cache() @property def shear(self) -> np.array: """Return the shear of the transform.""" return ( self._shear[np.triu_indices(n=self.ndim, k=1)] if is_matrix_upper_triangular(self._shear) else self._shear ) @shear.setter def shear(self, shear): """Set the shear of the transform.""" self._shear = shear_to_matrix(shear, ndim=self.ndim) self._linear_matrix = self._make_linear_matrix() self._clean_cache() @Affine.linear_matrix.setter def linear_matrix(self, linear_matrix): """Setting the linear matrix of a CompositeAffine transform is not supported.""" raise NotImplementedError( trans._( 'linear_matrix cannot be set directly for a CompositeAffine transform', deferred=True, ) ) @Affine.affine_matrix.setter def affine_matrix(self, affine_matrix): """Setting the affine matrix of a CompositeAffine transform is not supported.""" raise NotImplementedError( trans._( 'affine_matrix cannot be set directly for a CompositeAffine transform', deferred=True, ) ) def set_slice(self, axes: Sequence[int]) -> 'CompositeAffine': return CompositeAffine( scale=self._scale[axes], translate=self._translate[axes], rotate=self._rotate[np.ix_(axes, axes)], shear=self._shear[np.ix_(axes, axes)], ndim=len(axes), name=self.name, ) def expand_dims(self, axes: Sequence[int]) -> 'CompositeAffine': n = len(axes) + len(self.scale) not_axes = [i for i in range(n) if i not in axes] rotate = np.eye(n) rotate[np.ix_(not_axes, not_axes)] = self._rotate shear = np.eye(n) shear[np.ix_(not_axes, not_axes)] = self._shear translate = np.zeros(n) translate[not_axes] = self._translate scale = np.ones(n) scale[not_axes] = self._scale return CompositeAffine( translate=translate, scale=scale, rotate=rotate, shear=shear, ndim=n, name=self.name, ) def _make_linear_matrix(self): return self._rotate @ self._shear @ np.diag(self._scale) napari-0.5.0a1/napari/utils/translations.py000066400000000000000000000463631437041365600207420ustar00rootroot00000000000000""" Localization utilities to find available language packs and packages with localization data. """ import gettext import os from pathlib import Path from typing import Optional, Union from yaml import safe_load from napari.utils._base import _DEFAULT_CONFIG_PATH, _DEFAULT_LOCALE # Entry points NAPARI_LANGUAGEPACK_ENTRY = "napari.languagepack" # Constants LOCALE_DIR = "locale" def _get_display_name( locale: str, display_locale: str = _DEFAULT_LOCALE ) -> str: """ Return the language name to use with a `display_locale` for a given language locale. This is used to generate the preferences dialog options. Parameters ---------- locale : str The language name to use. display_locale : str, optional The language to display the `locale`. Returns ------- str Localized `locale` and capitalized language name using `display_locale` as language. """ try: # This is a dependency of the language packs to keep out of core import babel locale = locale if _is_valid_locale(locale) else _DEFAULT_LOCALE display_locale = ( display_locale if _is_valid_locale(display_locale) else _DEFAULT_LOCALE ) loc = babel.Locale.parse(locale) dislay_name = loc.get_display_name(display_locale).capitalize() except ModuleNotFoundError: dislay_name = display_locale.capitalize() return dislay_name def _is_valid_locale(locale: str) -> bool: """ Check if a `locale` value is valid. Parameters ---------- locale : str Language locale code. Notes ----- A valid locale is in the form language (See ISO-639 standard) and an optional territory (See ISO-3166 standard). Examples of valid locales: - English: "en" - Australian English: "en_AU" - Portuguese: "pt" - Brazilian Portuguese: "pt_BR" Examples of invalid locales: - Australian Spanish: "es_AU" - Brazilian German: "de_BR" """ valid = False try: # This is a dependency of the language packs to keep out of core import babel babel.Locale.parse(locale) valid = True except ModuleNotFoundError: valid = True except ValueError: pass except babel.core.UnknownLocaleError: pass return valid def get_language_packs(display_locale: str = _DEFAULT_LOCALE) -> dict: """ Return the available language packs installed in the system. The returned information contains the languages displayed in the current locale. This can be used to generate the preferences dialog information. Parameters ---------- display_locale : str, optional Default is _DEFAULT_LOCALE. Returns ------- dict A dict with the native and display language for all locales found. Examples -------- >>> get_language_packs("es_CO") { 'en': {'displayName': 'Inglés', 'nativeName': 'English'}, 'es_CO': { 'displayName': 'Español (colombia)', 'nativeName': 'Español (colombia)', }, } """ from napari_plugin_engine.manager import iter_available_plugins lang_packs = iter_available_plugins(NAPARI_LANGUAGEPACK_ENTRY) found_locales = {k: v for (k, v, _) in lang_packs} invalid_locales = [] valid_locales = [] for locale in found_locales: if _is_valid_locale(locale): valid_locales.append(locale) else: invalid_locales.append(locale) display_locale = ( display_locale if display_locale in valid_locales else _DEFAULT_LOCALE ) locales = { _DEFAULT_LOCALE: { "displayName": _get_display_name(_DEFAULT_LOCALE, display_locale), "nativeName": _get_display_name(_DEFAULT_LOCALE, _DEFAULT_LOCALE), } } for locale in valid_locales: locales[locale] = { "displayName": _get_display_name(locale, display_locale), "nativeName": _get_display_name(locale, locale), } return locales # --- Translators # ---------------------------------------------------------------------------- class TranslationString(str): """ A class that allows to create a deferred translations. """ def __deepcopy__(self, memo): from copy import deepcopy kwargs = deepcopy(self._kwargs) # Remove `n` from `kwargs` added in the initializer # See https://github.com/napari/napari/issues/4736 kwargs.pop("n") return TranslationString( domain=self._domain, msgctxt=self._msgctxt, msgid=self._msgid, msgid_plural=self._msgid_plural, n=self._n, deferred=self._deferred, **kwargs, ) def __new__( cls, domain: Optional[str] = None, msgctxt: Optional[str] = None, msgid: Optional[str] = None, msgid_plural: Optional[str] = None, n: Optional[str] = None, deferred: bool = False, **kwargs, ): if msgid is None: raise ValueError( trans._("Must provide at least a `msgid` parameter!") ) kwargs["n"] = n return str.__new__( cls, cls._original_value( msgid, msgid_plural, n, kwargs, ), ) def __init__( self, domain: Optional[str] = None, msgctxt: Optional[str] = None, msgid: Optional[str] = None, msgid_plural: Optional[str] = None, n: Optional[str] = None, deferred: bool = False, **kwargs, ) -> None: if msgid is None: raise ValueError( trans._("Must provide at least a `msgid` parameter!") ) self._domain = domain self._msgctxt = msgctxt self._msgid = msgid self._msgid_plural = msgid_plural self._n = n self._deferred = deferred self._kwargs = kwargs # Add `n` to `kwargs` to use with `format` self._kwargs['n'] = n def __repr__(self): return repr(self.__str__()) def __str__(self): return self.value() if self._deferred else self.translation() @classmethod def _original_value(cls, msgid, msgid_plural, n, kwargs): """ Return the original string with interpolated kwargs, if provided. Parameters ---------- msgid : str The singular string to translate. msgid_plural : str The plural string to translate. n : int The number for pluralization. kwargs : dict Any additional arguments to use when formating the string. """ string = msgid if n is None or n == 1 else msgid_plural return string.format(**kwargs) def value(self) -> str: """ Return the original string with interpolated kwargs, if provided. """ return self._original_value( self._msgid, self._msgid_plural, self._n, self._kwargs, ) def translation(self) -> str: """ Return the translated string with interpolated kwargs, if provided. """ if self._n is None and self._msgctxt is None: translation = gettext.dgettext( self._domain, self._msgid, ) elif self._n is None: translation = gettext.dpgettext( self._domain, self._msgctxt, self._msgid, ) elif self._msgctxt is None: translation = gettext.dngettext( self._domain, self._msgid, self._msgid_plural, self._n, ) else: translation = gettext.dnpgettext( self._domain, self._msgctxt, self._msgid, self._msgid_plural, self._n, ) return translation.format(**self._kwargs) class TranslationBundle: """ Translation bundle providing gettext translation functionality. Parameters ---------- domain : str The python package/module that this bundle points to. This corresponds to the module name of either the core package (``napari``) or any extension, for example ``napari_console``. The language packs will contain ``*.mo`` files with these names. locale : str The locale for this bundle. Examples include "en_US", "en_CO". """ def __init__(self, domain: str, locale: str) -> None: self._domain = domain self._locale = locale self._update_locale(locale) def _update_locale(self, locale: str): """ Update the locale environment variables. Parameters ---------- locale : str The language name to use. """ self._locale = locale localedir = None if locale.split("_")[0] != _DEFAULT_LOCALE: from napari_plugin_engine.manager import iter_available_plugins lang_packs = iter_available_plugins(NAPARI_LANGUAGEPACK_ENTRY) data = {k: v for (k, v, _) in lang_packs} if locale not in data: import warnings trans = self warnings.warn( trans._( "Requested locale not available: {locale}", deferred=True, locale=locale, ) ) else: import importlib mod = importlib.import_module(data[locale]) localedir = Path(mod.__file__).parent / LOCALE_DIR gettext.bindtextdomain(self._domain, localedir=localedir) def _dnpgettext( self, msgctxt: Optional[str] = None, msgid: Optional[str] = None, msgid_plural: Optional[str] = None, n: Optional[int] = None, **kwargs, ) -> str: """ Helper to handle all trans methods and delegate to corresponding gettext methods. Parameters ---------- msgctxt : str, optional The message context. msgid : str, optional The singular string to translate. msgid_plural : str, optional The plural string to translate. n : int, optional The number for pluralization. **kwargs : dict, optional Any additional arguments to use when formating the string. """ if msgid is None: trans = self raise ValueError( trans._( "Must provide at least a `msgid` parameter!", deferred=True ) ) if n is None and msgctxt is None: translation = gettext.dgettext(self._domain, msgid) elif n is None: translation = gettext.dpgettext(self._domain, msgctxt, msgid) elif msgctxt is None: translation = gettext.dngettext( self._domain, msgid, msgid_plural, n, ) else: translation = gettext.dnpgettext( self._domain, msgctxt, msgid, msgid_plural, n, ) kwargs['n'] = n return translation.format(**kwargs) def _( self, msgid: str, deferred: bool = False, **kwargs ) -> Union[TranslationString, str]: """ Shorthand for `gettext.gettext` with enhanced functionality. Parameters ---------- msgid : str The singular string to translate. deferred : bool, optional Define if the string translation should be deferred or executed in place. Default is False. **kwargs : dict, optional Any additional arguments to use when formatting the string. Returns ------- TranslationString or str The translation string which might be deferred or translated in place. """ return ( TranslationString( domain=self._domain, msgid=msgid, deferred=deferred, **kwargs ) if deferred else self._dnpgettext(msgid=msgid, **kwargs) ) def _n( self, msgid: str, msgid_plural: str, n: int, deferred: Optional[bool] = False, **kwargs, ) -> Union[TranslationString, str]: """ Shorthand for `gettext.ngettext` with enhanced functionality. Parameters ---------- msgid : str The singular string to translate. msgid_plural : str The plural string to translate. n : int The number for pluralization. deferred : bool, optional Define if the string translation should be deferred or executed in place. Default is False. **kwargs : dict, optional Any additional arguments to use when formating the string. Returns ------- TranslationString or str The translation string which might be deferred or translated in place. """ return ( TranslationString( domain=self._domain, msgid=msgid, msgid_plural=msgid_plural, n=n, deferred=deferred, **kwargs, ) if deferred else self._dnpgettext( msgid=msgid, msgid_plural=msgid_plural, n=n, **kwargs ) ) def _p( self, msgctxt: str, msgid: str, deferred: Optional[bool] = False, **kwargs, ) -> Union[TranslationString, str]: """ Shorthand for `gettext.pgettext` with enhanced functionality. Parameters ---------- msgctxt : str The message context. msgid : str The singular string to translate. deferred : bool, optional Define if the string translation should be deferred or executed in place. Default is False. **kwargs : dict, optional Any additional arguments to use when formating the string. Returns ------- TranslationString or str The translation string which might be deferred or translated in place. """ return ( TranslationString( domain=self._domain, msgctxt=msgctxt, msgid=msgid, deferred=deferred, **kwargs, ) if deferred else self._dnpgettext(msgctxt=msgctxt, msgid=msgid, **kwargs) ) def _np( self, msgctxt: str, msgid: str, msgid_plural: str, n: str, deferred: Optional[bool] = False, **kwargs, ) -> Union[TranslationString, str]: """ Shorthand for `gettext.npgettext` with enhanced functionality. Parameters ---------- msgctxt : str The message context. msgid : str The singular string to translate. msgid_plural : str The plural string to translate. n : int The number for pluralization. deferred : bool, optional Define if the string translation should be deferred or executed in place. Default is False. **kwargs : dict, optional Any additional arguments to use when formating the string. Returns ------- TranslationString or str The translation string which might be deferred or translated in place. """ return ( TranslationString( domain=self._domain, msgctxt=msgctxt, msgid=msgid, msgid_plural=msgid_plural, n=n, deferred=deferred, **kwargs, ) if deferred else self._dnpgettext( msgctxt=msgctxt, msgid=msgid, msgid_plural=msgid_plural, n=n, **kwargs, ) ) class _Translator: """ Translations manager. """ _TRANSLATORS = {} _LOCALE = _DEFAULT_LOCALE @staticmethod def _update_env(locale: str): """ Update the locale environment variables based on the settings. Parameters ---------- locale : str The language name to use. """ for key in ["LANGUAGE", "LANG"]: os.environ[key] = f"{locale}.UTF-8" @classmethod def _set_locale(cls, locale: str): """ Set locale for the translation bundles based on the settings. Parameters ---------- locale : str The language name to use. """ if _is_valid_locale(locale): cls._LOCALE = locale if locale.split("_")[0] != _DEFAULT_LOCALE: _Translator._update_env(locale) for bundle in cls._TRANSLATORS.values(): bundle._update_locale(locale) @classmethod def load(cls, domain: str = "napari") -> TranslationBundle: """ Load translation domain. The domain is usually the normalized ``package_name``. Parameters ---------- domain : str The translations domain. The normalized python package name. Returns ------- Translator A translator instance bound to the domain. """ if domain in cls._TRANSLATORS: trans = cls._TRANSLATORS[domain] else: trans = TranslationBundle(domain, cls._LOCALE) cls._TRANSLATORS[domain] = trans return trans def _load_language( default_config_path: str = _DEFAULT_CONFIG_PATH, locale: str = _DEFAULT_LOCALE, ) -> str: """ Load language from configuration file directly. Parameters ---------- default_config_path : str or Path The default configuration path, optional locale : str The default locale used to display options, optional Returns ------- str The language locale set by napari. """ default_config_path = Path(default_config_path) if default_config_path.exists(): with open(default_config_path) as fh: try: data = safe_load(fh) or {} except Exception as err: # noqa BLE001 import warnings warnings.warn( "The `language` setting defined in the napari " "configuration file could not be read.\n\n" "The default language will be used.\n\n" f"Error:\n{err}" ), data = {} locale = data.get("application", {}).get("language", locale) return os.environ.get("NAPARI_LANGUAGE", locale) # Default translator trans = _Translator.load("napari") # Update Translator locale before any other import uses it _Translator._set_locale(_load_language()) translator = _Translator napari-0.5.0a1/napari/utils/tree/000077500000000000000000000000001437041365600165725ustar00rootroot00000000000000napari-0.5.0a1/napari/utils/tree/__init__.py000066400000000000000000000001571437041365600207060ustar00rootroot00000000000000from napari.utils.tree.group import Group from napari.utils.tree.node import Node __all__ = ["Node", "Group"] napari-0.5.0a1/napari/utils/tree/_tests/000077500000000000000000000000001437041365600200735ustar00rootroot00000000000000napari-0.5.0a1/napari/utils/tree/_tests/test_tree_model.py000066400000000000000000000144661437041365600236360ustar00rootroot00000000000000from textwrap import dedent import pytest from napari.utils.tree import Group, Node @pytest.fixture def tree(): return Group( [ Node(name="1"), Group( [ Node(name="2"), Group([Node(name="3"), Node(name="4")], name="g2"), Node(name="5"), Node(name="6"), Node(name="7"), ], name="g1", ), Node(name="8"), Node(name="9"), ], name="root", ) def test_tree_str(tree): expected = dedent( """ root ├──1 ├──g1 │ ├──2 │ ├──g2 │ │ ├──3 │ │ └──4 │ ├──5 │ ├──6 │ └──7 ├──8 └──9""" ).strip() assert str(tree) == expected def test_node_indexing(tree: Group): expected_indices = [ 0, 1, (1, 0), (1, 1), (1, 1, 0), (1, 1, 1), (1, 2), (1, 3), (1, 4), 2, 3, ] assert list(tree._iter_indices()) == expected_indices for index in tree._iter_indices(): assert tree.index(tree[index]) == index item = tree[index] if item.parent: assert item.parent.index(item) is not None def test_relative_node_indexing(tree): """Test that nodes know their index relative to parent and root.""" root: Group[Node] = tree assert root.is_group() assert not root[0].is_group() assert root.index_from_root() == () assert root.index_in_parent() is None g1 = root[1] assert g1.name == 'g1' assert g1.index_in_parent() == 1 assert g1.index_from_root() == (1,) g1_1 = g1[1] assert g1_1.name == 'g2' assert g1_1.parent is g1 assert g1_1.parent.parent is root assert g1_1 is tree[1, 1] # nested index variant assert g1_1.index_from_root() == (1, 1) assert g1_1.index_in_parent() == 1 g1_1_0 = g1_1[0] assert g1_1_0.index_from_root() == (1, 1, 0) assert g1_1_0.index_in_parent() == 0 assert g1_1_0.name == '3' assert g1_1_0 is tree[1, 1, 0] # nested index variant g1_1_0.unparent() assert g1_1_0.index_from_root() == () assert g1_1_0.index_in_parent() is None with pytest.raises(IndexError) as e: g1_1_0.unparent() assert "Cannot unparent orphaned Node" in str(e) def test_traverse(tree): """Test depth first traversal.""" # iterating a group just returns its children assert [x.name for x in tree] == ['1', 'g1', '8', '9'] # traversing a group does a depth first traversal, including both groups # and nodes names = [x.name for x in tree.traverse()] e = ['root', '1', 'g1', '2', 'g2', '3', '4', '5', '6', '7', '8', '9'] assert names == e # traversing leaves_only=True returns only the Nodes, not the Groups names = [x.name for x in tree.traverse(leaves_only=True)] e = ['1', '2', '3', '4', '5', '6', '7', '8', '9'] assert names == e assert tree.is_group() g1 = tree[1] assert g1.parent is tree assert g1.name == 'g1' and g1.is_group() g2 = g1[1] assert g2.parent is g1 assert g2.name == 'g2' and g2.is_group() def test_slicing(tree): """Indexing into a group returns a group instance.""" assert tree.is_group() slc = tree[::-2] # take every other item, starting from the end assert [x.name for x in slc] == ['9', 'g1'] assert slc.is_group() expected = ['Group', '9', 'g1', '2', 'g2', '3', '4', '5', '6', '7'] assert [x.name for x in slc.traverse()] == expected def test_contains(tree): """Test that the ``in`` operator works for nested nodes.""" g1 = tree[1] assert g1.name == 'g1' assert g1 in tree g1_0 = g1[0] assert g1_0.name == '2' assert g1_0 in g1 assert g1_0 in tree # If you need to know if an item is an immediate child, you can use parent assert g1.parent is tree assert g1_0.parent is g1 g2 = g1[1] assert g2.name == 'g2' assert g2.is_group() assert g2 in tree g2_0 = g2[0] assert g2_0.name == '3' def test_deletion(tree): """Test that deletion removes parent""" g1 = tree[1] # first group in tree assert g1.parent is tree assert g1 in tree n1 = g1[0] # first item in group1 del tree[1] # delete g1 from the tree assert g1.parent is not tree assert g1 not in tree # the tree no longer has g1 or any of its children assert [x.name for x in tree.traverse()] == ['root', '1', '8', '9'] # g1 remains intact expected = ['g1', '2', 'g2', '3', '4', '5', '6', '7'] assert [x.name for x in g1.traverse()] == expected expected = ['2', '3', '4', '5', '6', '7'] assert [x.name for x in g1.traverse(leaves_only=True)] == expected # we can also delete slices, including extended slices del g1[1::2] assert n1.parent is g1 # the g1 tree is still intact assert [x.name for x in g1.traverse()] == ['g1', '2', '5', '7'] def test_nested_deletion(tree): """Test that we can delete nested indices from the root.""" # a tree is a NestedEventedList, so we can use nested_indices node5 = tree[1, 2] assert node5.name == '5' del tree[1, 2] assert node5 not in tree # nested indices may also be slices g2 = tree[1, 1] node4 = g2[1] assert node4 in tree del tree[1, 1, :] # delete all members of g2 inside of tree assert node4 not in tree # node4 is gone assert g2 == [] assert g2 in tree # the group itself remains in the tree def test_deep_index(tree: Group): """Test deep indexing""" node = tree[(1, 0)] assert tree.index(node) == (1, 0) def test_remove_selected(tree: Group): """Test remove_selected works, with nested""" node = tree[(1, 0)] tree.selection.active = node tree.remove_selected() def test_nested_custom_lookup(tree: Group): tree._lookup = {str: lambda x: x.name} # first level g1 = tree[1] assert g1.name == 'g1' # index with integer as usual assert tree.index("g1") == 1 assert tree['g1'] == g1 # index with string also works # second level g1_2 = g1[2] assert tree[1, 2].name == '5' assert tree.index('5') == (1, 2) assert tree['5'] == g1_2 napari-0.5.0a1/napari/utils/tree/group.py000066400000000000000000000106031437041365600203000ustar00rootroot00000000000000from __future__ import annotations from typing import TYPE_CHECKING, Generator, Iterable, List, TypeVar, Union from napari.utils.events.containers._selectable_list import ( SelectableNestableEventedList, ) from napari.utils.tree.node import Node if TYPE_CHECKING: from napari.utils.events.containers._nested_list import MaybeNestedIndex NodeType = TypeVar("NodeType", bound=Node) class Group(Node, SelectableNestableEventedList[NodeType]): """An object that can contain other objects in a composite Tree pattern. The ``Group`` (aka composite) is an element that has sub-elements: which may be ``Nodes`` or other ``Groups``. By inheriting from :class:`NestableEventedList`, ``Groups`` have basic python list-like behavior and emit events when modified. The main addition in this class is that when objects are added to a ``Group``, they are assigned a ``.parent`` attribute pointing to the group, which is removed upon deletion from the group. For additional background on the composite design pattern, see: https://refactoring.guru/design-patterns/composite Parameters ---------- children : Iterable[Node], optional Items to initialize the Group, by default (). All items must be instances of ``Node``. name : str, optional A name/id for this group, by default "Group" """ def __init__( self, children: Iterable[NodeType] = (), name: str = "Group", basetype=Node, ) -> None: Node.__init__(self, name=name) SelectableNestableEventedList.__init__( self, data=children, basetype=basetype, lookup={str: lambda e: e.name}, ) def __newlike__(self, iterable: Iterable): # NOTE: TRICKY! # whenever we slice into a group with group[start:end], # the super().__newlike__() call is going to create a new object # of the same type (Group), and then populate it with items in iterable # ... # However, `Group.insert` changes the parent of each item as # it gets inserted. (The implication is that no Node can live in # multiple groups at the same time). This means that simply slicing # into a group will actually reparent *all* items in that group # (even if the resulting slice goes unused...). # # So, we call new._list.extend here to avoid that reparenting. # Though this may have its own negative consequences for typing/events? new = type(self)() new._basetypes = self._basetypes new._lookup = self._lookup.copy() new._list.extend(iterable) return new def __getitem__(self, key) -> Union[NodeType, Group[NodeType]]: return super().__getitem__(key) def __delitem__(self, key: "MaybeNestedIndex"): """Remove item at ``key``, and unparent.""" if isinstance(key, (int, tuple)): self[key].parent = None # type: ignore else: for item in self[key]: item.parent = None super().__delitem__(key) def insert(self, index: int, value): """Insert ``value`` as child of this group at position ``index``.""" value.parent = self super().insert(index, value) def is_group(self) -> bool: """Return True, indicating that this ``Node`` is a ``Group``.""" return True def __contains__(self, other): """Return true if ``other`` appears anywhere under this group.""" return any(item is other for item in self.traverse()) def traverse( self, leaves_only=False, with_ancestors=False ) -> Generator[NodeType, None, None]: """Recursive all nodes and leaves of the Group tree.""" obj = self.root() if with_ancestors else self if not leaves_only: yield obj for child in obj: yield from child.traverse(leaves_only) def _render(self) -> List[str]: """Recursively return list of strings that can render ascii tree.""" lines = [self._node_name()] for n, child in enumerate(self): spacer, bul = ( (" ", "└──") if n == len(self) - 1 else (" │", "├──") ) child_tree = child._render() lines.append(f" {bul}" + child_tree.pop(0)) lines.extend([spacer + lay for lay in child_tree]) return lines napari-0.5.0a1/napari/utils/tree/node.py000066400000000000000000000067511437041365600201020ustar00rootroot00000000000000from typing import TYPE_CHECKING, Generator, List, Optional, Tuple from napari.utils.translations import trans if TYPE_CHECKING: from napari.utils.tree.group import Group class Node: """An object that can be a member of a :class:`Group`. ``Node`` forms the base object of a composite Tree pattern. This class describes operations that are common to both simple (node) and complex (group) elements of the tree. ``Node`` may not have children, whereas :class:`~napari.utils.tree.group.Group` can. For additional background on the composite design pattern, see: https://refactoring.guru/design-patterns/composite Parameters ---------- name : str, optional A name/id for this node, by default "Node" Attributes ---------- parent : Group, optional The parent of this Node. """ def __init__(self, name: str = "Node") -> None: self.parent: Optional[Group] = None self._name = name @property def name(self) -> str: return self._name @name.setter def name(self, value: str) -> None: self._name = value def is_group(self) -> bool: """Return True if this Node is a composite. :class:`~napari.utils.tree.Group` will return True. """ return False def index_in_parent(self) -> Optional[int]: """Return index of this Node in its parent, or None if no parent.""" return self.parent.index(self) if self.parent is not None else None def index_from_root(self) -> Tuple[int, ...]: """Return index of this Node relative to root. Will return ``()`` if this object *is* the root. """ item = self indices: List[int] = [] while item.parent is not None: indices.insert(0, item.index_in_parent()) # type: ignore item = item.parent return tuple(indices) def iter_parents(self): """Iterate the parent chain, starting with nearest relatives""" obj = self.parent while obj: yield obj obj = obj.parent def root(self) -> 'Node': """Get the root parent.""" parents = list(self.iter_parents()) return parents[-1] if parents else self def traverse( self, leaves_only=False, with_ancestors=False ) -> Generator['Node', None, None]: """Recursive all nodes and leaves of the Node. This is mostly used by :class:`~napari.utils.tree.Group`, which can also traverse children. A ``Node`` simply yields itself. """ yield self def __str__(self): """Render ascii tree string representation of this node""" return "\n".join(self._render()) def _render(self) -> List[str]: """Return list of strings that can render ascii tree. For ``Node``, we just return the name of this specific node. :class:`~napari.utils.tree.Group` will render a full tree. """ return [self._node_name()] def _node_name(self) -> str: """Will be used when rendering node tree as string. Subclasses may override as desired. """ return self.name def unparent(self): """Remove this object from its parent.""" if self.parent is not None: self.parent.remove(self) return self raise IndexError( trans._( "Cannot unparent orphaned Node: {node!r}", deferred=True, node=self, ), ) napari-0.5.0a1/napari/utils/validators.py000066400000000000000000000075611437041365600203660ustar00rootroot00000000000000from collections.abc import Collection, Generator from itertools import tee from typing import Iterable from napari.utils.translations import trans def validate_n_seq(n: int, dtype=None): """Creates a function to validate a sequence of len == N and type == dtype. Currently does **not** validate generators (will always validate true). Parameters ---------- n : int Desired length of the sequence dtype : type, optional If provided each item in the sequence must match dtype, by default None Returns ------- function Function that can be called on an object to validate that is a sequence of len `n` and (optionally) each item in the sequence has type `dtype` Examples -------- >>> validate = validate_N_seq(2) >>> validate(8) # raises TypeError >>> validate([1, 2, 3]) # raises ValueError >>> validate([4, 5]) # just fine, thank you very much """ def func(obj): """Function that validates whether an object is a sequence of len `n`. Parameters ---------- obj : any the object to be validated Raises ------ TypeError If the object is not an indexable collection. ValueError If the object does not have length `n` TypeError If `dtype` was provided to the wrapper function and all items in the sequence are not of type `dtype`. """ if isinstance(obj, Generator): return if not (isinstance(obj, Collection) and hasattr(obj, '__getitem__')): raise TypeError( trans._( "object '{obj}' is not an indexable collection (list, tuple, or np.array), of length {number}", deferred=True, obj=obj, number=n, ) ) if len(obj) != n: raise ValueError( trans._( "object must have length {number}, got {obj_len}", deferred=True, number=n, obj_len=len(obj), ) ) if dtype is not None: for item in obj: if not isinstance(item, dtype): raise TypeError( trans._( "Every item in the sequence must be of type {dtype}, but {item} is of type {item_type}", deferred=True, dtype=dtype, item=item, item_type=type(item), ) ) return func def _pairwise(iterable: Iterable): """Convert iterable to a zip object containing tuples of pairs along the sequence. Examples -------- >>> pairwise([1, 2, 3, 4]) >>> list(pairwise([1, 2, 3, 4])) [(1, 2), (2, 3), (3, 4)] """ # duplicate the iterable a, b = tee(iterable) # shift b by one position next(b, None) # create tuple pairs from the values in a and b return zip(a, b) def _validate_increasing(values: Iterable) -> None: """Ensure that values in an iterable are monotocially increasing. Examples -------- >>> _validate_increasing([1, 2, 3, 4]) None >>> _validate_increasing([1, 4, 3, 4]) ValueError: Sequence [1, 4, 3, 4] must be monotonically increasing. Raises ------ ValueError If `values` is constant or decreasing from one value to the next. """ # convert iterable to pairwise tuples, check each tuple if any(a >= b for a, b in _pairwise(values)): raise ValueError( trans._( "Sequence {sequence} must be monotonically increasing.", deferred=True, sequence=values, ) ) napari-0.5.0a1/napari/view_layers.py000066400000000000000000000400641437041365600174020ustar00rootroot00000000000000"""Methods to create a new viewer instance then add a particular layer type. All functions follow this pattern, (where is replaced with one of the layer types, like "image", "points", etc...): .. code-block:: python def view_(*args, **kwargs): # ... pop all of the viewer kwargs out of kwargs into viewer_kwargs viewer = Viewer(**viewer_kwargs) add_method = getattr(viewer, f"add_{}") add_method(*args, **kwargs) return viewer """ import inspect from typing import Any, List, Optional, Tuple from numpydoc.docscrape import NumpyDocString as _NumpyDocString from napari.components.dims import Dims from napari.layers import Image from napari.viewer import Viewer __all__ = [ 'view_image', 'view_labels', 'view_path', 'view_points', 'view_shapes', 'view_surface', 'view_tracks', 'view_vectors', 'imshow', ] _doc_template = """Create a viewer and add a{n} {layer_string} layer. {params} Returns ------- viewer : :class:`napari.Viewer` The newly-created viewer. """ _VIEW_DOC = _NumpyDocString(Viewer.__doc__) _VIEW_PARAMS = " " + "\n".join(_VIEW_DOC._str_param_list('Parameters')[2:]) def _merge_docstrings(add_method, layer_string): # create combined docstring with parameters from add_* and Viewer methods import textwrap add_method_doc = _NumpyDocString(add_method.__doc__) # this ugliness is because the indentation of the parsed numpydocstring # is different for the first parameter :( lines = add_method_doc._str_param_list('Parameters') lines = lines[:3] + textwrap.dedent("\n".join(lines[3:])).splitlines() params = "\n".join(lines) + "\n" + textwrap.dedent(_VIEW_PARAMS) n = 'n' if layer_string.startswith(tuple('aeiou')) else '' return _doc_template.format(n=n, layer_string=layer_string, params=params) def _merge_layer_viewer_sigs_docs(func): """Make combined signature, docstrings, and annotations for `func`. This is a decorator that combines information from `Viewer.__init__`, and one of the `viewer.add_*` methods. It updates the docstring, signature, and type annotations of the decorated function with the merged versions. Parameters ---------- func : callable `view_` function to modify Returns ------- func : callable The same function, with merged metadata. """ from napari.utils.misc import _combine_signatures # get the `Viewer.add_*` method layer_string = func.__name__.replace("view_", "") if layer_string == 'path': add_method = Viewer.open else: add_method = getattr(Viewer, f'add_{layer_string}') # merge the docstrings of Viewer and viewer.add_* func.__doc__ = _merge_docstrings(add_method, layer_string) # merge the signatures of Viewer and viewer.add_* func.__signature__ = _combine_signatures( add_method, Viewer, return_annotation=Viewer, exclude=('self',) ) # merge the __annotations__ func.__annotations__ = { **add_method.__annotations__, **Viewer.__init__.__annotations__, 'return': Viewer, } # _forwardrefns_ is used by stubgen.py to populate the globalns # when evaluate forward references with get_type_hints func._forwardrefns_ = {**add_method.__globals__} return func _viewer_params = inspect.signature(Viewer).parameters _dims_params = Dims.__fields__ def _make_viewer_then( add_method: str, /, *args, viewer: Optional[Viewer] = None, **kwargs, ) -> Tuple[Viewer, Any]: """Create a viewer, call given add_* method, then return viewer and layer. This function will be deprecated soon (See #4693) Parameters ---------- add_method : str Which ``add_`` method to call on the viewer, e.g. `add_image`, or `add_labels`. *args : list Positional arguments for the ``add_`` method. viewer : Viewer, optional A pre-existing viewer, which will be used provided, rather than creating a new one. **kwargs : dict Keyword arguments for either the `Viewer` constructor or for the ``add_`` method. Returns ------- viewer : napari.Viewer The created viewer, or the same one that was passed in, if given. layer(s): napari.layers.Layer or List[napari.layers.Layer] The value returned by the add_method. Can be a list of layers if ``add_image`` is called with a ``channel_axis=`` keyword argument. """ vkwargs = {k: kwargs.pop(k) for k in list(kwargs) if k in _viewer_params} # separate dims kwargs because we want to set those after adding data dims_kwargs = { k: vkwargs.pop(k) for k in list(vkwargs) if k in _dims_params } if viewer is None: viewer = Viewer(**vkwargs) kwargs.update(kwargs.pop("kwargs", {})) method = getattr(viewer, add_method) added = method(*args, **kwargs) if isinstance(added, list): added = tuple(added) for arg_name, arg_val in dims_kwargs.items(): setattr(viewer.dims, arg_name, arg_val) return viewer, added # Each of the following functions will have this pattern: # # def view_image(*args, **kwargs): # # ... pop all of the viewer kwargs out of kwargs into viewer_kwargs # viewer = Viewer(**viewer_kwargs) # viewer.add_image(*args, **kwargs) # return viewer @_merge_layer_viewer_sigs_docs def view_image(*args, **kwargs): return _make_viewer_then('add_image', *args, **kwargs)[0] @_merge_layer_viewer_sigs_docs def view_labels(*args, **kwargs): return _make_viewer_then('add_labels', *args, **kwargs)[0] @_merge_layer_viewer_sigs_docs def view_points(*args, **kwargs): return _make_viewer_then('add_points', *args, **kwargs)[0] @_merge_layer_viewer_sigs_docs def view_shapes(*args, **kwargs): return _make_viewer_then('add_shapes', *args, **kwargs)[0] @_merge_layer_viewer_sigs_docs def view_surface(*args, **kwargs): return _make_viewer_then('add_surface', *args, **kwargs)[0] @_merge_layer_viewer_sigs_docs def view_tracks(*args, **kwargs): return _make_viewer_then('add_tracks', *args, **kwargs)[0] @_merge_layer_viewer_sigs_docs def view_vectors(*args, **kwargs): return _make_viewer_then('add_vectors', *args, **kwargs)[0] @_merge_layer_viewer_sigs_docs def view_path(*args, **kwargs): return _make_viewer_then('open', *args, **kwargs)[0] def imshow( data, *, channel_axis=None, rgb=None, colormap=None, contrast_limits=None, gamma=1, interpolation2d='nearest', interpolation3d='linear', rendering='mip', depiction='volume', iso_threshold=None, attenuation=0.05, name=None, metadata=None, scale=None, translate=None, rotate=None, shear=None, affine=None, opacity=1, blending=None, visible=True, multiscale=None, cache=True, plane=None, experimental_clipping_planes=None, viewer=None, title='napari', ndisplay=2, order=(), axis_labels=(), show=True, ) -> Tuple[Viewer, List["Image"]]: """Load data into an Image layer and return the Viewer and Layer. Parameters ---------- data : array or list of array Image data. Can be N >= 2 dimensional. If the last dimension has length 3 or 4 can be interpreted as RGB or RGBA if rgb is `True`. If a list and arrays are decreasing in shape then the data is treated as a multiscale image. Please note multiscale rendering is only supported in 2D. In 3D, only the lowest resolution scale is displayed. channel_axis : int, optional Axis to expand image along. If provided, each channel in the data will be added as an individual image layer. In channel_axis mode, all other parameters MAY be provided as lists, and the Nth value will be applied to the Nth channel in the data. If a single value is provided, it will be broadcast to all Layers. rgb : bool or list Whether the image is rgb RGB or RGBA. If not specified by user and the last dimension of the data has length 3 or 4 it will be set as `True`. If `False` the image is interpreted as a luminance image. If a list then must be same length as the axis that is being expanded as channels. colormap : str, napari.utils.Colormap, tuple, dict, list Colormaps to use for luminance images. If a string must be the name of a supported colormap from vispy or matplotlib. If a tuple the first value must be a string to assign as a name to a colormap and the second item must be a Colormap. If a dict the key must be a string to assign as a name to a colormap and the value must be a Colormap. If a list then must be same length as the axis that is being expanded as channels, and each colormap is applied to each new image layer. contrast_limits : list (2,) Color limits to be used for determining the colormap bounds for luminance images. If not passed is calculated as the min and max of the image. If list of lists then must be same length as the axis that is being expanded and then each colormap is applied to each image. gamma : list, float Gamma correction for determining colormap linearity. Defaults to 1. If a list then must be same length as the axis that is being expanded as channels. interpolation : str or list Deprecated, to be removed in 0.6.0 interpolation2d : str or list Interpolation mode used by vispy in 2D. Must be one of our supported modes. If a list then must be same length as the axis that is being expanded as channels. interpolation3d : str or list Interpolation mode used by vispy in 3D. Must be one of our supported modes. If a list then must be same length as the axis that is being expanded as channels. rendering : str or list Rendering mode used by vispy. Must be one of our supported modes. If a list then must be same length as the axis that is being expanded as channels. depiction : str Selects a preset volume depiction mode in vispy * volume: images are rendered as 3D volumes. * plane: images are rendered as 2D planes embedded in 3D. iso_threshold : float or list Threshold for isosurface. If a list then must be same length as the axis that is being expanded as channels. attenuation : float or list Attenuation rate for attenuated maximum intensity projection. If a list then must be same length as the axis that is being expanded as channels. name : str or list of str Name of the layer. If a list then must be same length as the axis that is being expanded as channels. metadata : dict or list of dict Layer metadata. If a list then must be a list of dicts with the same length as the axis that is being expanded as channels. scale : tuple of float or list Scale factors for the layer. If a list then must be a list of tuples of float with the same length as the axis that is being expanded as channels. translate : tuple of float or list Translation values for the layer. If a list then must be a list of tuples of float with the same length as the axis that is being expanded as channels. rotate : float, 3-tuple of float, n-D array or list. If a float convert into a 2D rotation matrix using that value as an angle. If 3-tuple convert into a 3D rotation matrix, using a yaw, pitch, roll convention. Otherwise assume an nD rotation. Angles are assumed to be in degrees. They can be converted from radians with np.degrees if needed. If a list then must have same length as the axis that is being expanded as channels. shear : 1-D array or list. A vector of shear values for an upper triangular n-D shear matrix. If a list then must have same length as the axis that is being expanded as channels. affine : n-D array or napari.utils.transforms.Affine (N+1, N+1) affine transformation matrix in homogeneous coordinates. The first (N, N) entries correspond to a linear transform and the final column is a length N translation vector and a 1 or a napari `Affine` transform object. Applied as an extra transform on top of the provided scale, rotate, and shear values. opacity : float or list Opacity of the layer visual, between 0.0 and 1.0. If a list then must be same length as the axis that is being expanded as channels. blending : str or list One of a list of preset blending modes that determines how RGB and alpha values of the layer visual get mixed. Allowed values are {'opaque', 'translucent', and 'additive'}. If a list then must be same length as the axis that is being expanded as channels. visible : bool or list of bool Whether the layer visual is currently being displayed. If a list then must be same length as the axis that is being expanded as channels. multiscale : bool Whether the data is a multiscale image or not. Multiscale data is represented by a list of array like image data. If not specified by the user and if the data is a list of arrays that decrease in shape then it will be taken to be multiscale. The first image in the list should be the largest. Please note multiscale rendering is only supported in 2D. In 3D, only the lowest resolution scale is displayed. cache : bool Whether slices of out-of-core datasets should be cached upon retrieval. Currently, this only applies to dask arrays. plane : dict or SlicingPlane Properties defining plane rendering in 3D. Properties are defined in data coordinates. Valid dictionary keys are {'position', 'normal', 'thickness', and 'enabled'}. experimental_clipping_planes : list of dicts, list of ClippingPlane, or ClippingPlaneList Each dict defines a clipping plane in 3D in data coordinates. Valid dictionary keys are {'position', 'normal', and 'enabled'}. Values on the negative side of the normal are discarded if the plane is enabled. viewer : Viewer object, optional, by default None. title : string, optional The title of the viewer window. By default 'napari'. ndisplay : {2, 3}, optional Number of displayed dimensions. By default 2. order : tuple of int, optional Order in which dimensions are displayed where the last two or last three dimensions correspond to row x column or plane x row x column if ndisplay is 2 or 3. By default None axis_labels : list of str, optional Dimension names. By default they are labeled with sequential numbers show : bool, optional Whether to show the viewer after instantiation. By default True. Returns ------- viewer : napari.Viewer The created or passed viewer. layer(s) : napari.layers.Image or List[napari.layers.Image] The added layer(s). (May be more than one if the ``channel_axis`` keyword argument is given. """ return _make_viewer_then( 'add_image', data, viewer=viewer, channel_axis=channel_axis, rgb=rgb, colormap=colormap, contrast_limits=contrast_limits, gamma=gamma, interpolation2d=interpolation2d, interpolation3d=interpolation3d, rendering=rendering, depiction=depiction, iso_threshold=iso_threshold, attenuation=attenuation, name=name, metadata=metadata, scale=scale, translate=translate, rotate=rotate, shear=shear, affine=affine, opacity=opacity, blending=blending, visible=visible, multiscale=multiscale, cache=cache, plane=plane, experimental_clipping_planes=experimental_clipping_planes, title=title, ndisplay=ndisplay, order=order, axis_labels=axis_labels, show=show, ) napari-0.5.0a1/napari/viewer.py000066400000000000000000000140421437041365600163470ustar00rootroot00000000000000import sys import typing from typing import TYPE_CHECKING, Optional from weakref import WeakSet import magicgui as mgui from napari.components.viewer_model import ViewerModel from napari.utils import _magicgui, config if TYPE_CHECKING: # helpful for IDE support from napari._qt.qt_main_window import Window @mgui.register_type(bind=_magicgui.proxy_viewer_ancestor) class Viewer(ViewerModel): """Napari ndarray viewer. Parameters ---------- title : string, optional The title of the viewer window. By default 'napari'. ndisplay : {2, 3}, optional Number of displayed dimensions. By default 2. order : tuple of int, optional Order in which dimensions are displayed where the last two or last three dimensions correspond to row x column or plane x row x column if ndisplay is 2 or 3. By default None axis_labels : list of str, optional Dimension names. By default they are labeled with sequential numbers show : bool, optional Whether to show the viewer after instantiation. By default True. """ _window: 'Window' = None # type: ignore if sys.version_info < (3, 9): _instances: typing.ClassVar[WeakSet] = WeakSet() else: _instances: typing.ClassVar[WeakSet['Viewer']] = WeakSet() def __init__( self, *, title='napari', ndisplay=2, order=(), axis_labels=(), show=True, ) -> None: super().__init__( title=title, ndisplay=ndisplay, order=order, axis_labels=axis_labels, ) # we delay initialization of plugin system to the first instantiation # of a viewer... rather than just on import of plugins module from napari.plugins import _initialize_plugins # having this import here makes all of Qt imported lazily, upon # instantiating the first Viewer. from napari.window import Window _initialize_plugins() self._window = Window(self, show=show) self._instances.add(self) # Expose private window publically. This is needed to keep window off pydantic model @property def window(self) -> 'Window': return self._window def update_console(self, variables): """Update console's namespace with desired variables. Parameters ---------- variables : dict, str or list/tuple of str The variables to inject into the console's namespace. If a dict, a simple update is done. If a str, the string is assumed to have variable names separated by spaces. A list/tuple of str can also be used to give the variable names. If just the variable names are give (list/tuple/str) then the variable values looked up in the callers frame. """ if self.window._qt_viewer._console is None: return else: self.window._qt_viewer.console.push(variables) def screenshot( self, path=None, *, size=None, scale=None, canvas_only=True, flash: bool = True, ): """Take currently displayed screen and convert to an image array. Parameters ---------- path : str Filename for saving screenshot image. size : tuple (int, int) Size (resolution) of the screenshot. By default, the currently displayed size. Only used if `canvas_only` is True. scale : float Scale factor used to increase resolution of canvas for the screenshot. By default, the currently displayed resolution. Only used if `canvas_only` is True. canvas_only : bool If True, screenshot shows only the image display canvas, and if False include the napari viewer frame in the screenshot, By default, True. flash : bool Flag to indicate whether flash animation should be shown after the screenshot was captured. By default, True. Returns ------- image : array Numpy array of type ubyte and shape (h, w, 4). Index [0, 0] is the upper-left corner of the rendered region. """ return self.window.screenshot( path=path, size=size, scale=scale, flash=flash, canvas_only=canvas_only, ) def show(self, *, block=False): """Resize, show, and raise the viewer window.""" self.window.show(block=block) def close(self): """Close the viewer window.""" # Remove all the layers from the viewer self.layers.clear() # Close the main window self.window.close() if config.async_loading: from napari.components.experimental.chunk import chunk_loader # TODO_ASYNC: Find a cleaner way to do this? This fixes some # tests. We are telling the ChunkLoader that this layer is # going away: # https://github.com/napari/napari/issues/1500 for layer in self.layers: chunk_loader.on_layer_deleted(layer) self._instances.discard(self) @classmethod def close_all(cls) -> int: """ Class metod, Close all existing viewer instances. This is mostly exposed to avoid leaking of viewers when running tests. As having many non-closed viewer can adversely affect performances. It will return the number of viewer closed. Returns ------- int number of viewer closed. """ # copy to not iterate while changing. viewers = [v for v in cls._instances] ret = len(viewers) for viewer in viewers: viewer.close() return ret def current_viewer() -> Optional[Viewer]: """Return the currently active napari viewer.""" try: from napari._qt.qt_main_window import _QtMainWindow return _QtMainWindow.current_viewer() except ImportError: return None napari-0.5.0a1/napari/window.py000066400000000000000000000015761437041365600163650ustar00rootroot00000000000000"""The Window class is the primary entry to the napari GUI. Currently, this module is just a stub file that will simply pass through the :class:`napari._qt.qt_main_window.Window` class. In the future, this module could serve to define a window Protocol that a backend would need to implement to server as a graphical user interface for napari. """ __all__ = ['Window'] from napari.utils.translations import trans try: from napari._qt import Window except ImportError as e: err = e class Window: # type: ignore def __init__(self, *args, **kwargs) -> None: pass def close(self): pass def __getattr__(self, name): raise type(err)( trans._( "An error occured when importing Qt dependencies. Cannot show napari window. See cause above", ) ) from err napari-0.5.0a1/napari_builtins/000077500000000000000000000000001437041365600164045ustar00rootroot00000000000000napari-0.5.0a1/napari_builtins/__init__.py000066400000000000000000000002401437041365600205110ustar00rootroot00000000000000from importlib.metadata import PackageNotFoundError, version try: __version__ = version("napari") except PackageNotFoundError: __version__ = 'unknown' napari-0.5.0a1/napari_builtins/_skimage_data.py000066400000000000000000000034521437041365600215320ustar00rootroot00000000000000from functools import partial def _load_skimage_data(name, **kwargs): import skimage.data if name == 'cells3d': return [ ( skimage.data.cells3d(), { 'channel_axis': 1, 'name': ['membrane', 'nuclei'], 'contrast_limits': [(1110, 23855), (1600, 50000)], }, ) ] elif name == 'kidney': return [ ( skimage.data.kidney(), { 'channel_axis': -1, 'name': ['nuclei', 'WGA', 'actin'], 'colormap': ['blue', 'green', 'red'], }, ) ] elif name == 'lily': return [ ( skimage.data.lily(), { 'channel_axis': -1, 'name': ['lily-R', 'lily-G', 'lily-W', 'lily-B'], 'colormap': ['red', 'green', 'gray', 'blue'], }, ) ] elif name == 'binary_blobs_3D': kwargs['n_dim'] = 3 kwargs.setdefault('length', 128) kwargs.setdefault('volume_fraction', 0.25) name = 'binary_blobs' return [(getattr(skimage.data, name)(**kwargs), {'name': name})] # fmt: off SKIMAGE_DATA = [ 'astronaut', 'binary_blobs', 'binary_blobs_3D', 'brain', 'brick', 'camera', 'cat', 'cell', 'cells3d', 'checkerboard', 'clock', 'coffee', 'coins', 'colorwheel', 'eagle', 'grass', 'gravel', 'horse', 'hubble_deep_field', 'human_mitosis', 'immunohistochemistry', 'kidney', 'lfw_subset', 'lily', 'microaneurysms', 'moon', 'page', 'retina', 'rocket', 'shepp_logan_phantom', 'skin', 'text', ] globals().update({key: partial(_load_skimage_data, key) for key in SKIMAGE_DATA}) napari-0.5.0a1/napari_builtins/_tests/000077500000000000000000000000001437041365600177055ustar00rootroot00000000000000napari-0.5.0a1/napari_builtins/_tests/conftest.py000066400000000000000000000027761437041365600221200ustar00rootroot00000000000000from pathlib import Path from typing import List from unittest.mock import patch import numpy as np import pytest from npe2 import DynamicPlugin, PluginManager, PluginManifest import napari_builtins from napari import layers @pytest.fixture(autouse=True) def _mock_npe2_pm(): """Mock plugin manager with no registered plugins.""" with patch.object(PluginManager, 'discover'): _pm = PluginManager() with patch('npe2.PluginManager.instance', return_value=_pm): yield _pm @pytest.fixture(autouse=True) def _use_builtins(_mock_npe2_pm: PluginManager): plugin = DynamicPlugin('napari', plugin_manager=_mock_npe2_pm) mf = PluginManifest.from_file( Path(napari_builtins.__file__).parent / 'builtins.yaml' ) plugin.manifest = mf with plugin: yield plugin LAYERS: List[layers.Layer] = [ layers.Image(np.random.rand(10, 10)), layers.Labels(np.random.randint(0, 16000, (32, 32), 'uint64')), layers.Points(np.random.rand(20, 2)), layers.Points( np.random.rand(20, 2), properties={'values': np.random.rand(20)} ), layers.Shapes( [ [(0, 0), (1, 1)], [(5, 7), (10, 10)], [(1, 3), (2, 4), (3, 5), (4, 6), (5, 7), (6, 8)], [(4, 3), (5, -4), (6.1, 5), (7, 6.5), (8, 7), (9, 8)], [(5.4, 6.7), (1.2, -3)], ], shape_type=['ellipse', 'line', 'path', 'polygon', 'rectangle'], ), ] @pytest.fixture(params=LAYERS) def some_layer(request): return request.param napari-0.5.0a1/napari_builtins/_tests/test_io.py000066400000000000000000000273251437041365600217360ustar00rootroot00000000000000import csv import os from pathlib import Path from tempfile import TemporaryDirectory from typing import NamedTuple, Tuple from uuid import uuid4 import dask.array as da import imageio import npe2 import numpy as np import pytest import tifffile import zarr from napari_builtins.io._read import ( _guess_layer_type_from_column_names, _guess_zarr_path, csv_to_layer_data, magic_imread, read_csv, ) from napari_builtins.io._write import write_csv class ImageSpec(NamedTuple): shape: Tuple[int, ...] dtype: str ext: str levels: int = 1 PNG = ImageSpec((10, 10), 'uint8', '.png') PNG_RGB = ImageSpec((10, 10, 3), 'uint8', '.png') PNG_RECT = ImageSpec((10, 15), 'uint8', '.png') TIFF_2D = ImageSpec((15, 10), 'uint8', '.tif') TIFF_3D = ImageSpec((2, 15, 10), 'uint8', '.tif') ZARR1 = ImageSpec((10, 20, 20), 'uint8', '.zarr') @pytest.fixture def _write_spec(tmp_path: Path): def writer(spec: ImageSpec): image = np.random.random(spec.shape).astype(spec.dtype) fname = tmp_path / f'{uuid4()}{spec.ext}' if spec.ext == '.tif': tifffile.imwrite(str(fname), image) elif spec.ext == '.zarr': fname.mkdir() z = zarr.open(str(fname), 'a', shape=image.shape) z[:] = image else: imageio.imwrite(str(fname), image) return fname return writer def test_no_files_raises(tmp_path): with pytest.raises(ValueError) as e: magic_imread(tmp_path) assert "No files found in" in str(e.value) def test_guess_zarr_path(): assert _guess_zarr_path('dataset.zarr') assert _guess_zarr_path('dataset.zarr/some/long/path') assert not _guess_zarr_path('data.tif') assert not _guess_zarr_path('no_zarr_suffix/data.png') def test_zarr(): image = np.random.random((10, 20, 20)) with TemporaryDirectory(suffix='.zarr') as fout: z = zarr.open(fout, 'a', shape=image.shape) z[:] = image image_in = magic_imread([fout]) # Note: due to lazy loading, the next line needs to happen within # the context manager. Alternatively, we could convert to NumPy here. np.testing.assert_array_equal(image, image_in) def test_zarr_nested(tmp_path): image = np.random.random((10, 20, 20)) image_name = 'my_image' root_path = tmp_path / 'dataset.zarr' grp = zarr.open(str(root_path), mode='a') grp.create_dataset(image_name, data=image) image_in = magic_imread([str(root_path / image_name)]) np.testing.assert_array_equal(image, image_in) def test_zarr_multiscale(): multiscale = [ np.random.random((20, 20)), np.random.random((10, 10)), np.random.random((5, 5)), ] with TemporaryDirectory(suffix='.zarr') as fout: root = zarr.open_group(fout, 'a') for i in range(len(multiscale)): shape = 20 // 2**i z = root.create_dataset(str(i), shape=(shape,) * 2) z[:] = multiscale[i] multiscale_in = magic_imread([fout]) assert len(multiscale) == len(multiscale_in) # Note: due to lazy loading, the next line needs to happen within # the context manager. Alternatively, we could convert to NumPy here. for images, images_in in zip(multiscale, multiscale_in): np.testing.assert_array_equal(images, images_in) def test_write_csv(tmpdir): expected_filename = os.path.join(tmpdir, 'test.csv') column_names = ['column_1', 'column_2', 'column_3'] expected_data = np.random.random((5, len(column_names))) # Write csv file write_csv(expected_filename, expected_data, column_names=column_names) assert os.path.exists(expected_filename) # Check csv file is as expected with open(expected_filename) as output_csv: csv.reader(output_csv, delimiter=',') for row_index, row in enumerate(output_csv): if row_index == 0: assert row == "column_1,column_2,column_3\n" else: output_row_data = [float(i) for i in row.split(',')] np.testing.assert_allclose( np.array(output_row_data), expected_data[row_index - 1] ) def test_read_csv(tmpdir): expected_filename = os.path.join(tmpdir, 'test.csv') column_names = ['column_1', 'column_2', 'column_3'] expected_data = np.random.random((5, len(column_names))) # Write csv file write_csv(expected_filename, expected_data, column_names=column_names) assert os.path.exists(expected_filename) # Read csv file read_data, read_column_names, _ = read_csv(expected_filename) read_data = np.array(read_data).astype('float') np.testing.assert_allclose(expected_data, read_data) assert column_names == read_column_names def test_guess_layer_type_from_column_names(): points_names = ['index', 'axis-0', 'axis-1'] assert _guess_layer_type_from_column_names(points_names) == 'points' shapes_names = ['index', 'shape-type', 'vertex-index', 'axis-0', 'axis-1'] assert _guess_layer_type_from_column_names(shapes_names) == 'shapes' also_points_names = ['no-index', 'axis-0', 'axis-1'] assert _guess_layer_type_from_column_names(also_points_names) == 'points' bad_names = ['no-index', 'no-axis-0', 'axis-1'] assert _guess_layer_type_from_column_names(bad_names) is None def test_read_csv_raises(tmp_path): """Test various exception raising circumstances with read_csv.""" temp = tmp_path / 'points.csv' # test that points data is detected with require_type = None, any, points # but raises for other shape types. data = [['index', 'axis-0', 'axis-1']] data.extend(np.random.random((3, 3)).tolist()) with open(temp, mode='w', newline='') as csvfile: csv.writer(csvfile).writerows(data) assert read_csv(temp, require_type=None)[2] == 'points' assert read_csv(temp, require_type='any')[2] == 'points' assert read_csv(temp, require_type='points')[2] == 'points' with pytest.raises(ValueError): read_csv(temp, require_type='shapes') # test that unrecognized data is detected with require_type = None # but raises for specific shape types or "any" data = [['some', 'random', 'header']] data.extend(np.random.random((3, 3)).tolist()) with open(temp, mode='w', newline='') as csvfile: csv.writer(csvfile).writerows(data) assert read_csv(temp, require_type=None)[2] is None with pytest.raises(ValueError): assert read_csv(temp, require_type='any') with pytest.raises(ValueError): assert read_csv(temp, require_type='points') with pytest.raises(ValueError): read_csv(temp, require_type='shapes') def test_csv_to_layer_data_raises(tmp_path): """Test various exception raising circumstances with csv_to_layer_data.""" temp = tmp_path / 'points.csv' # test that points data is detected with require_type == points, any, None # but raises for other shape types. data = [['index', 'axis-0', 'axis-1']] data.extend(np.random.random((3, 3)).tolist()) with open(temp, mode='w', newline='') as csvfile: csv.writer(csvfile).writerows(data) assert csv_to_layer_data(temp, require_type=None)[2] == 'points' assert csv_to_layer_data(temp, require_type='any')[2] == 'points' assert csv_to_layer_data(temp, require_type='points')[2] == 'points' with pytest.raises(ValueError): csv_to_layer_data(temp, require_type='shapes') # test that unrecognized data simply returns None when require_type==None # but raises for specific shape types or require_type=="any" data = [['some', 'random', 'header']] data.extend(np.random.random((3, 3)).tolist()) with open(temp, mode='w', newline='') as csvfile: csv.writer(csvfile).writerows(data) assert csv_to_layer_data(temp, require_type=None) is None with pytest.raises(ValueError): assert csv_to_layer_data(temp, require_type='any') with pytest.raises(ValueError): assert csv_to_layer_data(temp, require_type='points') with pytest.raises(ValueError): csv_to_layer_data(temp, require_type='shapes') @pytest.mark.parametrize('spec', [PNG, PNG_RGB, TIFF_3D, TIFF_2D]) @pytest.mark.parametrize('stacks', [1, 3]) def test_single_file(spec: ImageSpec, _write_spec, stacks: int): fnames = [str(_write_spec(spec)) for _ in range(stacks)] [(layer_data,)] = npe2.read(fnames, stack=stacks > 1) assert isinstance(layer_data, np.ndarray if stacks == 1 else da.Array) assert layer_data.shape == tuple( i for i in (stacks,) + spec.shape if i > 1 ) assert layer_data.dtype == spec.dtype @pytest.mark.parametrize( 'spec', [PNG, [PNG], [PNG, PNG], TIFF_3D, [TIFF_3D, TIFF_3D]] ) @pytest.mark.parametrize('stack', [True, False]) @pytest.mark.parametrize('use_dask', [True, False, None]) def test_magic_imread(_write_spec, spec: ImageSpec, stack, use_dask): fnames = ( [_write_spec(s) for s in spec] if isinstance(spec, list) else _write_spec(spec) ) images = magic_imread(fnames, stack=stack, use_dask=use_dask) if isinstance(spec, ImageSpec): expect_shape = spec.shape else: expect_shape = (len(spec),) + spec[0].shape if stack else spec[0].shape expect_shape = tuple(i for i in expect_shape if i > 1) expected_arr_type = ( da.Array if ( use_dask or (use_dask is None and isinstance(spec, list) and len(spec) > 1) ) else np.ndarray ) if isinstance(spec, list) and len(spec) > 1 and not stack: assert isinstance(images, list) assert all(isinstance(img, expected_arr_type) for img in images) assert all(img.shape == expect_shape for img in images) else: assert isinstance(images, expected_arr_type) assert images.shape == expect_shape @pytest.mark.parametrize('stack', [True, False]) def test_irregular_images(_write_spec, stack): specs = [PNG, PNG_RECT] fnames = [str(_write_spec(spec)) for spec in specs] # Ideally, this would work "magically" with dask and irregular images, # but there is no foolproof way to do this without reading in all the # files. We need to be able to inspect the file shape without reading # it in first, then we can automatically turn stacking off when shapes # are irregular (and create proper dask arrays) if stack: with pytest.raises( ValueError, match='input arrays must have the same shape' ): magic_imread(fnames, use_dask=False, stack=stack) return else: images = magic_imread(fnames, use_dask=False, stack=stack) assert isinstance(images, list) assert len(images) == 2 assert all(img.shape == spec.shape for img, spec in zip(images, specs)) def test_add_zarr(_write_spec): [out] = npe2.read([str(_write_spec(ZARR1))], stack=False) assert out[0].shape == ZARR1.shape # type: ignore def test_add_zarr_1d_array_is_ignored(): # For more details: https://github.com/napari/napari/issues/1471 with TemporaryDirectory(suffix='.zarr') as zarr_dir: z = zarr.open(zarr_dir, 'w') z['1d'] = np.zeros(3) image_path = os.path.join(zarr_dir, '1d') assert npe2.read([image_path], stack=False) == [(None,)] def test_add_many_zarr_1d_array_is_ignored(): # For more details: https://github.com/napari/napari/issues/1471 with TemporaryDirectory(suffix='.zarr') as zarr_dir: z = zarr.open(zarr_dir, 'w') z['1d'] = np.zeros(3) z['2d'] = np.zeros((3, 4)) z['3d'] = np.zeros((3, 4, 5)) for name in z.array_keys(): [out] = npe2.read([os.path.join(zarr_dir, name)], stack=False) if name == '1d': assert out == (None,) else: assert isinstance(out[0], da.Array) assert out[0].ndim == int(name[0]) napari-0.5.0a1/napari_builtins/_tests/test_reader.py000066400000000000000000000037471437041365600225730ustar00rootroot00000000000000from pathlib import Path from typing import Callable, Optional import imageio import npe2 import numpy as np import pytest import tifffile from napari_builtins.io._write import write_csv @pytest.fixture def save_image(tmp_path: Path): """Create a temporary file.""" def _save(filename: str, data: Optional[np.ndarray] = None): dest = tmp_path / filename _data: np.ndarray = np.random.rand(20, 20) if data is None else data if dest.suffix in {".tif", ".tiff"}: tifffile.imwrite(str(dest), _data) elif dest.suffix in {'.npy'}: np.save(str(dest), _data) else: imageio.imsave(str(dest), _data) return dest return _save @pytest.mark.parametrize('ext', ['.tif', '.npy', '.png', '.jpg']) @pytest.mark.parametrize('stack', [False, True]) def test_reader_plugin_tif(save_image: Callable[..., Path], ext, stack): """Test the builtin reader plugin reads a temporary file.""" files = [ str(save_image(f'test_{i}{ext}')) for i in range(5 if stack else 1) ] layer_data = npe2.read(files, stack=stack) assert isinstance(layer_data, list) assert len(layer_data) == 1 assert isinstance(layer_data[0], tuple) def test_reader_plugin_url(): layer_data = npe2.read( ['https://samples.fiji.sc/FakeTracks.tif'], stack=False ) assert isinstance(layer_data, list) assert len(layer_data) == 1 assert isinstance(layer_data[0], tuple) def test_reader_plugin_csv(tmp_path): """Test the builtin reader plugin reads a temporary file.""" dest = str(tmp_path / 'test.csv') table = np.random.random((5, 3)) write_csv(dest, table, column_names=['index', 'axis-0', 'axis-1']) layer_data = npe2.read([dest], stack=False) assert layer_data is not None assert isinstance(layer_data, list) assert len(layer_data) == 1 assert isinstance(layer_data[0], tuple) assert layer_data[0][2] == 'points' assert np.allclose(table[:, 1:], layer_data[0][0]) napari-0.5.0a1/napari_builtins/_tests/test_writer.py000066400000000000000000000043461437041365600226410ustar00rootroot00000000000000from pathlib import Path from typing import TYPE_CHECKING import npe2 import numpy as np import pytest from conftest import LAYERS from napari_builtins.io import napari_get_reader if TYPE_CHECKING: from napari import layers _EXTENSION_MAP = { 'image': '.tif', 'labels': '.tif', 'points': '.csv', 'shapes': '.csv', } @pytest.mark.parametrize('use_ext', [True, False]) def test_layer_save(tmp_path: Path, some_layer: 'layers.Layer', use_ext: bool): """Test saving layer data.""" ext = _EXTENSION_MAP[some_layer._type_string] path_with_ext = tmp_path / f'layer_file{ext}' path_no_ext = tmp_path / 'layer_file' assert not path_with_ext.is_file() assert some_layer.save(str(path_with_ext if use_ext else path_no_ext)) assert path_with_ext.is_file() # Read data back in reader = napari_get_reader(str(path_with_ext)) assert callable(reader) [(read_data, *rest)] = reader(str(path_with_ext)) if isinstance(some_layer.data, list): for d in zip(read_data, some_layer.data): np.testing.assert_allclose(*d) else: np.testing.assert_allclose(read_data, some_layer.data) if rest: meta, type_string = rest assert type_string == some_layer._type_string for key, value in meta.items(): # type: ignore np.testing.assert_equal(value, getattr(some_layer, key)) # the layer_writer_and_data fixture is defined in napari/conftest.py def test_no_write_layer_bad_extension(some_layer: 'layers.Layer'): """Test not writing layer data with a bad extension.""" with pytest.warns(UserWarning, match='No data written!'): assert not some_layer.save('layer.bad_extension') # test_plugin_manager fixture is provided by napari_plugin_engine._testsupport def test_get_writer_succeeds(tmp_path: Path): """Test writing layers data.""" path = tmp_path / 'layers_folder' written = npe2.write(path=str(path), layer_data=LAYERS) # type: ignore # check expected files were written expected = { str(path / f'{layer.name}{_EXTENSION_MAP[layer._type_string]}') for layer in LAYERS } assert path.is_dir() assert set(written) == expected for expect in expected: assert Path(expect).is_file() napari-0.5.0a1/napari_builtins/builtins.yaml000066400000000000000000000251531437041365600211270ustar00rootroot00000000000000display_name: napari builtins name: napari contributions: commands: - id: napari.get_reader python_name: napari_builtins.io:napari_get_reader title: Builtin Reader - id: napari.write_image python_name: napari_builtins.io:napari_write_image title: napari built-in image writer - id: napari.write_labels python_name: napari_builtins.io:napari_write_labels title: napari built-in label field writer - id: napari.write_points python_name: napari_builtins.io:napari_write_points title: napari built-in points writer - id: napari.write_shapes python_name: napari_builtins.io:napari_write_shapes title: napari built-in shapes writer - id: napari.write_directory python_name: napari_builtins.io:write_layer_data_with_plugins title: napari built-in save to folder # samples - id: napari.data.astronaut title: Generate astronaut sample python_name: napari_builtins._skimage_data:astronaut - id: napari.data.binary_blobs title: Generate binary_blobs sample python_name: napari_builtins._skimage_data:binary_blobs - id: napari.data.binary_blobs_3D title: Generate binary_blobs_3D sample python_name: napari_builtins._skimage_data:binary_blobs_3D - id: napari.data.brain title: Generate brain sample python_name: napari_builtins._skimage_data:brain - id: napari.data.brick title: Generate brick sample python_name: napari_builtins._skimage_data:brick - id: napari.data.camera title: Generate camera sample python_name: napari_builtins._skimage_data:camera - id: napari.data.cat title: Generate cat sample python_name: napari_builtins._skimage_data:cat - id: napari.data.cell title: Generate cell sample python_name: napari_builtins._skimage_data:cell - id: napari.data.cells3d title: Generate cells3d sample python_name: napari_builtins._skimage_data:cells3d - id: napari.data.checkerboard title: Generate checkerboard sample python_name: napari_builtins._skimage_data:checkerboard - id: napari.data.clock title: Generate clock sample python_name: napari_builtins._skimage_data:clock - id: napari.data.coffee title: Generate coffee sample python_name: napari_builtins._skimage_data:coffee - id: napari.data.coins title: Generate coins sample python_name: napari_builtins._skimage_data:coins - id: napari.data.colorwheel title: Generate colorwheel sample python_name: napari_builtins._skimage_data:colorwheel - id: napari.data.eagle title: Generate eagle sample python_name: napari_builtins._skimage_data:eagle - id: napari.data.grass title: Generate grass sample python_name: napari_builtins._skimage_data:grass - id: napari.data.gravel title: Generate gravel sample python_name: napari_builtins._skimage_data:gravel - id: napari.data.horse title: Generate horse sample python_name: napari_builtins._skimage_data:horse - id: napari.data.hubble_deep_field title: Generate hubble_deep_field sample python_name: napari_builtins._skimage_data:hubble_deep_field - id: napari.data.human_mitosis title: Generate human_mitosis sample python_name: napari_builtins._skimage_data:human_mitosis - id: napari.data.immunohistochemistry title: Generate immunohistochemistry sample python_name: napari_builtins._skimage_data:immunohistochemistry - id: napari.data.kidney title: Generate kidney sample python_name: napari_builtins._skimage_data:kidney - id: napari.data.lfw_subset title: Generate lfw_subset sample python_name: napari_builtins._skimage_data:lfw_subset - id: napari.data.lily title: Generate lily sample python_name: napari_builtins._skimage_data:lily - id: napari.data.microaneurysms title: Generate microaneurysms sample python_name: napari_builtins._skimage_data:microaneurysms - id: napari.data.moon title: Generate moon sample python_name: napari_builtins._skimage_data:moon - id: napari.data.page title: Generate page sample python_name: napari_builtins._skimage_data:page - id: napari.data.retina title: Generate retina sample python_name: napari_builtins._skimage_data:retina - id: napari.data.rocket title: Generate rocket sample python_name: napari_builtins._skimage_data:rocket - id: napari.data.shepp_logan_phantom title: Generate shepp_logan_phantom sample python_name: napari_builtins._skimage_data:shepp_logan_phantom - id: napari.data.skin title: Generate skin sample python_name: napari_builtins._skimage_data:skin - id: napari.data.text title: Generate text sample python_name: napari_builtins._skimage_data:text readers: - command: napari.get_reader accepts_directories: true filename_patterns: [ "*.3fr", "*.arw", "*.avi", "*.bay", "*.bmp", "*.bmq", "*.bsdf", "*.bufr", "*.bw", "*.cap", "*.cine", "*.cr2", "*.crw", "*.cs1", "*.csv", "*.ct", "*.cur", "*.cut", "*.dc2", "*.dcm", "*.dcr", "*.dcx", "*.dds", "*.dicom", "*.dng", "*.drf", "*.dsc", "*.ecw", "*.emf", "*.eps", "*.erf", "*.exr", "*.fff", "*.fit", "*.fits", "*.flc", "*.fli", "*.fpx", "*.ftc", "*.fts", "*.ftu", "*.fz", "*.g3", "*.gbr", "*.gdcm", "*.gif", "*.gipl", "*.grib", "*.h5", "*.hdf", "*.hdf5", "*.hdp", "*.hdr", "*.ia", "*.icns", "*.ico", "*.iff", "*.iim", "*.iiq", "*.im", "*.img.gz", "*.img", "*.ipl", "*.j2c", "*.j2k", "*.jfif", "*.jif", "*.jng", "*.jp2", "*.jpc", "*.jpe", "*.jpeg", "*.jpf", "*.jpg", "*.jpx", "*.jxr", "*.k25", "*.kc2", "*.kdc", "*.koa", "*.lbm", "*.lfp", "*.lfr", "*.lsm", "*.mdc", "*.mef", "*.mgh", "*.mha", "*.mhd", "*.mic", "*.mkv", "*.mnc", "*.mnc2", "*.mos", "*.mov", "*.mp4", "*.mpeg", "*.mpg", "*.mpo", "*.mri", "*.mrw", "*.msp", "*.nef", "*.nhdr", "*.nia", "*.nii.gz", "*.nii", "*.npy", "*.npz", "*.nrrd", "*.nrw", "*.orf", "*.pbm", "*.pcd", "*.pct", "*.pcx", "*.pef", "*.pfm", "*.pgm", "*.pic", "*.pict", "*.png", "*.ppm", "*.ps", "*.psd", "*.ptx", "*.pxn", "*.pxr", "*.qtk", "*.raf", "*.ras", "*.raw", "*.rdc", "*.rgb", "*.rgba", "*.rw2", "*.rwl", "*.rwz", "*.sgi", "*.spe", "*.sr2", "*.srf", "*.srw", "*.sti", "*.stk", "*.swf", "*.targa", "*.tga", "*.tif", "*.tiff", "*.vtk", "*.wap", "*.wbm", "*.wbmp", "*.wdp", "*.webm", "*.webp", "*.wmf", "*.wmv", "*.xbm", "*.xpm", "*.zarr", ] writers: - command: napari.write_image display_name: lossless layer_types: ["image"] filename_extensions: [ ".tif", ".tiff", ".png", ".bmp", ".bsdf", ".bw", ".eps", ".gif", ".icns", ".ico", ".im", ".lsm", ".npz", ".pbm", ".pcx", ".pgm", ".ppm", ".ps", ".rgb", ".rgba", ".sgi", ".stk", ".tga", ] - command: napari.write_image display_name: lossy layer_types: ["image"] filename_extensions: [ ".jpg", ".jpeg", ".j2c", ".j2k", ".jfif", ".jp2", ".jpc", ".jpe", ".jpf", ".jpx", ".mpo", ] - command: napari.write_labels display_name: labels layer_types: ["labels"] filename_extensions: [ ".tif", ".tiff", ".bsdf", ".im", ".lsm", ".npz", ".pbm", ".pcx", ".pgm", ".ppm", ".stk", ] - command: napari.write_points display_name: points layer_types: ["points"] filename_extensions: [".csv"] - command: napari.write_shapes display_name: shapes layer_types: ["shapes"] filename_extensions: [".csv"] - command: napari.write_directory display_name: Save to Folder layer_types: ["image*", "labels*", "points*", "shapes*"] sample_data: - display_name: Astronaut (RGB) key: astronaut command: napari.data.astronaut - display_name: Binary Blobs key: binary_blobs command: napari.data.binary_blobs - display_name: Binary Blobs (3D) key: binary_blobs_3D command: napari.data.binary_blobs_3D - display_name: Brain (3D) key: brain command: napari.data.brain - display_name: Brick key: brick command: napari.data.brick - display_name: Camera key: camera command: napari.data.camera - display_name: Cat (RGB) key: cat command: napari.data.cat - display_name: Cell key: cell command: napari.data.cell - display_name: Cells (3D+2Ch) key: cells3d command: napari.data.cells3d - display_name: Checkerboard key: checkerboard command: napari.data.checkerboard - display_name: Clock key: clock command: napari.data.clock - display_name: Coffee (RGB) key: coffee command: napari.data.coffee - display_name: Coins key: coins command: napari.data.coins - display_name: Colorwheel (RGB) key: colorwheel command: napari.data.colorwheel - display_name: Eagle key: eagle command: napari.data.eagle - display_name: Grass key: grass command: napari.data.grass - display_name: Gravel key: gravel command: napari.data.gravel - display_name: Horse key: horse command: napari.data.horse - display_name: Hubble Deep Field (RGB) key: hubble_deep_field command: napari.data.hubble_deep_field - display_name: Human Mitosis key: human_mitosis command: napari.data.human_mitosis - display_name: Immunohistochemistry (RGB) key: immunohistochemistry command: napari.data.immunohistochemistry - display_name: Kidney (3D+3Ch) key: kidney command: napari.data.kidney - display_name: Labeled Faces in the Wild key: lfw_subset command: napari.data.lfw_subset - display_name: Lily (4Ch) key: lily command: napari.data.lily - display_name: Microaneurysms key: microaneurysms command: napari.data.microaneurysms - display_name: Moon key: moon command: napari.data.moon - display_name: Page key: page command: napari.data.page - display_name: Retina (RGB) key: retina command: napari.data.retina - display_name: Rocket (RGB) key: rocket command: napari.data.rocket - display_name: Shepp Logan Phantom key: shepp_logan_phantom command: napari.data.shepp_logan_phantom - display_name: Skin (RGB) key: skin command: napari.data.skin - display_name: Text key: text command: napari.data.text napari-0.5.0a1/napari_builtins/io/000077500000000000000000000000001437041365600170135ustar00rootroot00000000000000napari-0.5.0a1/napari_builtins/io/__init__.py000066400000000000000000000012661437041365600211310ustar00rootroot00000000000000from napari_builtins.io._read import ( csv_to_layer_data, imread, magic_imread, napari_get_reader, read_csv, read_zarr_dataset, ) from napari_builtins.io._write import ( imsave_extensions, napari_write_image, napari_write_labels, napari_write_points, napari_write_shapes, write_csv, write_layer_data_with_plugins, ) __all__ = [ 'csv_to_layer_data', 'imread', 'imsave_extensions', 'magic_imread', 'napari_get_reader', 'napari_write_image', 'napari_write_labels', 'napari_write_points', 'napari_write_shapes', 'read_csv', 'read_zarr_dataset', 'write_csv', 'write_layer_data_with_plugins', ] napari-0.5.0a1/napari_builtins/io/_read.py000066400000000000000000000401221437041365600204360ustar00rootroot00000000000000import csv import os import re import tempfile import urllib.parse from contextlib import contextmanager, suppress from glob import glob from pathlib import Path from typing import TYPE_CHECKING, List, Optional, Sequence, Tuple, Union from urllib.error import HTTPError, URLError import dask.array as da import numpy as np from dask import delayed from napari.utils.misc import abspath_or_url from napari.utils.translations import trans if TYPE_CHECKING: from napari.types import FullLayerData, LayerData, ReaderFunction try: import imageio.v2 as imageio except ModuleNotFoundError: import imageio # type: ignore IMAGEIO_EXTENSIONS = {x for f in imageio.formats for x in f.extensions} READER_EXTENSIONS = IMAGEIO_EXTENSIONS.union({'.zarr', '.lsm', '.npy'}) def _alphanumeric_key(s: str) -> List[Union[str, int]]: """Convert string to list of strings and ints that gives intuitive sorting.""" return [int(c) if c.isdigit() else c for c in re.split('([0-9]+)', s)] URL_REGEX = re.compile(r'https?://|ftps?://|file://|file:\\') def _is_url(filename): """Return True if string is an http or ftp path. Originally vendored from scikit-image/skimage/io/util.py """ return isinstance(filename, str) and URL_REGEX.match(filename) is not None @contextmanager def file_or_url_context(resource_name): """Yield name of file from the given resource (i.e. file or url). Originally vendored from scikit-image/skimage/io/util.py """ if _is_url(resource_name): url_components = urllib.parse.urlparse(resource_name) _, ext = os.path.splitext(url_components.path) try: with tempfile.NamedTemporaryFile(delete=False, suffix=ext) as f: u = urllib.request.urlopen(resource_name) f.write(u.read()) # f must be closed before yielding yield f.name except (URLError, HTTPError): # pragma: no cover # could not open URL os.remove(f.name) raise except BaseException: # pragma: no cover # could not create temporary file raise else: os.remove(f.name) else: yield resource_name def imread(filename: str) -> np.ndarray: """Custom implementation of imread to avoid skimage dependency. Parameters ---------- filename : string The path from which to read the image. Returns ------- data : np.ndarray The image data. """ filename = abspath_or_url(filename) ext = os.path.splitext(filename)[1] if ext.lower() in ('.npy',): return np.load(filename) if ext.lower() not in [".tif", ".tiff", ".lsm"]: return imageio.imread(filename) import tifffile # Pre-download urls before loading them with tifffile with file_or_url_context(filename) as filename: return tifffile.imread(str(filename)) def _guess_zarr_path(path: str) -> bool: """Guess whether string path is part of a zarr hierarchy.""" return any(part.endswith(".zarr") for part in Path(path).parts) def read_zarr_dataset(path): """Read a zarr dataset, including an array or a group of arrays. Parameters ---------- path : str Path to directory ending in '.zarr'. Path can contain either an array or a group of arrays in the case of multiscale data. Returns ------- image : array-like Array or list of arrays shape : tuple Shape of array or first array in list """ if os.path.exists(os.path.join(path, '.zarray')): # load zarr array image = da.from_zarr(path) shape = image.shape elif os.path.exists(os.path.join(path, '.zgroup')): # else load zarr all arrays inside file, useful for multiscale data image = [ read_zarr_dataset(os.path.join(path, subpath))[0] for subpath in sorted(os.listdir(path)) if not subpath.startswith('.') ] assert image, 'No arrays found in zarr group' shape = image[0].shape else: # pragma: no cover raise ValueError( trans._( "Not a zarr dataset or group: {path}", deferred=True, path=path ) ) return image, shape PathOrStr = Union[str, Path] def magic_imread( filenames: Union[PathOrStr, List[PathOrStr]], *, use_dask=None, stack=True ): """Dispatch the appropriate reader given some files. The files are assumed to all have the same shape. Parameters ---------- filenames : list List of filenames or directories to be opened. A list of `pathlib.Path` objects and a single filename or `Path` object are also accepted. use_dask : bool Whether to use dask to create a lazy array, rather than NumPy. Default of None will resolve to True if filenames contains more than one image, False otherwise. stack : bool Whether to stack the images in multiple files into a single array. If False, a list of arrays will be returned. Returns ------- image : array-like Array or list of images """ _filenames: List[str] = ( [str(x) for x in filenames] if isinstance(filenames, (list, tuple)) else [str(filenames)] ) if not _filenames: # pragma: no cover raise ValueError("No files found") # replace folders with their contents filenames_expanded: List[str] = [] for filename in _filenames: # zarr files are folders, but should be read as 1 file if ( os.path.isdir(filename) and not _guess_zarr_path(filename) and not _is_url(filename) ): dir_contents = sorted( glob(os.path.join(filename, '*.*')), key=_alphanumeric_key ) # remove subdirectories dir_contents_files = filter( lambda f: not os.path.isdir(f), dir_contents ) filenames_expanded.extend(dir_contents_files) else: filenames_expanded.append(filename) if use_dask is None: use_dask = len(filenames_expanded) > 1 if not filenames_expanded: raise ValueError( trans._( "No files found in {filenames} after removing subdirectories", deferred=True, filenames=filenames, ) ) # then, read in images images = [] shape = None for filename in filenames_expanded: if _guess_zarr_path(filename): image, zarr_shape = read_zarr_dataset(filename) # 1D images are currently unsupported, so skip them. if len(zarr_shape) == 1: continue if shape is None: shape = zarr_shape else: if shape is None: image = imread(filename) shape = image.shape dtype = image.dtype if use_dask: image = da.from_delayed( delayed(imread)(filename), shape=shape, dtype=dtype ) elif len(images) > 0: # not read by shape clause image = imread(filename) images.append(image) if not images: return None if len(images) == 1: image = images[0] elif stack: if use_dask: image = da.stack(images) else: try: image = np.stack(images) except ValueError as e: if 'input arrays must have the same shape' in str(e): msg = trans._( 'To stack multiple files into a single array with numpy, all input arrays must have the same shape. Set `use_dask` to True to stack arrays with different shapes.', deferred=True, ) raise ValueError(msg) from e raise # pragma: no cover else: image = images # return a list return image def _points_csv_to_layerdata( table: np.ndarray, column_names: List[str] ) -> "FullLayerData": """Convert table data and column names from a csv file to Points LayerData. Parameters ---------- table : np.ndarray CSV data. column_names : list of str The column names of the csv file Returns ------- layer_data : tuple 3-tuple ``(array, dict, str)`` (points data, metadata, 'points') """ data_axes = [cn.startswith('axis-') for cn in column_names] data = np.array(table[:, data_axes]).astype('float') # Add properties to metadata if provided prop_axes = np.logical_not(data_axes) if column_names[0] == 'index': prop_axes[0] = False meta: dict = {} if np.any(prop_axes): meta['properties'] = {} for ind in np.nonzero(prop_axes)[0]: values = table[:, ind] try: values = np.array(values).astype('int') except ValueError: with suppress(ValueError): values = np.array(values).astype('float') meta['properties'][column_names[ind]] = values return data, meta, 'points' def _shapes_csv_to_layerdata( table: np.ndarray, column_names: List[str] ) -> "FullLayerData": """Convert table data and column names from a csv file to Shapes LayerData. Parameters ---------- table : np.ndarray CSV data. column_names : list of str The column names of the csv file Returns ------- layer_data : tuple 3-tuple ``(array, dict, str)`` (points data, metadata, 'shapes') """ data_axes = [cn.startswith('axis-') for cn in column_names] raw_data = np.array(table[:, data_axes]).astype('float') inds = np.array(table[:, 0]).astype('int') n_shapes = max(inds) + 1 # Determine when shape id changes transitions = list((np.diff(inds)).nonzero()[0] + 1) shape_boundaries = [0] + transitions + [len(table)] if n_shapes != len(shape_boundaries) - 1: raise ValueError( trans._('Expected number of shapes not found', deferred=True) ) data = [] shape_type = [] for ind_a, ind_b in zip(shape_boundaries[:-1], shape_boundaries[1:]): data.append(raw_data[ind_a:ind_b]) shape_type.append(table[ind_a, 1]) return data, {'shape_type': shape_type}, 'shapes' def _guess_layer_type_from_column_names( column_names: List[str], ) -> Optional[str]: """Guess layer type based on column names from a csv file. Parameters ---------- column_names : list of str List of the column names from the csv. Returns ------- str or None Layer type if recognized, otherwise None. """ if {'index', 'shape-type', 'vertex-index', 'axis-0', 'axis-1'}.issubset( column_names ): return 'shapes' elif {'axis-0', 'axis-1'}.issubset(column_names): return 'points' else: return None def read_csv( filename: str, require_type: Optional[str] = None ) -> Tuple[np.ndarray, List[str], Optional[str]]: """Return CSV data only if column names match format for ``require_type``. Reads only the first line of the CSV at first, then optionally raises an exception if the column names are not consistent with a known format, as determined by the ``require_type`` argument and :func:`_guess_layer_type_from_column_names`. Parameters ---------- filename : str Path of file to open require_type : str, optional The desired layer type. If provided, should be one of the keys in ``csv_reader_functions`` or the string "any". If ``None``, data, will not impose any format requirements on the csv, and data will always be returned. If ``any``, csv must be recognized as one of the valid layer data formats, otherwise a ``ValueError`` will be raised. If a specific layer type string, then a ``ValueError`` will be raised if the column names are not of the predicted format. Returns ------- (data, column_names, layer_type) : Tuple[np.array, List[str], str] The table data and column names from the CSV file, along with the detected layer type (string). Raises ------ ValueError If the column names do not match the format requested by ``require_type``. """ with open(filename, newline='') as csvfile: reader = csv.reader(csvfile, delimiter=',') column_names = next(reader) layer_type = _guess_layer_type_from_column_names(column_names) if require_type: if not layer_type: raise ValueError( trans._( 'File "{filename}" not recognized as valid Layer data', deferred=True, filename=filename, ) ) elif layer_type != require_type and require_type.lower() != "any": raise ValueError( trans._( 'File "{filename}" not recognized as {require_type} data', deferred=True, filename=filename, require_type=require_type, ) ) data = np.array(list(reader)) return data, column_names, layer_type csv_reader_functions = { 'points': _points_csv_to_layerdata, 'shapes': _shapes_csv_to_layerdata, } def csv_to_layer_data( path: str, require_type: Optional[str] = None ) -> Optional["FullLayerData"]: """Return layer data from a CSV file if detected as a valid type. Parameters ---------- path : str Path of file to open require_type : str, optional The desired layer type. If provided, should be one of the keys in ``csv_reader_functions`` or the string "any". If ``None``, unrecognized CSV files will simply return ``None``. If ``any``, unrecognized CSV files will raise a ``ValueError``. If a specific layer type string, then a ``ValueError`` will be raised if the column names are not of the predicted format. Returns ------- layer_data : tuple, or None 3-tuple ``(array, dict, str)`` (points data, metadata, layer_type) if CSV is recognized as a valid type. Raises ------ ValueError If ``require_type`` is not ``None``, but the CSV is not detected as a valid data format. """ try: # pass at least require "any" here so that we don't bother reading the # full dataset if it's not going to yield valid layer_data. _require = require_type or 'any' table, column_names, _type = read_csv(path, require_type=_require) except ValueError: if not require_type: return None raise if _type in csv_reader_functions: return csv_reader_functions[_type](table, column_names) return None # only reachable if it is a valid layer type without a reader def _csv_reader(path: Union[str, Sequence[str]]) -> List["LayerData"]: if isinstance(path, str): layer_data = csv_to_layer_data(path, require_type=None) return [layer_data] if layer_data else [] return [ layer_data for p in path if (layer_data := csv_to_layer_data(p, require_type=None)) ] def _magic_imreader(path: str) -> List["LayerData"]: return [(magic_imread(path),)] def napari_get_reader( path: Union[str, List[str]] ) -> Optional["ReaderFunction"]: """Our internal fallback file reader at the end of the reader plugin chain. This will assume that the filepath is an image, and will pass all of the necessary information to viewer.add_image(). Parameters ---------- path : str path to file/directory Returns ------- callable function that returns layer_data to be handed to viewer._add_layer_data """ if isinstance(path, str): if path.endswith('.csv'): return _csv_reader if os.path.isdir(path): return _magic_imreader path = [path] if all(str(x).lower().endswith(tuple(READER_EXTENSIONS)) for x in path): return _magic_imreader return None # pragma: no cover napari-0.5.0a1/napari_builtins/io/_write.py000066400000000000000000000227221437041365600206630ustar00rootroot00000000000000import csv import os import shutil from tempfile import TemporaryDirectory from typing import TYPE_CHECKING, Any, List, Optional, Tuple, Union import numpy as np from napari.utils.io import imsave from napari.utils.misc import abspath_or_url if TYPE_CHECKING: from napari.types import FullLayerData def write_csv( filename: str, data: Union[List, np.ndarray], column_names: Optional[List[str]] = None, ): """Write a csv file. Parameters ---------- filename : str Filename for saving csv. data : list or ndarray Table values, contained in a list of lists or an ndarray. column_names : list, optional List of column names for table data. """ with open(filename, mode='w', newline='') as csvfile: writer = csv.writer( csvfile, delimiter=',', quotechar='"', quoting=csv.QUOTE_MINIMAL, ) if column_names is not None: writer.writerow(column_names) for row in data: writer.writerow(row) def imsave_extensions() -> Tuple[str, ...]: """Valid extensions of files that imsave can write to. Returns ------- tuple Valid extensions of files that imsave can write to. """ # import imageio # return tuple(set(x for f in imageio.formats for x in f.extensions)) # The above method generates a lot of extensions that will fail. This list # is a more realistic set, generated by trying to write a variety of numpy # arrays (skimage.data.camera, grass, and some random numpy arrays/shapes). # TODO: maybe write a proper imageio plugin. return ( '.bmp', '.bsdf', '.bw', '.eps', '.gif', '.icns', '.ico', '.im', '.j2c', '.j2k', '.jfif', '.jp2', '.jpc', '.jpe', '.jpeg', '.jpf', '.jpg', '.jpx', '.lsm', '.mpo', '.npz', '.pbm', '.pcx', '.pgm', '.png', '.ppm', '.ps', '.rgb', '.rgba', '.sgi', '.stk', '.tga', '.tif', '.tiff', ) def napari_write_image(path: str, data: Any, meta: dict) -> Optional[str]: """Our internal fallback image writer at the end of the plugin chain. Parameters ---------- path : str Path to file, directory, or resource (like a URL). data : array or list of array Image data. Can be N dimensional. If meta['rgb'] is ``True`` then the data should be interpreted as RGB or RGBA. If ``meta['multiscale']`` is ``True``, then the data should be interpreted as a multiscale image. meta : dict Image metadata. Returns ------- path : str or None If data is successfully written, return the ``path`` that was written. Otherwise, if nothing was done, return ``None``. """ ext = os.path.splitext(path)[1] if not ext: path += '.tif' ext = '.tif' if ext in imsave_extensions(): imsave(path, data) return path return None def napari_write_labels(path: str, data: Any, meta: dict) -> Optional[str]: """Our internal fallback labels writer at the end of the plugin chain. Parameters ---------- path : str Path to file, directory, or resource (like a URL). data : array or list of array Image data. Can be N dimensional. If meta['rgb'] is ``True`` then the data should be interpreted as RGB or RGBA. If ``meta['multiscale']`` is ``True``, then the data should be interpreted as a multiscale image. meta : dict Image metadata. Returns ------- path : str or None If data is successfully written, return the ``path`` that was written. Otherwise, if nothing was done, return ``None``. """ dtype = data.dtype if data.dtype.itemsize >= 4 else np.uint32 return napari_write_image(path, np.asarray(data, dtype=dtype), meta) def napari_write_points(path: str, data: Any, meta: dict) -> Optional[str]: """Our internal fallback points writer at the end of the plugin chain. Append ``.csv`` extension to the filename if it is not already there. Parameters ---------- path : str Path to file, directory, or resource (like a URL). data : array (N, D) Coordinates for N points in D dimensions. meta : dict Points metadata. Returns ------- path : str or None If data is successfully written, return the ``path`` that was written. Otherwise, if nothing was done, return ``None``. """ ext = os.path.splitext(path)[1] if ext == '': path += '.csv' elif ext != '.csv': # If an extension is provided then it must be `.csv` return None properties = meta.get('properties', {}) # TODO: we need to change this to the axis names once we get access to them # construct table from data column_names = [f'axis-{str(n)}' for n in range(data.shape[1])] if properties: column_names += properties.keys() prop_table = [ np.expand_dims(col, axis=1) for col in properties.values() ] else: prop_table = [] # add index of each point column_names = ['index'] + column_names indices = np.expand_dims(list(range(data.shape[0])), axis=1) table = np.concatenate([indices, data] + prop_table, axis=1) # write table to csv file write_csv(path, table, column_names) return path def napari_write_shapes(path: str, data: Any, meta: dict) -> Optional[str]: """Our internal fallback points writer at the end of the plugin chain. Append ``.csv`` extension to the filename if it is not already there. Parameters ---------- path : str Path to file, directory, or resource (like a URL). data : list of array (N, D) List of coordinates for shapes, each with for N vertices in D dimensions. meta : dict Points metadata. Returns ------- path : str or None If data is successfully written, return the ``path`` that was written. Otherwise, if nothing was done, return ``None``. """ ext = os.path.splitext(path)[1] if ext == '': path += '.csv' elif ext != '.csv': # If an extension is provided then it must be `.csv` return None shape_type = meta.get('shape_type', ['rectangle'] * len(data)) # No data passed so nothing written if len(data) == 0: return None # TODO: we need to change this to the axis names once we get access to them # construct table from data n_dimensions = max(s.shape[1] for s in data) column_names = [f'axis-{str(n)}' for n in range(n_dimensions)] # add shape id and vertex id of each vertex column_names = ['index', 'shape-type', 'vertex-index'] + column_names # concatenate shape data into 2D array len_shapes = [s.shape[0] for s in data] all_data = np.concatenate(data) all_idx = np.expand_dims( np.concatenate([np.repeat(i, s) for i, s in enumerate(len_shapes)]), axis=1, ) all_types = np.expand_dims( np.concatenate( [np.repeat(shape_type[i], s) for i, s in enumerate(len_shapes)] ), axis=1, ) all_vert_idx = np.expand_dims( np.concatenate([np.arange(s) for s in len_shapes]), axis=1 ) table = np.concatenate( [all_idx, all_types, all_vert_idx, all_data], axis=1 ) # write table to csv file write_csv(path, table, column_names) return path def write_layer_data_with_plugins( path: str, layer_data: List["FullLayerData"] ) -> List[str]: """Write layer data out into a folder one layer at a time. Call ``napari_write_`` for each layer using the ``layer.name`` variable to modify the path such that the layers are written to unique files in the folder. Parameters ---------- path : str path to file/directory layer_data : list of napari.types.LayerData List of layer_data, where layer_data is ``(data, meta, layer_type)``. Returns ------- list of str A list of any filepaths that were written. """ import npe2 # remember whether it was there to begin with already_existed = os.path.exists(path) # Try and make directory based on current path if it doesn't exist if not already_existed: os.makedirs(path) written: List[str] = [] # the files that were actually written try: # build in a temporary directory and then move afterwards, # it makes cleanup easier if an exception is raised inside. with TemporaryDirectory(dir=path) as tmp: # Loop through data for each layer for layer_data_tuple in layer_data: _, meta, type_ = layer_data_tuple # Create full path using name of layer # Write out data using first plugin found for this hook spec # or named plugin if provided npe2.write( path=abspath_or_url(os.path.join(tmp, meta['name'])), layer_data=[layer_data_tuple], plugin_name='napari', ) for fname in os.listdir(tmp): written.append(os.path.join(path, fname)) shutil.move(os.path.join(tmp, fname), path) except Exception as exc: if not already_existed: shutil.rmtree(path, ignore_errors=True) raise exc return written napari-0.5.0a1/pyproject.toml000066400000000000000000000104541437041365600161410ustar00rootroot00000000000000[build-system] requires = [ "setuptools >= 42", "wheel", "setuptools_scm[toml]>=3.4" ] build-backend = "setuptools.build_meta" [tool.setuptools_scm] write_to = "napari/_version.py" [tool.briefcase] project_name = "napari" bundle = "com.napari" author = "napari" url = "https://napari.org/" license = "BSD license" # version populated in bundle.py version = "0.0.1" [tool.briefcase.app.napari] formal_name = "napari" description = "napari: a multi-dimensional image viewer" sources = ['napari'] icon = "napari/resources/icon" # populated in bundle.py requires = [] [tool.black] target-version = ['py38', 'py39', 'py310'] skip-string-normalization = true line-length = 79 exclude = ''' ( /( \.eggs | \.git | \.hg | \.mypy_cache | \.tox | \.venv | _build | buck-out | build | dist | examples | vendored | _vendor )/ | napari/resources/qt.py | tools/minreq.py ) ''' [tool.check-manifest] ignore = [ "bundle.py", ".cirrus.yml", ".pre-commit-config.yaml", "asv.conf.json", "codecov.yml", "Makefile", "napari/_version.py", # added during build by setuptools_scm "tools/minreq.py", "tox.ini", "napari/_qt/qt_resources/_qt_resources_*.py", "*.pyi", # added by make typestubs "binder/*", ".env_sample", ".devcontainer/*", "napari/resources/icons/_themes/*/*.svg" ] [tool.ruff] line-length = 79 select = ["E", "F", "UP", "I", "W", "YTT", "TCH", "BLE", "B"] ignore = ["E501", "UP006", "UP007", "TCH001", "TCH002", "TCH003"] exclude = [ ".bzr", ".direnv", ".eggs", ".git", ".mypy_cache", ".pants.d", ".ruff_cache", ".svn", ".tox", ".venv", "__pypackages__", "_build", "buck-out", "build", "dist", "node_modules", "venv", "*vendored*", "*_vendor*", ] target-version = "py38" fix = true [tool.ruff.per-file-ignores] "napari/_vispy/__init__.py" = ["E402"] "**/_tests/*.py" = ["B011"] "napari/utils/_testsupport.py" = ["B011"] [tool.ruff.isort] known-first-party=['napari'] [tool.pytest.ini_options] # These follow standard library warnings filters syntax. See more here: # https://docs.python.org/3/library/warnings.html#describing-warning-filters addopts = "--maxfail=5 --durations=10 -rXxs" # NOTE: only put things that will never change in here. # napari deprecation and future warnings should NOT go in here. # instead... assert the warning with `pytest.warns()` in the relevant test, # That way we can clean them up when no longer necessary filterwarnings = [ "error:::napari", # turn warnings from napari into errors "error:::test_.*", # turn warnings in our own tests into errors "default:::napari.+vendored.+", # just print warnings inside vendored modules "ignore:Restart required for this change:UserWarning:napari", # triggered by a lot of async tests "ignore::DeprecationWarning:shibokensupport", "ignore::DeprecationWarning:ipykernel", "ignore::DeprecationWarning:tensorstore", "ignore:Accessing zmq Socket:DeprecationWarning:jupyter_client", "ignore:pythonw executable not found:UserWarning:", "ignore:data shape .* exceeds GL_MAX_TEXTURE_SIZE:UserWarning", "ignore:For best performance with Dask arrays in napari:UserWarning:", "ignore:numpy.ufunc size changed:RuntimeWarning", "ignore:Multiscale rendering is only supported in 2D. In 3D, only the lowest resolution scale is displayed", "ignore:Alternative shading modes are only available in 3D, defaulting to none", "ignore:distutils Version classes are deprecated::", "ignore:There is no current event loop:DeprecationWarning:", ] markers = [ "sync_only: Test should only be run synchronously", "async_only: Test should only be run asynchronously", "examples: Test of examples", "disable_qthread_start: Disable thread start in this Test", "disable_qthread_pool_start: Disable strarting QRunnable using QThreadPool start in this Test", "disable_qtimer_start: Disable timer start in this Test", "disable_qanimation_start: Disable animation start in this Test", ] [tool.mypy] files = "napari" ignore_missing_imports = true exclude = [ "_tests", ] show_error_codes = true no_implicit_optional = true warn_redundant_casts = true warn_unused_ignores = true check_untyped_defs = true # # maybe someday :) # disallow_any_generics = true # no_implicit_reexport = true # disallow_untyped_defs = true napari-0.5.0a1/resources/000077500000000000000000000000001437041365600152335ustar00rootroot00000000000000napari-0.5.0a1/resources/bundle_license.rtf000066400000000000000000000054351437041365600207320ustar00rootroot00000000000000{\rtf1\ansi\ansicpg1252\cocoartf2580 \cocoatextscaling0\cocoaplatform0{\fonttbl\f0\fswiss\fcharset0 Helvetica;\f1\fnil\fcharset0 LucidaGrande;} {\colortbl;\red255\green255\blue255;} {\*\expandedcolortbl;;} {\*\listtable{\list\listtemplateid1\listhybrid{\listlevel\levelnfc23\levelnfcn23\leveljc0\leveljcn0\levelfollow0\levelstartat1\levelspace360\levelindent0{\*\levelmarker \{square\}}{\leveltext\leveltemplateid1\'01\uc0\u9642 ;}{\levelnumbers;}\fi-360\li720\lin720 }{\listname ;}\listid1}} {\*\listoverridetable{\listoverride\listid1\listoverridecount0\ls1}} \margl1440\margr1440\vieww28300\viewh16080\viewkind0 \pard\tx720\tx1440\tx2160\tx2880\tx3600\tx4320\tx5040\tx5760\tx6480\tx7200\tx7920\tx8640\pardirnatural\partightenfactor0 \f0\fs24 \cf0 BSD 3-Clause License\ \ Copyright (c) 2018, Napari\ All rights reserved.\ \ Redistribution and use in source and binary forms, with or without modification, are permitted provided that the following conditions are met:\ \ \pard\tx220\tx720\tx1440\tx2160\tx2880\tx3600\tx4320\tx5040\tx5760\tx6480\tx7200\tx7920\tx8640\li720\fi-720\pardirnatural\partightenfactor0 \ls1\ilvl0\cf0 {\listtext \f1 \uc0\u9642 \f0 }Redistributions of source code must retain the above copyright notice, this list of conditions and the following disclaimer.\ {\listtext \f1 \uc0\u9642 \f0 }Redistributions in binary form must reproduce the above copyright notice, this list of conditions and the following disclaimer in the documentation and/or other materials provided with the distribution.\ {\listtext \f1 \uc0\u9642 \f0 }Neither the name of the copyright holder nor the names of its contributors may be used to endorse or promote products derived from this software without specific prior written permission.\ \pard\tx720\tx1440\tx2160\tx2880\tx3600\tx4320\tx5040\tx5760\tx6480\tx7200\tx7920\tx8640\pardirnatural\partightenfactor0 \cf0 \ THIS SOFTWARE IS PROVIDED BY THE COPYRIGHT HOLDERS AND CONTRIBUTORS "AS IS" AND ANY EXPRESS OR IMPLIED WARRANTIES, INCLUDING, BUT NOT LIMITED TO, THE IMPLIED WARRANTIES OF MERCHANTABILITY AND FITNESS FOR A PARTICULAR PURPOSE ARE DISCLAIMED. IN NO EVENT SHALL THE COPYRIGHT HOLDER OR CONTRIBUTORS BE LIABLE FOR ANY DIRECT, INDIRECT, INCIDENTAL, SPECIAL, EXEMPLARY, OR CONSEQUENTIAL DAMAGES (INCLUDING, BUT NOT LIMITED TO, PROCUREMENT OF SUBSTITUTE GOODS OR SERVICES; LOSS OF USE, DATA, OR PROFITS; OR BUSINESS INTERRUPTION) HOWEVER CAUSED AND ON ANY THEORY OF LIABILITY, WHETHER IN CONTRACT, STRICT LIABILITY, OR TORT (INCLUDING NEGLIGENCE OR OTHERWISE) ARISING IN ANY WAY OUT OF THE USE OF THIS SOFTWARE, EVEN IF ADVISED OF THE POSSIBILITY OF SUCH DAMAGE.\ \ This installer bundles other packages, which are distributed under their own license terms. {\field{\*\fldinst{HYPERLINK "https://github.com/napari/napari/blob/latest/EULA.md"}}{\fldrslt Check the full list here}}.}napari-0.5.0a1/resources/bundle_license.txt000066400000000000000000000032161437041365600207510ustar00rootroot00000000000000BSD 3-Clause License Copyright (c) 2018, Napari All rights reserved. Redistribution and use in source and binary forms, with or without modification, are permitted provided that the following conditions are met: * Redistributions of source code must retain the above copyright notice, this list of conditions and the following disclaimer. * Redistributions in binary form must reproduce the above copyright notice, this list of conditions and the following disclaimer in the documentation and/or other materials provided with the distribution. * Neither the name of the copyright holder nor the names of its contributors may be used to endorse or promote products derived from this software without specific prior written permission. THIS SOFTWARE IS PROVIDED BY THE COPYRIGHT HOLDERS AND CONTRIBUTORS "AS IS" AND ANY EXPRESS OR IMPLIED WARRANTIES, INCLUDING, BUT NOT LIMITED TO, THE IMPLIED WARRANTIES OF MERCHANTABILITY AND FITNESS FOR A PARTICULAR PURPOSE ARE DISCLAIMED. IN NO EVENT SHALL THE COPYRIGHT HOLDER OR CONTRIBUTORS BE LIABLE FOR ANY DIRECT, INDIRECT, INCIDENTAL, SPECIAL, EXEMPLARY, OR CONSEQUENTIAL DAMAGES (INCLUDING, BUT NOT LIMITED TO, PROCUREMENT OF SUBSTITUTE GOODS OR SERVICES; LOSS OF USE, DATA, OR PROFITS; OR BUSINESS INTERRUPTION) HOWEVER CAUSED AND ON ANY THEORY OF LIABILITY, WHETHER IN CONTRACT, STRICT LIABILITY, OR TORT (INCLUDING NEGLIGENCE OR OTHERWISE) ARISING IN ANY WAY OUT OF THE USE OF THIS SOFTWARE, EVEN IF ADVISED OF THE POSSIBILITY OF SUCH DAMAGE. This installer bundles other packages, which are distributed under their own license terms. Check the full list here: https://github.com/napari/napari/blob/latest/EULA.md napari-0.5.0a1/resources/bundle_readme.md000066400000000000000000000044761437041365600203560ustar00rootroot00000000000000Welcome to the napari installation contents ------------------------------------------- This is the base installation of napari, a fast n-dimensional image viewer written in Python. ## How do I run napari? In most cases, you would run it through the platform-specific shortcut we created for your convenience. In other words, _not_ through this directory! You should be able to see a `napari (x.y.z)` menu item, where `x.y.z` is the installed version. * Linux: check your desktop launcher. * MacOS: check `~/Applications` or the Launchpad. * Windows: check the Start Menu or the Desktop. We generally recommend using the shortcut because it will pre-activate the `conda` environment for you! That said, you can also execute the `napari` executable directly from these locations: * Linux and macOS: find it under `bin`, next to this file. * Windows: navigate to `Scripts`, next to this file. In unmodified installations, this _should_ be enough to launch `napari`, but sometimes you will need to activate the `conda` environment to ensure all dependencies are importable. ## What does `conda` have to do with `napari`? The `napari` installer uses `conda` packages to bundle all its dependencies (Python, qt, etc). This directory is actually a full `conda` installation! If you have used `conda` before, this is equivalent to what you usually call the `base` environment. ## Can I modify the `napari` installation? Yes. In practice, you can consider it a `conda` environment. You can even activate it as usual, provided you specify the full path to the location, instead of the _name_. ``` # macOS $ conda activate ~/Library/napari-x.y.z # Linux $ conda activate ~/.local/napari-x.y.z # Windows $ conda activate %LOCALAPPDATA%/napari-x.y.z ``` Then you will be able to run `conda` and `pip` as usual. That said, we advise against this advanced manipulation. It can render `napari` unusable if not done carefully! You might need to reinstall it in that case. ## What is `_conda.exe`? This executable is a full `conda` installation, condensed in a single file. It allows us to handle the installation in a more robust way. It also provides a way to restore destructive changes without reinstalling anything. Again, consider this an advanced tool only meant for expert debugging. ## More information Check our online documentation at https://napari.org/ napari-0.5.0a1/resources/conda_menu_config.json000066400000000000000000000031501437041365600215620ustar00rootroot00000000000000{ "$schema": "https://json-schema.org/draft-07/schema", "$id": "https://schemas.conda.io/menuinst-1.schema.json", "menu_name": "napari (__PKG_VERSION__)", "menu_items": [ { "name": "napari (__PKG_VERSION__)", "description": "a fast n-dimensional image viewer in Python", "icon": "{{ MENU_DIR }}/napari.{{ ICON_EXT }}", "precommand": "unset PYTHONHOME && unset PYTHONPATH", "command": [ "{{ PYTHON }}", "-m", "napari" ], "activate": true, "terminal": false, "platforms": { "win": { "precommand": "set \"PYTHONHOME=\" & set \"PYTHONPATH=\"", "desktop": true }, "linux": { "Categories": [ "Graphics", "Science" ] }, "osx": { "CFBundleName": "napari", "CFBundleDisplayName": "napari", "CFBundleVersion": "__PKG_VERSION__", "entitlements": [ "com.apple.security.files.user-selected.read-write", "com.apple.security.files.downloads.read-write", "com.apple.security.assets.pictures.read-write", "com.apple.security.assets.music.read-write", "com.apple.security.assets.movies.read-write" ] } } } ] } napari-0.5.0a1/resources/osx_pkg_welcome.rtf.tmpl000066400000000000000000000021171437041365600221110ustar00rootroot00000000000000{\rtf1\ansi\ansicpg1252\cocoartf2580 \cocoascreenfonts1\cocoatextscaling0\cocoaplatform0{\fonttbl\f0\fswiss\fcharset0 Helvetica;\f1\fnil\fcharset0 LucidaGrande;} {\colortbl;\red255\green255\blue255;\red60\green64\blue68;\red255\green255\blue255;} {\*\expandedcolortbl;;\cssrgb\c30196\c31765\c33725;\cssrgb\c100000\c100000\c100000;} \margl1440\margr1440\vieww12040\viewh13780\viewkind0 \pard\tx720\tx1440\tx2160\tx2880\tx3600\tx4320\tx5040\tx5760\tx6480\tx7200\tx7920\tx8640\pardirnatural\partightenfactor0 \f0\fs28 \cf0 Thanks for choosing napari v__VERSION__!\ \ {\field{\*\fldinst{HYPERLINK "https://napari.org"}}{\fldrslt napari}} is a fast, interactive, multi-dimensional image viewer for Python. It's designed for browsing, annotating, and analyzing large multi-dimensional images.\ \ The installation will begin shortly.\ \ If at any point an error is shown, please save the logs (\uc0\u8984+L) before closing the installer and submit the resulting file along with your report in {\field{\*\fldinst{HYPERLINK "https://github.com/napari/napari/issues"}}{\fldrslt our issue tracker}}. Thank you!\ }napari-0.5.0a1/resources/requirements_mypy.txt000066400000000000000000000000761437041365600216000ustar00rootroot00000000000000mypy==0.982 types-PyYAML==6.0.12.1 types-setuptools==65.5.0.2 napari-0.5.0a1/setup.cfg000066400000000000000000000120201437041365600150350ustar00rootroot00000000000000[metadata] name = napari url = https://napari.org download_url = https://github.com/napari/napari license = BSD 3-Clause license_file = LICENSE description = n-dimensional array viewer in Python long_description = file: README.md long_description_content_type = text/markdown author = napari team author_email = napari-steering-council@googlegroups.com project_urls = Bug Tracker = https://github.com/napari/napari/issues Documentation = https://napari.org Source Code = https://github.com/napari/napari classifiers = Development Status :: 3 - Alpha Environment :: X11 Applications :: Qt Intended Audience :: Education Intended Audience :: Science/Research License :: OSI Approved :: BSD License Programming Language :: C Programming Language :: Python Programming Language :: Python :: 3 :: Only Programming Language :: Python :: 3.8 Programming Language :: Python :: 3.9 Programming Language :: Python :: 3.10 Topic :: Scientific/Engineering Topic :: Scientific/Engineering :: Visualization Topic :: Scientific/Engineering :: Information Analysis Topic :: Scientific/Engineering :: Bio-Informatics Topic :: Utilities Operating System :: Microsoft :: Windows Operating System :: POSIX Operating System :: Unix Operating System :: MacOS [options] zip_safe = False packages = find: python_requires = >=3.8 include_package_data = True install_requires = appdirs>=1.4.4 app-model>=0.1.0,<0.3.0 # as per @czaki request. app-model v0.3.0 can drop napari v0.4.17 cachey>=0.2.1 certifi>=2018.1.18 dask[array]>=2.15.0,!=2.28.0 # https://github.com/napari/napari/issues/1656 imageio>=2.5.0,!=2.11.0,!=2.22.1 jsonschema>=3.2.0 magicgui>=0.3.6 napari-console>=0.0.6 napari-plugin-engine>=0.1.9 napari-svg>=0.1.6 npe2>=0.5.2 numpy>=1.20 numpydoc>=0.9.2 pandas>=1.1.0 ; python_version < '3.9' pandas>=1.3.0 ; python_version >= '3.9' Pillow!=7.1.0,!=7.1.1 # not a direct dependency, but 7.1.0 and 7.1.1 broke imageio pint>=0.17 psutil>=5.0 psygnal>=0.3.4 pydantic>=1.9.0 pygments>=2.4.0 PyOpenGL>=3.1.0 PyYAML>=5.1 qtpy>=1.10.0 scikit-image>=0.19.1 scikit-image[data] # just `pooch`, but needed by `builtins` to provide all scikit-image.data samples scipy>=1.4.1 ; python_version < '3.9' scipy>=1.5.4 ; python_version >= '3.9' sphinx<5 # numpydoc dependency. sphinx>=5 breaks the docs build; see https://github.com/napari/napari/pull/4915 superqt>=0.3.0 tifffile>=2020.2.16 toolz>=0.10.0 tqdm>=4.56.0 typing_extensions vispy>=0.12.1,<0.13 wrapt>=1.11.1 [options.package_data] * = *.pyi napari_builtins = builtins.yaml # for explanation of %(extra)s syntax see: # https://github.com/pypa/setuptools/issues/1260#issuecomment-438187625 # this syntax may change in the future [options.extras_require] pyside2 = PySide2>=5.13.2,!=5.15.0 ; python_version != '3.8' PySide2>=5.14.2,!=5.15.0 ; python_version == '3.8' pyside = # alias for pyside2 %(pyside2)s pyqt5 = PyQt5>=5.12.3,!=5.15.0 pyqt = # alias for pyqt5 %(pyqt5)s qt = # alias for pyqt5 %(pyqt5)s # all is the full "batteries included" extra. all = %(pyqt5)s # optional (i.e. opt-in) packages, see https://github.com/napari/napari/pull/3867#discussion_r864354854 optional = triangle testing = babel>=2.9.0 fsspec hypothesis>=6.8.0 lxml matplotlib pooch>=1.6.0 pytest-cov pytest-qt pytest>=7.0.0 tensorstore>=0.1.13 torch>=1.7 virtualenv xarray zarr release = PyGithub>=1.44.1 twine>=3.1.1 gitpython>=3.1.0 requests-cache>=0.9.2 dev = black check-manifest>=0.42 pre-commit>=2.9.0 pydantic[dotenv] rich %(testing)s build = black ruff pyqt5 bundle_build = briefcase==0.3.1 dmgbuild>=1.4.2 markupsafe<2.1 PySide2==5.15.2 ruamel.yaml tomlkit wheel bundle_run = imagecodecs pip PySide2==5.15.2 scikit-image[data] zarr wheel pims numpy==1.19.3 [options.entry_points] console_scripts = napari = napari.__main__:main pytest11 = napari = napari.utils._testsupport napari.manifest = napari_builtins = napari_builtins:builtins.yaml [coverage:report] exclude_lines = pragma: no cover if TYPE_CHECKING: raise NotImplementedError() except ImportError: [coverage:run] omit = */_vendor/* [importlinter] root_package = napari include_external_packages=True [importlinter:contract:1] name = "Forbid import PyQt and PySide" type = forbidden source_modules = napari forbidden_modules = PyQt5 PySide2 ignore_imports = napari._qt.qt_resources._icons -> PyQt5 napari._qt.qt_resources._icons -> PySide2 napari._qt -> PySide2 [importlinter:contract:2] name = "Block import from qt module in abstract ones" type = layers layers= napari.qt napari.layers [importlinter:contract:3] name = "Block import from qt module in abstract ones" type = layers layers= napari.qt napari.components napari-0.5.0a1/tools/000077500000000000000000000000001437041365600143615ustar00rootroot00000000000000napari-0.5.0a1/tools/check_vendored_modules.py000066400000000000000000000063521437041365600214340ustar00rootroot00000000000000""" Check state of vendored modules. """ import shutil import sys from pathlib import Path from subprocess import check_output TOOLS_PATH = Path(__file__).parent REPO_ROOT_PATH = TOOLS_PATH.parent VENDOR_FOLDER = "_vendor" NAPARI_FOLDER = "napari" def _clone(org, reponame, tag): repo_path = TOOLS_PATH / reponame if repo_path.is_dir(): shutil.rmtree(repo_path) check_output( [ "git", "clone", '--depth', '1', '--branch', tag, f"https://github.com/{org}/{reponame}", ], cwd=TOOLS_PATH, ) return repo_path def check_vendored_files( org: str, reponame: str, tag: str, source_paths: Path, target_path: Path ) -> str: repo_path = _clone(org, reponame, tag) vendor_path = REPO_ROOT_PATH / NAPARI_FOLDER / target_path for s in source_paths: shutil.copy(repo_path / s, vendor_path) return check_output(["git", "diff"], cwd=vendor_path).decode("utf-8") def check_vendored_module(org: str, reponame: str, tag: str) -> str: """ Check if the vendored module is up to date. Parameters ---------- org : str The github organization name. reponame : str The github repository name. tag : str The github tag. Returns ------- str Returns the diff if the module is not up to date or an empty string if it is. """ repo_path = _clone(org, reponame, tag) vendor_path = REPO_ROOT_PATH / NAPARI_FOLDER / VENDOR_FOLDER / reponame if vendor_path.is_dir(): shutil.rmtree(vendor_path) shutil.copytree(repo_path / reponame, vendor_path) shutil.copy(repo_path / "LICENSE", vendor_path) shutil.rmtree(repo_path, ignore_errors=True) return check_output(["git", "diff"], cwd=vendor_path).decode("utf-8") def main(): CI = '--ci' in sys.argv print("\n\nChecking vendored modules\n") for org, reponame, tag, source, target in [ ("albertosottile", "darkdetect", "master", None, None), ( "matplotlib", "matplotlib", "v3.2.1", [ # this file seem to be post 3.0.3 but pre 3.1 # plus there may have been custom changes. # 'lib/matplotlib/colors.py', # # this file seem much more recent, but is touched much more rarely. # it is at least from 3.2.1 as the turbo colormap is present and # was added in matplotlib in 3.2.1 #'lib/matplotlib/_cm_listed.py' ], 'utils/colormaps/vendored/', ), ]: print(f"\n * Checking '{org}/{reponame}'\n") if not source: diff = check_vendored_module(org, reponame, tag) else: diff = check_vendored_files( org, reponame, tag, [Path(s) for s in source], Path(target) ) if CI: print(f"::set-output name=vendored::{org}/{reponame}") sys.exit(0) if diff: print(diff) print( f"\n * '{org}/{reponame}' vendor code seems to not be up to date!!!\n" ) sys.exit(1) if __name__ == "__main__": main() napari-0.5.0a1/tools/minreq.py000066400000000000000000000024711437041365600162320ustar00rootroot00000000000000""" Script to replace minimum requirements with exact requirements in setup.cfg This ensures that our test matrix includes a version with only the minimum required versions of packages, and we don't accidentally use features only available in newer versions. This script does nothing if the 'MIN_REQ' environment variable is anything other than '1'. """ import os from configparser import ConfigParser def pin_config_minimum_requirements(config_filename): # read it with configparser config = ConfigParser() config.read(config_filename) # swap out >= requirements for == config['options']['install_requires'] = config['options'][ 'install_requires' ].replace('>=', '==') config['options.extras_require']['pyside2'] = config[ 'options.extras_require' ]['pyside2'].replace('>=', '==') config['options.extras_require']['pyqt5'] = config[ 'options.extras_require' ]['pyqt5'].replace('>=', '==') # rewrite setup.cfg with new config with open(config_filename, 'w') as fout: config.write(fout) if __name__ == '__main__': if os.environ.get('MIN_REQ', '') == '1': # find setup.cfg config_filename = os.path.join( os.path.dirname(__file__), "..", "setup.cfg" ) pin_config_minimum_requirements(config_filename) napari-0.5.0a1/tools/perfmon/000077500000000000000000000000001437041365600160275ustar00rootroot00000000000000napari-0.5.0a1/tools/perfmon/README.md000066400000000000000000000040011437041365600173010ustar00rootroot00000000000000# Utilties for napari performance monitoring This directory contains configs and tools associated with [performance monitoring as described on napari.org](https://napari.org/stable/howtos/perfmon.html?highlight=perfmon). Storing these in the repo makes it easier to reproduce monitoring experiments and results by standardizing configurations and tooling. Napari developers would be encouraged to add configurations to focus on specific areas of concern, e.g. slicing. Users can then be encouraged to use this tool to help developers better understand napari's performance in the wild. ## Usage From the root of the napari repo: ```shell python tools/perfmon/run.py CONFIG EXAMPLE_SCRIPT ``` To take a specific example, let's say that we want to monitor `Layer.refresh` while interacting with a multi-scale image in napari. First, we would call the run command with the slicing config and one of the built-in example scripts: ```shell python tools/perfmon/run.py slicing examples/add_multiscale_image.py ``` After interacting with napari then quitting the application either through the application menu or keyboard shortcut, a traces JSON file should be output to the slicing subdirectory: ```shell cat tools/perfmon/slicing/traces-latest.json ``` You can then plot the distribution of the `Layer.refresh` callable defined in the slicing config: ```shell python tools/perfmon/plot_callable.py slicing Layer.refresh ``` Next, you might want to switch to a branch, repeat a similar interaction with the same configuration to measure a potential improvement to napari: ```shell python tools/perfmon/run.py slicing examples/add_multiscale_image.py --output=test ``` By specifying the `output` argument, the trace JSON file is written to a different location to avoid overwriting the first file: ```shell cat tools/perfmon/slicing/traces-test.json ``` We can then generate a comparison of the two runs to understand if there was an improvement: ```shell python tools/perfmon/compare_callable.py slicing Layer.refresh latest test ``` napari-0.5.0a1/tools/perfmon/compare_callable.py000066400000000000000000000033431437041365600216510ustar00rootroot00000000000000import json import logging import pathlib from argparse import ArgumentParser import matplotlib.pyplot as plt logging.basicConfig( format='%(levelname)s : %(asctime)s : %(message)s', level=logging.INFO, ) parser = ArgumentParser( description='Plot the durations of a callable measured by perfmon.', ) parser.add_argument( 'config', help='The name of the sub-directory that contains the perfmon traces (e.g. slicing)', ) parser.add_argument( 'callable', help='The name of the callable to plot excluding the module (e.g. QtDimSliderWidget._value_changed).', ) parser.add_argument( 'baseline', default='baseline', help='The name added to output traces file for the baseline measurement.', ) parser.add_argument( 'test', default='test', help='The name added to output traces file for the test measurement.', ) args = parser.parse_args() logging.info( f'''Running compare_callable.py with the following arguments. {args}''' ) perfmon_dir = pathlib.Path(__file__).parent.resolve(strict=True) config_dir = perfmon_dir / args.config def _get_durations_ms(output_name: str) -> list[float]: file_path = str(config_dir / f'traces-{output_name}.json') with open(file_path) as traces_file: traces = json.load(traces_file) return [ trace['dur'] / 1000 for trace in traces if trace['name'] == args.callable ] baseline_durations_ms = _get_durations_ms(args.baseline) test_durations_ms = _get_durations_ms(args.test) plt.violinplot( [baseline_durations_ms, test_durations_ms], vert=False, ) plt.title(f'{args.config}: {args.callable} ({args.baseline} vs. {args.test})') plt.xlabel('Duration (ms)') plt.yticks([1, 2], [args.baseline, args.test]) plt.show() napari-0.5.0a1/tools/perfmon/plot_callable.py000066400000000000000000000024411437041365600211770ustar00rootroot00000000000000import json import logging import pathlib from argparse import ArgumentParser import matplotlib.pyplot as plt logging.basicConfig( format='%(levelname)s : %(asctime)s : %(message)s', level=logging.INFO, ) parser = ArgumentParser( description='Plot the durations of a callable measured by perfmon.', ) parser.add_argument( 'config', help='The name of the sub-directory that contains the perfmon traces (e.g. slicing)', ) parser.add_argument( 'callable', help='The name of the callable to plot excluding the module (e.g. QtDimSliderWidget._value_changed).', ) parser.add_argument( '--output', default='latest', help='The name added to output traces file.' ) args = parser.parse_args() logging.info( f'''Running plot_callable.py with the following arguments. {args}''' ) perfmon_dir = pathlib.Path(__file__).parent.resolve(strict=True) traces_path = perfmon_dir / args.config / f'traces-{args.output}.json' with open(traces_path) as traces_file: traces = json.load(traces_file) durations_ms = [ trace['dur'] / 1000 for trace in traces if trace['name'] == args.callable ] plt.violinplot(durations_ms, vert=False, showmeans=True, showmedians=True) plt.title(f'{args.config} ({args.output}): {args.callable}') plt.xlabel('Duration (ms)') plt.yticks([]) plt.show() napari-0.5.0a1/tools/perfmon/run.py000066400000000000000000000024201437041365600172030ustar00rootroot00000000000000import logging import os import pathlib import shutil import subprocess from argparse import ArgumentParser logging.basicConfig( format='%(levelname)s : %(asctime)s : %(message)s', level=logging.INFO, ) parser = ArgumentParser( description='Run napari with one of the perfmon configurations.' ) parser.add_argument( 'config', help='The name of the sub-directory that contains the perfmon configuration file (e.g. slicing).', ) parser.add_argument( 'example_script', help='The example script that should run napari.' ) parser.add_argument( '--output', default='latest', help='The name to add to the output traces file.', ) args = parser.parse_args() logging.info( f'''Running run.py with the following arguments. {args}''' ) perfmon_dir = pathlib.Path(__file__).parent.resolve(strict=True) config_dir = perfmon_dir / args.config config_path = str(config_dir / 'config.json') env = os.environ.copy() env['NAPARI_PERFMON'] = config_path subprocess.check_call( ['python', args.example_script], env=env, ) original_output_path = str(config_dir / 'traces-latest.json') desired_output_path = str(config_dir / (f'traces-{args.output}.json')) if desired_output_path != original_output_path: shutil.copy(original_output_path, desired_output_path) napari-0.5.0a1/tools/perfmon/slicing/000077500000000000000000000000001437041365600174575ustar00rootroot00000000000000napari-0.5.0a1/tools/perfmon/slicing/config.json000066400000000000000000000007261437041365600216240ustar00rootroot00000000000000{ "trace_qt_events": false, "trace_file_on_start": "tools/perfmon/slicing/traces-latest.json", "trace_callables": [ "slicing" ], "callable_lists": { "slicing": [ "napari.components.dims.Dims.set_current_step", "napari.layers.base.base.Layer.refresh", "napari.layers.base.base.Layer.set_view_slice", "napari._qt.widgets.qt_dims_slider.QtDimSliderWidget._value_changed" ] } } napari-0.5.0a1/tools/string_list.json000066400000000000000000013074501437041365600176270ustar00rootroot00000000000000{ "SKIP_FILES": [ "napari/_lazy.py", "napari/_qt/widgets/qt_theme_sample.py", "napari/_version.py", "napari/__main__.py", "napari/conftest.py", "napari/utils/_dtype.py", "napari/utils/_testsupport.py", "napari/utils/shortcuts.py", "napari/utils/stubgen.py" ], "SKIP_FOLDERS": [ "/_tests/", "/_vendor/", "/__pycache__/", "/docs/", "/examples/", "/tools/", "/vendored/", "/qt_resources/" ], "SKIP_WORDS": { "napari/__init__.py": [ "1", "SPARSE_AUTO_DENSIFY", "not-installed", "_event_loop", "plugins.io", "utils.notifications", "view_layers", "viewer", "__version__", "components", "experimental", "layers", "qt", "types", "utils", "gui_qt", "run", "save_layers", "utils", "sys_info", "notification_manager", "view_image", "view_labels", "view_path", "view_points", "view_shapes", "view_surface", "view_tracks", "view_vectors", "Viewer", "current_viewer" ], "napari/_app_model/_app.py": ["__module__"], "napari/_app_model/actions/_layer_actions.py": [ "id", "group", "id", "group", "id", "group", "image", "when", "image", "when", "id", "group", "when", "when", "int8", "int16", "int32", "int64", "uint8", "uint16", "uint32", "uint64", "LAYER_CONVERT_TO_{_dtype.upper()}", "id", "max", "min", "std", "sum", "mean", "median", "LAYER_PROJECT_{mode.upper()}", "id" ], "napari/_app_model/constants/_commands.py": [ "napari:layer:duplicate", "napari:layer:split_stack", "napari:layer:split_rgb", "napari:layer:merge_stack", "napari:layer:toggle_visibility", "napari:layer:link_selected_layers", "napari:layer:unlink_selected_layers", "napari:layer:select_linked_layers", "napari:layer:convert_to_labels", "napari:layer:convert_to_image", "napari:layer:convert_to_int8", "napari:layer:convert_to_int16", "napari:layer:convert_to_int32", "napari:layer:convert_to_int64", "napari:layer:convert_to_uint8", "napari:layer:convert_to_uint16", "napari:layer:convert_to_uint32", "napari:layer:convert_to_uint64", "napari:layer:project_max", "napari:layer:project_min", "napari:layer:project_std", "napari:layer:project_sum", "napari:layer:project_mean", "napari:layer:project_median" ], "napari/_app_model/constants/_menus.py": [ "napari/layers/context", "napari/layers/convert_dtype", "napari/layers/project", "1_conversion", "5_split_merge", "9_link", "Set of all menu ids that can be contributed to by plugins.", "napari/", "navigation" ], "napari/_app_model/context/_context.py": [ "settings.", "{self._PREFIX}{event.key}", "dict" ], "napari/_app_model/context/_context_keys.py": ["A", "Event"], "napari/_app_model/context/_layerlist_context.py": [ "rgb", "image", "labels", "points", "shapes", "surface", "vectors", "tracks", "ndim", "shape", "LayerSel" ], "napari/_app_model/injection/_processors.py": [ "name", "Data", "add_{layer_type}" ], "napari/_event_loop.py": [], "napari/_qt/__init__.py": [ "No Qt bindings could be found", "PySide2", "QT_PLUGIN_PATH", "Qt", "plugins" ], "napari/_qt/_constants.py": [], "napari/_qt/code_syntax_highlight.py": [ "monospace", "color", "#{style['color']}", "bgcolor", "bgcolor", "bold", "italic", "underline" ], "napari/_qt/containers/__init__.py": [ "create_model", "create_view", "QtLayerList", "QtLayerListModel", "QtListModel", "QtListView", "QtNodeTreeModel", "QtNodeTreeView" ], "napari/_qt/containers/_base_item_model.py": ["ItemType", "_root"], "napari/_qt/containers/_base_item_view.py": ["ItemType"], "napari/_qt/containers/_factory.py": [], "napari/_qt/containers/_layer_delegate.py": [ "_context_menu", "dark", "folder", "folder-open", "is_group", "light", "new_{layer._type_string}", "globalPosition" ], "napari/_qt/containers/qt_layer_list.py": [], "napari/_qt/containers/qt_layer_model.py": [ "index", "name", "thumbnail", "visible" ], "napari/_qt/containers/qt_list_model.py": [ "ItemType", "QMimeData", "application/x-list-index", "dropMimeData: indices {moving_indices} \u27a1 {destRow}", "text/plain" ], "napari/_qt/containers/qt_list_view.py": ["ItemType"], "napari/_qt/containers/qt_tree_model.py": [ "NodeMimeData", "NodeType", "application/x-tree-node", "dropMimeData: indices {moving_indices} \u27a1 {dest_idx}", "text/plain" ], "napari/_qt/containers/qt_tree_view.py": ["NodeType"], "napari/_qt/dialogs/__init__.py": [], "napari/_qt/dialogs/confirm_close_dialog.py": [ "warning_icon_btn", "Ctrl+Q", "error_icon_element", "Ctrl+W", "warning_icon_element" ], "napari/_qt/dialogs/preferences_dialog.py": [ "0", "BaseModel", "ModelField", "QCloseEvent", "QKeyEvent", "_name", "preferences_exclude", "schema_version", "shortcuts", "NapariConfig", "call_order", "highlight", "highlight_thickness", "plugins", "properties", "ui:widget", "async_", "enum", "experimental", "octree", "string", "type", "extension2reader" ], "napari/_qt/dialogs/qt_about.py": ["QtAbout", "QtCopyToClipboardButton"], "napari/_qt/dialogs/qt_about_key_bindings.py": [ "secondary", "{layer.__name__} layer" ], "napari/_qt/dialogs/qt_activity_dialog.py": [ "Activity", "QtActivityButton", "QtCustomTitleBarLine", "QtCustomTitleLabel", "loading.gif" ], "napari/_qt/dialogs/qt_modal.py": [ "QtModalPopup", "QtPopupFrame", "`position` argument must have length 4", "bottom", "left", "right", "top" ], "napari/_qt/dialogs/qt_notification.py": [ "\nDebugging finished. Napari active again.", "Entering debugger. Type 'q' to return to napari.\n", "WARNING", "close_button", "expand_button", "expanded", "severity_icon", "source_label", "#D85E38", "#E3B617", "debug", "error", "icon", "info", "none", "warning", "opacity", "geometry", "QPushButton{padding: 4px 12px 4px 12px; font-size: 11px;min-height: 18px; border-radius: 0;}", "python", "resized" ], "napari/_qt/dialogs/qt_plugin_dialog.py": [ "-c", ".json", "bin", "cancel", "close_button", "conda-forge", "conda-meta", "darwin", "install_button", "#33F0FF", "help_button", "logo_silhouette", "npe2", "mamba", "napari-{'.'.join(parts)}-", "python3", "qt-{QT_VERSION}-", "remove", "small_italic_text", "--no-warn-script-location", "--prefix", "--upgrade", "-m", "-y", "PYTHONPATH", "UNKNOWN", "__main__", "author", "error_label", "license", "linux", "loading.gif", "napari_plugin_engine", "outdated", "pip", "plugin_manager_process_status", "remove_button", "small_text", "summary", "uninstall", "url", "version", "warning_icon", "CONDA_PINNED_PACKAGES", "&{env.value('CONDA_PINNED_PACKAGES')}", "CONDA_PINNED_PACKAGES", "napari={napari_version}{system_pins}", "nt", "TEMP", "TMP", "TEMP", "USERPROFILE", "HOME", "~", "USERPROFILE", "~", "shim", "shim", "warning", "#E3B617", "{pkg_name} {project_info.summary}", "latest_version", "=={item.latest_version}", "1.0", "shim", "napari_hub", "0-{item.text()}", "unavailable" ], "napari/_qt/dialogs/qt_plugin_report.py": [ "QtCopyToClipboardButton", "github.com", "pluginInfo", "url", "{meta.get(\"url\")}/issues/new?&body={err}", "python", "NoColor", "plugin home page:  
{url}" ], "napari/_qt/dialogs/qt_reader_dialog.py": [ "Choose reader", "persist_checkbox", "{display_name}", "{error_message}Choose reader for {self._current_file}:", "*", "[{paths[0]}, ...]", ".zarr" ], "napari/_qt/dialogs/screenshot_dialog.py": [".png"], "napari/_qt/experimental/__init__.py": [], "napari/_qt/experimental/qt_chunk_receiver.py": [], "napari/_qt/experimental/qt_poll.py": [], "napari/_qt/layer_controls/__init__.py": [], "napari/_qt/layer_controls/qt_colormap_combobox.py": [], "napari/_qt/layer_controls/qt_image_controls.py": [ "RGB", "rgb", "napari.layers.Image", "napari:orient_plane_normal_along_z", "napari:orient_plane_normal_along_y", "napari:orient_plane_normal_along_x", "napari:orient_plane_normal_along_view_direction", "x", "y", "z" ], "napari/_qt/layer_controls/qt_image_controls_base.py": [ "colorbar", "colormapComboBox", "numpy_dtype", "contrast_limits", "contrast_limits_range", "full range", "full_clim_range_button", "reset", "reset_clims_button", "top", "gamma", "reset_contrast_limits", "_keep_auto_contrast" ], "napari/_qt/layer_controls/qt_labels_controls.py": [ "erase", "erase_button", "fill", "fill_button", "paint", "paint_button", "pick_button", "picker", "shuffle", "zoom", "napari:activate_fill_mode", "napari:activate_label_erase_mode", "napari:activate_label_pan_zoom_mode", "napari:activate_label_picker_mode", "napari:activate_paint_mode", "napari.layers.Labels" ], "napari/_qt/layer_controls/qt_layer_controls_base.py": ["close", "layer"], "napari/_qt/layer_controls/qt_layer_controls_container.py": ["emphasized"], "napari/_qt/layer_controls/qt_points_controls.py": [ "add_points", "addition_button", "delete_button", "delete_shape", "pan_zoom", "select_button", "select_points", "napari:activate_points_add_mode", "napari:activate_points_select_mode", "napari:activate_points_pan_zoom_mode", "napari:delete_selected_points", "napari.layers.Points" ], "napari/_qt/layer_controls/qt_shapes_controls.py": [ "Backspace", "delete_button", "delete_shape", "direct", "direct_button", "ellipse", "ellipse_button", "line", "line_button", "move_back", "move_back_button", "move_front", "move_front_button", "path", "path_button", "polygon", "polygon_button", "rectangle", "rectangle_button", "select", "select_button", "vertex_insert", "vertex_insert_button", "vertex_remove", "vertex_remove_button", "zoom", "activate_add_ellipse_mode", "activate_add_line_mode", "activate_add_path_mode", "activate_add_polygon_mode", "activate_add_rectangle_mode", "activate_direct_mode", "activate_select_mode", "activate_vertex_insert_mode", "activate_vertex_remove_mode", "activate_shape_pan_zoom_mode", "napari:move_shapes_selection_to_back", "napari:move_shapes_selection_to_front", "napari.layers.Shapes", "napari:{action_name}" ], "napari/_qt/layer_controls/qt_surface_controls.py": [ "napari.layers.Surface" ], "napari/_qt/layer_controls/qt_tracks_controls.py": ["napari.layers.Tracks"], "napari/_qt/layer_controls/qt_vectors_controls.py": [ "colormap", "cycle", "direct", "napari.layers.Tracks" ], "napari/_qt/menus/_util.py": [ "MenuItem", "NapariMenu", "check_on", "checkable", "checked", "enabled", "items", "menu", "menuRole", "shortcut", "slot", "statusTip", "text", "value", "when" ], "napari/_qt/menus/debug_menu.py": [ ".json", "Alt+T", "Shift+Alt+T", "Window", "items", "menu", "shortcut", "slot", "statusTip", "text" ], "napari/_qt/menus/file_menu.py": [ "Alt+S", "Alt+Shift+S", "Ctrl+Alt+O", "Ctrl+O", "Ctrl+Q", "Ctrl+S", "Ctrl+Shift+O", "Ctrl+Shift+P", "Ctrl+Shift+S", "Ctrl+W", "Window", "display_name", "enabled", "menu", "menuRole", "shortcut", "slot", "statusTip", "text", "when", "items", "Alt+C", "Alt+Shift+C", "&", "&&", "Viewer" ], "napari/_qt/menus/help_menu.py": [ "Ctrl+/", "Window", "shortcut", "slot", "statusTip", "text" ], "napari/_qt/menus/plugins_menu.py": ["Window", "dock", "&", "&&"], "napari/_qt/menus/view_menu.py": [ "Axes", "Ctrl+Alt+O", "Ctrl+Alt+P", "Ctrl+F", "Ctrl+M", "Scale Bar", "Window", "arrows", "axes", "check_on", "checkable", "checked", "colored", "dashed", "items", "labels", "menu", "scale_bar", "shortcut", "slot", "statusTip", "text", "ticks", "visible", "when", "Darwin" ], "napari/_qt/menus/window_menu.py": ["Window"], "napari/_qt/perf/__init__.py": [], "napari/_qt/perf/qt_debug_menu.py": [".json", "Alt+T", "Shift+Alt+T"], "napari/_qt/perf/qt_event_tracing.py": [ "qt_event", "{event_str}:{object_name}" ], "napari/_qt/perf/qt_performance.py": [ "%vms", "1", "10", "100", "15", "20", "200", "30", "40", "5", "50", "UpdateRequest" ], "napari/_qt/qprogress.py": ["self", "gui"], "napari/_qt/qt_event_filters.py": ["{html.escape(tooltip)}"], "napari/_qt/qt_event_loop.py": [ "..", "IPython", "app_id", "app_name", "app_version", "darwin", "frozen", "gui_qt", "icon", "ipy_interactive", "logo.png", "napari", "napari.napari.viewer.{__version__}", "napari.org", "nt", "org_domain", "org_name", "qt", "resources", "run", "PYCHARM_HOSTED", "_in_event_loop", "theme_{name}" ], "napari/_qt/qt_main_window.py": [ "Ctrl+M", "_", "_QtMainWindow", "_qt_window", "all", "auto_call", "bottom", "call_button", "layout", "napari", "napari.viewer.Viewer", "napari_viewer", "reset_choices", "right", "run", "vertical", "_magic_widget", "left", "ignore", "QImage", "_parent", "name", "Viewer", "ViewerStatusBar", "nt", "system", "globalPosition", "_timer", "Widget", "layer_base", "source_type", "plugin", "coordinates" ], "napari/_qt/qt_viewer.py": [ "bottom", "circle", "cross", "expanded", "forbidden", "layerList", "left", "pointing", "right", "square", "standard", "top", ";;", "action_manager", "image", "napari", "points", "*{val}", "Saved {saved}", "crosshair", "ignore" ], "napari/_qt/qthreading.py": [ "_connect", "_ignore_errors", "_start_thread", "_worker_class", "_R", "_S", "_Y", "_P", "_progress", "desc", "total", "{layer_name.title()}Data" ], "napari/_qt/utils.py": [ "!QBYTE_", "native", "pyqtRemoveInputHook", "pyqtRestoreInputHook", "int", "<[^\n]+>", "PySide", "color" ], "napari/_qt/widgets/__init__.py": [], "napari/_qt/widgets/qt_action_context_menu.py": [ "SubMenu", "action_group", "description", "enable_when", "key", "show_when" ], "napari/_qt/widgets/qt_color_swatch.py": [ "#colorSwatch {background-color: ", ";}", "CustomColorDialog", "QColorSwatchEdit", "QtColorPopup", "\\(?([\\d.]+),\\s*([\\d.]+),\\s*([\\d.]+),?\\s*([\\d.]+)?\\)?", "colorSwatch", "int", "rgba({\",\".join(map(lambda x: str(int(x*255)), self._color))})", "transparent" ], "napari/_qt/widgets/qt_dict_table.py": [ "^[a-zA-Z0-9_.+-]+@[a-zA-Z0-9-]+\\.[a-zA-Z0-9-.]+$", "mailto:{text}", "https?:\\/\\/(www\\.)?[-a-zA-Z0-9@:%._\\+~#=]{1,256}\\.[a-zA-Z0-9()]{1,6}\b([-a-zA-Z0-9()@:%_\\+.~#?&//=]*)" ], "napari/_qt/widgets/qt_dims.py": [ "8", "axis_label", "last_used", "slice_label" ], "napari/_qt/widgets/qt_dims_slider.py": [ "False", "True", "_", "axis_label", "fpsSpinBox", "frame_requested", "playDirectionCheckBox", "playing", "reverse", "setStepType", "slice_label", "slice_label_sep", "fps", "loop_mode" ], "napari/_qt/widgets/qt_dims_sorter.py": ["Viewer", "help_label"], "napari/_qt/widgets/qt_extension2reader.py": [ "border-bottom: 2px solid white;", "margin: 4px;", "*", "*{fn_pattern}", "{fn_pattern}", "X" ], "napari/_qt/widgets/qt_highlight_preview.py": [ "border: 1px solid white;", "px", "white" ], "napari/_qt/widgets/qt_keyboard_settings.py": [ "Control", "Ctrl", "[-(?=.+)]", "border-bottom: 2px solid white;", "error_label", "{layer.__name__} layer" ], "napari/_qt/widgets/qt_large_int_spinbox.py": ["horizontalAdvance"], "napari/_qt/widgets/qt_message_popup.py": [ "background-color: rgba(0, 0, 0, 0);", "x" ], "napari/_qt/widgets/qt_mode_buttons.py": ["mode"], "napari/_qt/widgets/qt_plugin_sorter.py": [ ":[a-z]+:`([^`]+)`", "<", "\")}\">{_text.strip()}", "
", "\\1", "\\1", "\\1", "Parameters", "`([^`]+)`_", "``([^`]+)``", "``{_text}``", "enabled", "firstresult", "info_icon", "napari_", "small_text", "~", "\\*\\*([^*]+)\\*\\*", "\\*([^*]+)\\*" ], "napari/_qt/widgets/qt_progress_bar.py": [ ": ", "QtCustomTitleBarLine", "{value}: " ], "napari/_qt/widgets/qt_range_slider_popup.py": [], "napari/_qt/widgets/qt_scrollbar.py": ["position"], "napari/_qt/widgets/qt_size_preview.py": ["m", "px", "horizontalAdvance"], "napari/_qt/widgets/qt_splash_screen.py": [], "napari/_qt/widgets/qt_viewer_buttons.py": [ "viewer", "napari:roll_axes", "napari:transpose_axes", "napari:reset_view", "napari:toggle_grid", "napari:toggle_ndisplay", "perspective", "Control-Backspace", "console", "expanded", "grid_view_button", "home", "mode", "new_labels", "new_points", "new_shapes", "roll", "dim_sorter", "transpose", "gridStrideBox", "gridWidthBox", "help_label", "ndisplay_button", "shape", "stride", "napari:toggle_console_visibility", "ViewerModel" ], "napari/_qt/widgets/qt_viewer_dock_widget.py": [ "QTitleBarCloseButton", "QTitleBarFloatButton", "QTitleBarHideButton", "QtCustomTitleBar", "QtCustomTitleBarLine", "bottom", "dockWidgetArea", "left", "right", "addStretch", "top", "vertical", "{shortcut}", "ReferenceType[QtViewer]", "Widget" ], "napari/_qt/widgets/qt_viewer_status_bar.py": [ "_QtMainWindow", "{source_type}: " ], "napari/_qt/widgets/qt_welcome.py": [ "Ctrl+O", "QEvent", "drag", "logo_silhouette", "napari:show_shortcuts" ], "napari/_vispy/__init__.py": ["vispy"], "napari/_vispy/_text_utils.py": [], "napari/_vispy/_vispy_tracks_shader.py": [ "\n varying vec4 v_track_color;\n void apply_track_shading() {\n\n // if the alpha is below the threshold, discard the fragment\n if( v_track_color.a <= 0.0 ) {\n discard;\n }\n\n // interpolate\n gl_FragColor.a = clamp(v_track_color.a * gl_FragColor.a, 0.0, 1.0);\n }\n ", "\n varying vec4 v_track_color;\n void apply_track_shading() {\n\n float alpha;\n\n if ($a_vertex_time > $current_time) {\n // this is a hack to minimize the frag shader rendering ahead\n // of the current time point due to interpolation\n if ($a_vertex_time <= $current_time + 1){\n alpha = -100.;\n } else {\n alpha = 0.;\n }\n } else {\n // fade the track into the temporal distance, scaled by the\n // maximum tail length from the gui\n float fade = ($current_time - $a_vertex_time) / $tail_length;\n alpha = clamp(1.0-fade, 0.0, 1.0);\n }\n\n // when use_fade is disabled, the entire track is visible\n if ($use_fade == 0) {\n alpha = 1.0;\n }\n\n // set the vertex alpha according to the fade\n v_track_color.a = alpha;\n }\n ", "a_vertex_time", "current_time", "tail_length", "use_fade" ], "napari/_vispy/camera.py": ["first"], "napari/_vispy/canvas.py": ["lequal", "mouse_wheel"], "napari/_vispy/experimental/__init__.py": [], "napari/_vispy/experimental/texture_atlas.py": [], "napari/_vispy/experimental/tile_grid.py": ["segments"], "napari/_vispy/experimental/tile_set.py": [], "napari/_vispy/experimental/tiled_image_visual.py": [ "auto", "color_transform", "linear", "nearest", "texture", "texture2D_LUT", "texture_lut", "clim", "clim_float", "gamma", "gamma_float", "null_color_transform", "red_to_luminance" ], "napari/_vispy/experimental/vispy_tiled_image_layer.py": [ "_update_drawn_chunks", "napari.octree.visual", "tiles: %d -> %d create: %d delete: %d time: %.3fms" ], "napari/_vispy/filters/points_clamp_size.py": [ "\n void clamp_size() {\n if ($active == 1) {\n gl_PointSize = clamp(gl_PointSize, $min, $max);\n }\n }\n ", "active", "max", "min" ], "napari/_vispy/filters/tracks.py": [ "\n varying vec4 v_track_color;\n void apply_track_shading() {\n\n // if the alpha is below the threshold, discard the fragment\n if( v_track_color.a <= 0.0 ) {\n discard;\n }\n\n // interpolate\n gl_FragColor.a = clamp(v_track_color.a * gl_FragColor.a, 0.0, 1.0);\n }\n ", "\n varying vec4 v_track_color;\n void apply_track_shading() {\n\n float alpha;\n\n if ($a_vertex_time > $current_time + $head_length) {\n // this is a hack to minimize the frag shader rendering ahead\n // of the current time point due to interpolation\n if ($a_vertex_time <= $current_time + 1){\n alpha = -100.;\n } else {\n alpha = 0.;\n }\n } else {\n // fade the track into the temporal distance, scaled by the\n // maximum tail and head length from the gui\n float fade = ($head_length + $current_time - $a_vertex_time) / ($tail_length + $head_length);\n alpha = clamp(1.0-fade, 0.0, 1.0);\n }\n\n // when use_fade is disabled, the entire track is visible\n if ($use_fade == 0) {\n alpha = 1.0;\n }\n\n // set the vertex alpha according to the fade\n v_track_color.a = alpha;\n }\n ", "a_vertex_time", "current_time", "head_length", "tail_length", "use_fade" ], "napari/_vispy/image.py": [], "napari/_vispy/layers/base.py": ["clipping_planes"], "napari/_vispy/layers/image.py": ["auto", "tile2data", "texture_float"], "napari/_vispy/layers/points.py": [ "blending", "transparent", "spherical", "translucent_no_depth", "edge_width", "edge_width_rel", "values" ], "napari/_vispy/layers/shapes.py": [ "blending", "constant", "square", "values" ], "napari/_vispy/layers/surface.py": [ "none", "texture2D_LUT", "texture_lut", "face", "vertex" ], "napari/_vispy/layers/tracks.py": ["white"], "napari/_vispy/layers/vectors.py": ["constant"], "napari/_vispy/markers.py": ["a_position"], "napari/_vispy/overlays/axes.py": ["center", "gl", "segments"], "napari/_vispy/overlays/interaction_box.py": ["square", "white", "disc"], "napari/_vispy/overlays/scale_bar.py": [ "center", "gl", "segments", "{new_dim:~}" ], "napari/_vispy/overlays/text.py": [ "bottom", "center", "left", "right", "top" ], "napari/_vispy/quaternion.py": [], "napari/_vispy/utils.py": [], "napari/_vispy/utils/gl.py": [ "additive", "one", "one_minus_src_alpha", "opaque", "src_alpha", "translucent", "translucent_no_depth", "zero", "func_add", "minimum", "min" ], "napari/_vispy/utils/text.py": ["center"], "napari/_vispy/vispy_axes_visual.py": [ "1", "canvas", "center", "gl", "segments" ], "napari/_vispy/vispy_base_layer.py": ["data2world"], "napari/_vispy/vispy_camera.py": ["first"], "napari/_vispy/vispy_canvas.py": ["canvas", "lequal", "mouse_wheel"], "napari/_vispy/vispy_image_layer.py": ["auto", "tile2data"], "napari/_vispy/vispy_points_layer.py": ["center", "transparent"], "napari/_vispy/vispy_scale_bar_visual.py": [ "canvas", "center", "gl", "segments", "1px", "{new_dim:~}" ], "napari/_vispy/vispy_shapes_layer.py": ["center", "constant", "square"], "napari/_vispy/vispy_surface_layer.py": [ "texture2D_LUT", "texture_lut", "none" ], "napari/_vispy/vispy_text_visual.py": [ "bottom", "center", "left", "right", "top" ], "napari/_vispy/vispy_tracks_layer.py": ["white"], "napari/_vispy/vispy_vectors_layer.py": ["constant"], "napari/_vispy/vispy_welcome_visual.py": [ "..", "background", "center", "gpu", "grays", "left", "logo.png", "primary", "resources" ], "napari/_vispy/visuals/clipping_planes_mixin.py": ["_PVisual"], "napari/_vispy/visuals/markers.py": [ "a_position", "\nfloat clamped_size = clamp($v_size, $canvas_size_min, $canvas_size_max);\nfloat clamped_ratio = clamped_size / $v_size;\n$v_size = clamped_size;\nv_edgewidth = v_edgewidth * clamped_ratio;\ngl_PointSize = $v_size + 4. * (v_edgewidth + 1.5 * u_antialias);\n", "vertex", "\n}", "fragment", "canvas_size_min", "canvas_size_max" ], "napari/_vispy/visuals/surface.py": ["face", "vertex"], "napari/_vispy/visuals/tracks.py": ["white"], "napari/_vispy/visuals/volume.py": [ "fragment", "iso_categorical", "void main()", "\n// the tolerance for testing equality of floats with floatEqual and floatNotEqual\nconst float equality_tolerance = 1e-8;\n\nbool floatNotEqual(float val1, float val2)\n{\n // check if val1 and val2 are not equal\n bool not_equal = abs(val1 - val2) > equality_tolerance;\n\n return not_equal;\n}\n\nbool floatEqual(float val1, float val2)\n{\n // check if val1 and val2 are equal\n bool equal = abs(val1 - val2) < equality_tolerance;\n\n return equal;\n}\n\n\n// the background value for the iso_categorical shader\nconst float categorical_bg_value = 0;\n\nint detectAdjacentBackground(float val_neg, float val_pos)\n{\n // determine if the adjacent voxels along an axis are both background\n int adjacent_bg = int( floatEqual(val_neg, categorical_bg_value) );\n adjacent_bg = adjacent_bg * int( floatEqual(val_pos, categorical_bg_value) );\n return adjacent_bg;\n}\n\nvec4 calculateCategoricalColor(vec4 betterColor, vec3 loc, vec3 step)\n{\n // Calculate color by incorporating ambient and diffuse lighting\n vec4 color0 = $get_data(loc);\n vec4 color1;\n vec4 color2;\n float val0 = colorToVal(color0);\n float val1 = 0;\n float val2 = 0;\n int n_bg_borders = 0;\n\n // View direction\n vec3 V = normalize(view_ray);\n\n // calculate normal vector from gradient\n vec3 N; // normal\n color1 = $get_data(loc+vec3(-step[0],0.0,0.0));\n color2 = $get_data(loc+vec3(step[0],0.0,0.0));\n val1 = colorToVal(color1);\n val2 = colorToVal(color2);\n N[0] = val1 - val2;\n n_bg_borders += detectAdjacentBackground(val1, val2);\n\n color1 = $get_data(loc+vec3(0.0,-step[1],0.0));\n color2 = $get_data(loc+vec3(0.0,step[1],0.0));\n val1 = colorToVal(color1);\n val2 = colorToVal(color2);\n N[1] = val1 - val2;\n n_bg_borders += detectAdjacentBackground(val1, val2);\n\n color1 = $get_data(loc+vec3(0.0,0.0,-step[2]));\n color2 = $get_data(loc+vec3(0.0,0.0,step[2]));\n val1 = colorToVal(color1);\n val2 = colorToVal(color2);\n N[2] = val1 - val2;\n n_bg_borders += detectAdjacentBackground(val1, val2);\n\n // Normalize and flip normal so it points towards viewer\n N = normalize(N);\n float Nselect = float(dot(N,V) > 0.0);\n N = (2.0*Nselect - 1.0) * N; // == Nselect * N - (1.0-Nselect)*N;\n\n // Init colors\n vec4 ambient_color = vec4(0.0, 0.0, 0.0, 0.0);\n vec4 diffuse_color = vec4(0.0, 0.0, 0.0, 0.0);\n vec4 final_color;\n\n // todo: allow multiple light, define lights on viewvox or subscene\n int nlights = 1;\n for (int i=0; i 0.0 );\n L = normalize(L+(1.0-lightEnabled));\n\n // Calculate lighting properties\n float lambertTerm = clamp( dot(N,L), 0.0, 1.0 );\n if (n_bg_borders > 0) {\n // to fix dim pixels due to poor normal estimation,\n // we give a default lambda to pixels surrounded by background\n lambertTerm = 0.5;\n }\n\n // Calculate mask\n float mask1 = lightEnabled;\n\n // Calculate colors\n ambient_color += mask1 * u_ambient; // * gl_LightSource[i].ambient;\n diffuse_color += mask1 * lambertTerm;\n }\n\n // Calculate final color by componing different components\n final_color = betterColor * ( ambient_color + diffuse_color);\n final_color.a = betterColor.a;\n\n // Done\n return final_color;\n}\n", "\n vec4 color3 = vec4(0.0); // final color\n vec3 dstep = 1.5 / u_shape; // step to sample derivative, set to match iso shader\n gl_FragColor = vec4(0.0);\n bool discard_fragment = true;\n ", "\n // check if value is different from the background value\n if ( floatNotEqual(val, categorical_bg_value) ) {\n // Take the last interval in smaller steps\n vec3 iloc = loc - step;\n for (int i=0; i<10; i++) {\n color = $get_data(iloc);\n if (floatNotEqual(color.g, categorical_bg_value) ) {\n // when the non-background value is reached\n // calculate the color (apply lighting effects)\n color = applyColormap(color.g);\n color = calculateCategoricalColor(color, iloc, dstep);\n gl_FragColor = color;\n\n // set the variables for the depth buffer\n frag_depth_point = iloc * u_shape;\n discard_fragment = false;\n\n iter = nsteps;\n break;\n }\n iloc += step * 0.1;\n }\n }\n ", "\n if (discard_fragment)\n discard;\n " ], "napari/_vispy/volume.py": [ "\n // Apply colormap on mean value\n gl_FragColor = applyColormap(meanval);\n ", "\n // Incremental mean value used for numerical stability\n n += 1; // Increment the counter\n prev_mean = meanval; // Update the mean for previous iteration\n meanval = prev_mean + (val - prev_mean) / n; // Calculate the mean\n ", "\n // Refine search for min value\n loc = start_loc + step * (float(mini) - 0.5);\n for (int i=0; i<10; i++) {\n minval = min(minval, $sample(u_volumetex, loc).g);\n loc += step * 0.1;\n }\n gl_FragColor = applyColormap(minval);\n ", "\n float maxval = -99999.0; // The maximum encountered value\n float sumval = 0.0; // The sum of the encountered values\n float scaled = 0.0; // The scaled value\n int maxi = 0; // Where the maximum value was encountered\n vec3 maxloc = vec3(0.0); // Location where the maximum value was encountered\n ", "\n float minval = 99999.0; // The minimum encountered value\n int mini = 0; // Where the minimum value was encountered\n ", "\n float n = 0; // Counter for encountered values\n float meanval = 0.0; // The mean of encountered values\n float prev_mean = 0.0; // Variable to store the previous incremental mean\n ", "\n gl_FragColor = applyColormap(maxval);\n ", "\n if( val < minval ) {\n minval = val;\n mini = iter;\n }\n ", "\n sumval = sumval + val;\n scaled = val * exp(-u_attenuation * (sumval - 1) / u_relative_step_size);\n if( scaled > maxval ) {\n maxval = scaled;\n maxi = iter;\n maxloc = loc;\n }\n ", "attenuated_mip", "average", "cmap", "minip", "texture2D_LUT", "texture_lut", "u_attenuation", "u_threshold" ], "napari/benchmarks/__init__.py": [], "napari/benchmarks/benchmark_image_layer.py": [], "napari/benchmarks/benchmark_labels_layer.py": [], "napari/benchmarks/benchmark_points_layer.py": [ "mask_shape", "num_points", "point_size" ], "napari/benchmarks/benchmark_qt_viewer.py": [], "napari/benchmarks/benchmark_qt_viewer_image.py": [], "napari/benchmarks/benchmark_qt_viewer_labels.py": ["paint", "mouse_move"], "napari/benchmarks/benchmark_shapes_layer.py": [ "Event", "is_dragging", "modifiers", "mouse_press", "mouse_release", "n_shapes", "polygon", "position", "select", "type" ], "napari/benchmarks/benchmark_surface_layer.py": [], "napari/benchmarks/benchmark_text_manager.py": [ "car", "cat", "constant", "float_property", "n", "string_property", "{string_property}: {float_property:.2f}", "string", "test", "list" ], "napari/benchmarks/benchmark_vectors_layer.py": [], "napari/components/__init__.py": [], "napari/components/_interaction_box_mouse_bindings.py": [ "mouse_move", "Shift", "mode", "napari:reset_active_layer_affine", "napari:transform_active_layer", "pan_zoom", "transform" ], "napari/components/_viewer_constants.py": [ "bottom_left", "bottom_right", "circle", "cross", "forbidden", "pointing", "square", "standard", "top_left", "top_right", "top_center", "bottom_center", "crosshair" ], "napari/components/_viewer_key_bindings.py": ["napari:{func.__name__}"], "napari/components/_viewer_mouse_bindings.py": ["Control"], "napari/components/axes.py": [], "napari/components/camera.py": ["angles", "center", "yzx"], "napari/components/cursor.py": [], "napari/components/dims.py": [ "axis_labels", "current_step", "ndim", "order", "range" ], "napari/components/experimental/__init__.py": [], "napari/components/experimental/chunk/__init__.py": [], "napari/components/experimental/chunk/_cache.py": [ "ChunkCache.add_chunk: cache is disabled", "ChunkCache.get_chunk: disabled", "add_chunk: %s", "found", "get_chunk: %s %s", "napari.loader.cache", "not found" ], "napari/components/experimental/chunk/_commands/__init__.py": [], "napari/components/experimental/chunk/_commands/_loader.py": [ "\n{highlight(\"Available Commands:\")}\nloader.help\nloader.cache\nloader.config\nloader.layers\nloader.levels(index)\nloader.loads(index)\nloader.set_default(index)\nloader.set_sync(index)\nloader.set_async(index)\n", "--", "???", "AVG (ms)", "CHUNKS", "DATA", "DURATION (ms)", "ID", "INDEX", "LAYER", "LEVEL", "LEVELS", "LOADS", "Layer ID", "Layer index {layer_index} has no LayerInfo.", "Layer index {layer_index} is invalid.", "Levels", "MBIT/s", "MODE", "Mbit/s", "NAME", "NONE", "Name", "SHAPE", "SIZE", "Shape", "TOTAL", "TYPE", "align", "async", "auto", "auto_sync_ms", "currsize", "delay_queue_ms", "enabled", "left", "loader", "log_path", "maxsize", "name", "num_workers", "sync", "synchronous", "use_processes", "{data[0].shape}", "{load.duration_ms:.1f}", "{load.mbits:.1f}", "{stats.mbits:.1f}", "{stats.window_ms.average:.1f}" ], "napari/components/experimental/chunk/_commands/_tables.py": [ "align", "left", "name", "right", "width", "{heading:>{heading_width}}", "{highlight(aligned)}: {value}", "{value:<{width}}", "{value_str:<{width}}", "{value_str:>{width}}" ], "napari/components/experimental/chunk/_commands/_utils.py": [ "\u001b{_code(color)}{string}\u001b[0m", "[{num_str}m", "black", "blue", "cyan", "green", "magenta", "red", "white", "yellow" ], "napari/components/experimental/chunk/_delay_queue.py": [ "DelayQueue.add: %s", "DelayQueue.submit: %s", "delay_queue", "napari.loader" ], "napari/components/experimental/chunk/_info.py": [ "LayerInfo.get_layer: layer %d was deleted", "async", "load_chunk", "load_ms", "mixed", "napari.loader", "num_bytes", "sync", "time" ], "napari/components/experimental/chunk/_loader.py": [ "\nThere is one global chunk_loader instance to handle async loading for all\nViewer instances. There are two main reasons we do this instead of one\nChunkLoader per Viewer:\n\n1. We size the ChunkCache as a fraction of RAM, so having more than one\n cache would use too much RAM.\n\n2. We might size the thread pool for optimal performance, and having\n multiple pools would result in more workers than we want.\n\nThink of the ChunkLoader as a shared resource like \"the filesystem\" where\nmultiple clients can be access it at the same time, but it is the interface\nto just one physical resource.\n", "%(levelname)s - %(name)s - %(message)s", "_done: load=%.3fms elapsed=%.3fms %s", "auto_sync_ms", "enabled", "force_synchronous", "loader_defaults", "log_path", "napari.loader", "napari.octree", "octree", "wait_for_data_id: no futures for data_id=%d", "wait_for_data_id: waiting on %d futures for data_id=%d" ], "napari/components/experimental/chunk/_pool.py": [ "Process pool num_workers=%d", "Thread pool num_workers=%d", "_submit_async: %s elapsed=%.3fms num_futures=%d", "cancel_requests: %d -> %d futures (cancelled %d)", "delay_queue_ms", "napari.loader", "num_workers", "use_processes" ], "napari/components/experimental/chunk/_pool_group.py": [ "loader_defaults", "loaders", "octree" ], "napari/components/experimental/chunk/_request.py": [ "image", "napari.loader", "thumbnail_source", "location=({self.level_index}, {self.row}, {self.col}) " ], "napari/components/experimental/chunk/_utils.py": ["EMPTY", "dask"], "napari/components/experimental/commands.py": [ "\n{highlight(\"Available Commands:\")}\nexperimental.cmds.loader\n", "Available Commands:\nexperimental.cmds.loader" ], "napari/components/experimental/monitor/__init__.py": [], "napari/components/experimental/monitor/_api.py": [ "127.0.0.1", "Ignore message that was not a dict: %s", "client_data", "client_messages", "napari", "napari.monitor", "napari_data", "napari_messages", "napari_shutdown" ], "napari/components/experimental/monitor/_monitor.py": [ "0", "Monitor: not starting, disabled", "Monitor: not starting, no usable config file", "Monitor: not starting, requires Python 3.9 or newer", "NAPARI_MON", "Writing to log path %s", "log_path", "napari.monitor" ], "napari/components/experimental/monitor/_service.py": [ "", "Listening on port %s", "MonitorService.stop", "NAPARI_MON_CLIENT", "Started %d clients.", "Starting %d clients...", "Starting client %s", "clients", "napari.monitor", "server_port" ], "napari/components/experimental/monitor/_utils.py": ["ascii"], "napari/components/experimental/remote/__init__.py": [], "napari/components/experimental/remote/_commands.py": [ "Calling RemoteCommands.%s(%s)", "RemoteCommands.%s does not exist.", "RemoveCommands._process_command: %s", "napari.monitor" ], "napari/components/experimental/remote/_manager.py": ["napari.monitor"], "napari/components/experimental/remote/_messages.py": [ "delta_ms", "frame_time", "layers", "napari.monitor", "poll", "time" ], "napari/components/grid.py": [], "napari/components/layerlist.py": [ "Extent", "data world step", "ignore", "WriterContribution", "link_layers", "unlink_layers", "extent", "_extent_world", "_step_size", "_ranges" ], "napari/components/scale_bar.py": [], "napari/components/text_overlay.py": [], "napari/components/viewer_model.py": [ "_mouse_drag_gen", "_mouse_wheel_gen", "_persisted_mouse_event", "active_layer", "add_", "affine", "attenuation", "axis_labels", "blending", "colormap", "contrast_limits", "dark", "data", "exclude", "gamma", "gray", "int", "interpolation", "iso_threshold", "keymap", "keyword argument ", "kwargs", "layer", "layers", "metadata", "mip", "mouse_drag_callbacks", "mouse_move_callbacks", "mouse_wheel_callbacks", "multiscale", "name", "napari", "napari.Viewer: {self.title}", "ndisplay", "nearest", "opacity", "order", "rendering", "rgb", "rotate", "scale", "self", "shear", "standard", "theme", "translate", "unexpected keyword argument", "visible", "ViewerModel", "valid_add_kwargs", "cache", "experimental_clipping_planes", "plane", "layers_change", "mode", "napari:{fun.__name__}", "interpolation2d", "0.6.0", "linear", "volume", "interpolation3d", "depiction", "translucent_no_depth", "uri", "builtins", "[{_path}], ...]" ], "napari/experimental/__init__.py": [], "napari/layers/__init__.py": [], "napari/layers/_data_protocols.py": [ "...", "__annotations__", "__dict__", "__weakref__" ], "napari/layers/_layer_actions.py": [ "colormap", "image", "int64", "labels", "max", "name", "rendering", "{layer} {mode}-proj", "scale", "translate", "rotate", "shear", "affine" ], "napari/layers/_source.py": ["_LAYER_SOURCE", "parent"], "napari/layers/base/__init__.py": [], "napari/layers/base/_base_constants.py": [], "napari/layers/base/base.py": [ "<{cls.__name__} layer {repr(self.name)} at {hex(id(self))}>", "Extent", "_round_index", "blending", "constant", "data", "data world step", "deselect", "ignore", "metadata", "name", "opacity", "rotate", "scale", "select", "shear", "standard", "tile2data", "translate", "translucent", "visible", "world2grid", "{cls.__name__}", "_double_click_modes", "data2physical", "experimental_clipping_planes", "extent", "keyword argument ", "physical2world", "unexpected keyword argument", "affine", "layer_base", "source_type", "plugin", "sample", "widget", " : ", "coordinates" ], "napari/layers/image/__init__.py": [], "napari/layers/image/_image_loader.py": [], "napari/layers/image/_image_mouse_bindings.py": ["Shift", "mouse_move"], "napari/layers/image/_image_slice.py": [ "ImageSlice.__init__", "f", "napari.loader" ], "napari/layers/image/_image_slice_data.py": [], "napari/layers/image/_image_utils.py": ["dtype", "image", "labels", "ndim"], "napari/layers/image/_image_view.py": [], "napari/layers/image/experimental/__init__.py": [], "napari/layers/image/experimental/_chunk_set.py": [], "napari/layers/image/experimental/_chunked_image_loader.py": [ "ChunkedImageLoader.load", "ChunkedImageLoader.match: accept %s", "ChunkedImageLoader.match: reject %s", "napari.loader" ], "napari/layers/image/experimental/_chunked_slice_data.py": [ "image", "napari.loader", "thumbnail_slice", "thumbnail_source" ], "napari/layers/image/experimental/_image_location.py": [ "location=({self.data_id}, {self.data_level}, {self.indices}) " ], "napari/layers/image/experimental/_octree_loader.py": [ "_cancel_load: Chunk did not exist %s", "data", "get_drawable_chunks: Starting with draw_set=%d ideal_chunks=%d", "napari.loader.futures", "napari.octree.loader" ], "napari/layers/image/experimental/_octree_slice.py": [ "data", "napari.octree.slice", "on_chunk_loaded: adding %s", "on_chunk_loaded: missing OctreeChunk: %s", "on_chunk_loaded: wrong slice_id: %s" ], "napari/layers/image/experimental/octree.py": [ "Created %d additional levels in %.3fms", "Multiscale data has %d levels.", "Octree now has %d total levels:", "_create_extra_levels", "napari.octree", "size={level.size} shape={level.shape} base level", "size={level.size} shape={level.shape} downscale={downscale}" ], "napari/layers/image/experimental/octree_chunk.py": [ "%s has %d chunks at %s", "%s has %d chunks:", "Chunk %d %s in_memory=%d loading=%d", "napari.octree", "{self.location}" ], "napari/layers/image/experimental/octree_image.py": [ "get_drawable_chunks: Intersection is empty", "get_drawable_chunks: No slice or view", "napari.octree.image", "on_chunk_loaded calling loaded()", "on_chunk_loaded: load=%.3fms elapsed=%.3fms location = %s", "tile_config", "tile_state" ], "napari/layers/image/experimental/octree_intersection.py": [ "OctreeView", "base_shape", "corners", "image_shape", "level_index", "seen", "shape_in_tiles", "tile_size" ], "napari/layers/image/experimental/octree_level.py": [ "({dim[0]}, {dim[1]}) = {intword(dim[0] * dim[1])}", "Level %d: %s pixels -> %s tiles", "napari.octree" ], "napari/layers/image/experimental/octree_tile_builder.py": [ "Downsampling levels to a single tile...", "Level %d downsampled %s in %.3fms", "downsampling", "napari.octree", "nearest" ], "napari/layers/image/experimental/octree_util.py": ["octree", "tile_size"], "napari/layers/image/image.py": [ "standard", "attenuation", "colormap", "contrast_limits", "data", "gamma", "gray", "ignore", "interpolation", "iso_threshold", "mip", "multiscale", "nearest", "rendering", "rgb", "tile2data", "translucent", "ndim", "dtype", "plane", "slice", "interpolation2d", "0.6.0", "linear", "volume", "select", "bilinear", "interpolation3d", "depiction" ], "napari/layers/intensity_mixin.py": ["_contrast_limits", "Image", "slice"], "napari/layers/labels/__init__.py": [], "napari/layers/labels/_labels_constants.py": [ "backspace", "darwin", "delete" ], "napari/layers/labels/_labels_mouse_bindings.py": ["mouse_move"], "napari/layers/labels/_labels_utils.py": ["dtype"], "napari/layers/labels/labels.py": [ "black", "circle", "color", "cross", "data", "index", "multiscale", "nearest", "num_colors", "properties", "seed", "standard", "translucent", "transparent", "; ", "_color", "experimental_clipping_planes", "plane", "iso_categorical", "rendering", "{k}: {v[idx]}", "features", "volume", "depiction", "coordinates", "xarray.DataArray" ], "napari/layers/points/__init__.py": [], "napari/layers/points/_points_constants.py": [ "*", "+", "-", "->", ">", "^", "o", "s", "tailed_arrow", "triangle_down", "triangle_up", "v", "|" ], "napari/layers/points/_points_key_bindings.py": ["mode"], "napari/layers/points/_points_mouse_bindings.py": [ "Control", "Shift", "mouse_move", "mouse_press", "mouse_release" ], "napari/layers/points/_points_utils.py": [], "napari/layers/points/points.py": [ "_{attribute}", "current_value", "data", "edge", "edge_color", "edge_color_cycle", "edge_colormap", "edge_contrast_limits", "edge_width", "face", "face_color", "face_color_cycle", "face_colormap", "face_contrast_limits", "indices", "n_dimensional", "name", "ndim", "o", "properties", "size", "standard", "symbol", "text", "translucent", "values", "viridis", "white", "property_choices", "crosshair", "ij", "; ", "features", "index", "none", "out_of_slice_display", "shading", "shown", "{k}: {v[value]}", "dimgray", "edge_width_is_relative", "antialiasing", "canvas_size_limits", "coordinates" ], "napari/layers/shapes/__init__.py": [], "napari/layers/shapes/_mesh.py": ["edge", "face"], "napari/layers/shapes/_shape_list.py": [ "edge", "face", "int", "update_{attribute}_colors" ], "napari/layers/shapes/_shapes_constants.py": [ "backspace", "darwin", "delete" ], "napari/layers/shapes/_shapes_key_bindings.py": ["mode"], "napari/layers/shapes/_shapes_models/__init__.py": [], "napari/layers/shapes/_shapes_models/_polgyon_base.py": ["int", "polygon"], "napari/layers/shapes/_shapes_models/ellipse.py": ["ellipse", "int"], "napari/layers/shapes/_shapes_models/line.py": ["int", "line"], "napari/layers/shapes/_shapes_models/path.py": ["path"], "napari/layers/shapes/_shapes_models/polygon.py": ["polygon"], "napari/layers/shapes/_shapes_models/rectangle.py": ["int", "rectangle"], "napari/layers/shapes/_shapes_models/shape.py": ["int", "rectangle"], "napari/layers/shapes/_shapes_mouse_bindings.py": [ "Shift", "ellipse", "line", "mouse_move", "path", "rectangle" ], "napari/layers/shapes/_shapes_utils.py": [ "polygon", "p", "vertices", "triangles", "ij,ij->i" ], "napari/layers/shapes/shapes.py": [ "_current_{attribute}_color", "_{attribute}_color_cycle", "_{attribute}_color_cycle_values", "_{attribute}_color_mode", "_{attribute}_color_property", "_{attribute}_contrast_limits", "black", "cross", "data", "edge", "edge_color", "edge_color_cycle", "edge_colormap", "edge_contrast_limits", "edge_width", "face", "face_color", "face_color_cycle", "face_colormap", "face_contrast_limits", "indices", "int", "ndim", "opacity", "pointing", "properties", "rectangle", "shape_type", "standard", "text", "translucent", "viridis", "white", "z_index", "{attribute}_color", "{attribute}_color_cycle", "{attribute}_color_cycle_map", "{attribute}_colormap", "{attribute}_contrast_limits", "#777777", "ellipse", "line", "path", "polygon", "property_choices", "features" ], "napari/layers/surface/__init__.py": [], "napari/layers/surface/normals.py": ["black", "blue", "orange"], "napari/layers/surface/surface.py": [ "colormap", "contrast_limits", "data", "gamma", "gray", "int", "translucent", "flat", "shading", "normals", "wireframe" ], "napari/layers/surface/wireframe.py": ["black"], "napari/layers/tracks/__init__.py": [], "napari/layers/tracks/_track_utils.py": ["ID:{i}", "track_id"], "napari/layers/tracks/tracks.py": [ "additive", "color_by", "colormap", "colormaps_dict", "constant", "data", "graph", "properties", "tail_length", "tail_width", "track_id", "turbo", "head_length", "features" ], "napari/layers/utils/__init__.py": [], "napari/layers/utils/_color_manager_constants.py": [ "colormap", "cycle", "direct" ], "napari/layers/utils/_link_layers.py": [ "Set {attr!r} on {l1} to that of {l2}", "_", "data", "name", "set_{attr}_on_layer_{id(l2)}", "status", "thumbnail", "ReferenceType[Layer]" ], "napari/layers/utils/_text_constants.py": [], "napari/layers/utils/_text_utils.py": [ "bottom", "center", "left", "right", "top" ], "napari/layers/utils/color_encoding.py": [ "The default color to use, which may also be used a safe fallback color.", "cyan", "ColorEncoding", "ConstantColorEncoding", "ManualColorEncoding", "DirectColorEncoding", "NominalColorEncoding", "QuantitativeColorEncoding", "colormap", "contrast_limits" ], "napari/layers/utils/color_manager.py": [ "categorical_colormap", "color", "color_mode", "color_properties", "colors", "continuous_colormap", "contrast_limits", "current_color", "current_value", "n_colors", "name", "values", "viridis", "white" ], "napari/layers/utils/color_manager_utils.py": [ "categorical_colormap", "color_properties", "continuous_colormap", "contrast_limits", "current_color" ], "napari/layers/utils/color_transformations.py": [], "napari/layers/utils/interactivity_utils.py": ["j, ij -> i"], "napari/layers/utils/layer_utils.py": ["b", "f", "u", "ui", "napari:"], "napari/layers/utils/plane.py": ["normal", "position"], "napari/layers/utils/stack_utils.py": [ "additive", "affine", "blending", "colormap", "contrast_limits", "gray", "image", "metadata", "multiscale", "name", "rgb", "rotate", "scale", "shear", "translate", "{name} layer {i}", "blue", "experimental_clipping_planes", "plane", "green", "red", "translucent_no_depth" ], "napari/layers/utils/string_encoding.py": [ "A scalar array that represents one string value.", "An Nx1 array where each element represents one string value.", "The default string value, which may also be used a safe fallback string.", "", "=", "ERROR #{n + 1}: {str(err)} {\"-\" * _pad}", "{\"module\": >16}: {err0.plugin}", "{\"napari version\": >16}: {__version__}", "{\"plugin package\": >16}: {package_meta[\"package\"]}", "{\"version\": >16}: {package_meta[\"version\"]}", "Neutral" ], "napari/plugins/hook_specifications.py": [], "napari/plugins/hub.py": [ "https://api.napari-hub.org/plugins", "https://api.anaconda.org/package/{channel}/{package_name}", "/", "name", "version", "authors", "summary", "license", "project_site", "conda-forge", "versions", "development_status", "Development Status :: {1}", "1.0", "UNKNOWN" ], "napari/plugins/io.py": [ "napari_write_{layer._type_string}", "Falling back to original plugin engine.", "Writing to {path}. Hook caller: {hook_caller}", "builtins" ], "napari/plugins/pypi.py": [ "name", "briefcase", "constructor", "jupyter", "ipython", "python", "runtime", "{k}/{v}", "https://npe2api.vercel.app/api/summary", "User-Agent", "https://npe2api.vercel.app/api/conda", "display_name" ], "napari/plugins/utils.py": ["napari_get_reader", "*", "?", "[-_.]+"], "napari/qt/__init__.py": [], "napari/qt/progress.py": ["progrange", "progress"], "napari/qt/threading.py": [], "napari/resources/__init__.py": [], "napari/resources/_icons.py": [ "(]*>)", ".svg", "", "", "\\1{svg_style.format(color, opacity)}", "icons", "{color}/{svg_stem}{opacity}.svg", "_{op * 100:.0f}", "_themes", "icon", "warning", "logo_silhouette", "background" ], "napari/resources/_qt_resources_0.4.13.dev128+g012fea64.d20211026_pyqt5_5.15.2.py": [ "\u0000\u0000\u0002u\n\n\n\n\n\n\u0000\u0000\u0003W\n\n\n\n\n\n\u0000\u0000\u0003i\n\n\n\n\n\n\u0000\u0000\u0003\u00e2\n\n\n\n\n\n\n\n\n\n\n\n\n\u0000\u0000\u0003S\n\n\n\n\t\n\t\t\n\t\n\n\n\n\u0000\u0000\u0002\u00c1\n\n\n\n\n\n\u0000\u0000\u0002J\n\n\n\n\n\u0000\u0000\u0002E\n\n\n\n\n\u0000\u0000\u0003>\n Artboard 1\n \n\n\u0000\u0000\u0002E\n\n\n\n\n\u0000\u0000\u0003]\n\n\n\n\n\n\u0000\u0000\u0002\u008c\n\n\n\n\n\u0000\u0000\u0003\u00c1\n\n\n\n\t\n\t\t\n\t\t\n\t\n\n\n\u0000\u0000\u0004K\n\n\n\n\n\n\n\u0000\u0000\u0003S\n\n\n\n\n\n\u0000\u0000\u0002R\n\n\n\n\n\u0000\u0000\u0002I\n\n\n\n\n\u0000\u0000\u0002\u00cd\n\n\n\n\n\u0000\u0000\u0003\u00c5\n\n\n\n\n\n\n\u0000\u0000\u0003\u00a2\n\n\n\n\t\n\t\t\n\t\n\n\n\n\u0000\u0000\u0006\u000f\n\n\n\n\n\n\n\n\n\n\n\u0000\u0000\t\u00ab\n\n\n\n\t\n\t\n\t\n\t\n\t\n\t\n\n\n\u0000\u0000\u0002\u00f7\n\n\n\n\n\n\n\n\n\n\u0000\u0000\u0002\u00ba\n\n\n\n\n\n\u0000\u0000\u0002\u00f8\n\n\n\n\n\u0000\u0000\u0003\u0081\n\n\n\n\t\n\t\t\n\t\t\n\t\t\n\t\t\n\t\n\n\n\u0000\u0000\u0003\u00a8\n\n\n\n\n\n\n\u0000\u0000\u0006\u008e\n\n\n\n\t\n\n\n\n\u0000\u0000\u0002E\n\n\n\n\n\u0000\u0000\u0006.\n\n\n\n\n\n\n\n\n\n\n\n\n\u0000\u0000\u0004L\n\n\n\n\t\n\t\n\t\n\t\n\t\n\t\n\n\n\u0000\u0000\u0006\u0013\n\n\n\n\n\n\n\n\n\n\n\n\u0000\u0000\u0003[\n\n\n\n\n\n\u0000\u0000\u0002E\n\n\n\n\n\u0000\u0000\u0002v\n Artboard 1\n \n\n\u0000\u0000\u0002\u00fc\n\n\n\n\n\n\u0000\u0000\u0003\u008c\n\n\n\n\n\n\u0000\u0000\u0003\u00d1\n\n\n\n\n\n\n\n\u0000\u0000\u0002\u0081\n\n\n\n\n\n\u0000\u0000\u0003\u00b0\n Artboard 1\n \n\n\u0000\u0000\u0003\u00dd\n\n\n\n\n\n\n\u0000\u0000\u0003g\n\n\n\n\t\n\t\t\n\t\n\n\n\u0000\u0000\u0002\u0090\u0000\u0000\u0004\u0001\n\n\n\n\n\n\n\u0000\u0000\u0004S\n\n\n\n\t\n\t\t\n\t\n\n\n\t\n\t\t\n\t\n\n\n\n\u0000\u0000\u0001\u0094\n \n \n \n\n\u0000\u0000\u0003\u0095\n\n\n\n\n\u0000\u0000\u0005\u00a9\n\n\n\n\t\n\n\n\u0000\u0000\u0003]\n\n\n\n\n\n\u0000\u0000\u0004+\n\n\n\n\t\n\t\t\n\t\n\n\n\t\n\t\t\n\t\n\n\n\n\u0000\u0000\u0002\u00c5\n\n\n\n\t\n\t\t\n\t\n\n\n\u0000\u0000\u0004Y\n\n\n\n\t\n\n\n\u0000\u0000\u0001\u0081\n \n \n \n\n\u0000\u0000\u0002E\n\n\n\n\n\u0000\u0000\u00050\n\n\n\n\n\n\n\n\n\n\u0000\u0000\u0002\u00b9\u0000\u0000\u0002u\n\n\n\n\n\n\u0000\u0000\u0003W\n\n\n\n\n\n\u0000\u0000\u0003i\n\n\n\n\n\n\u0000\u0000\u0003\u00e2\n\n\n\n\n\n\n\n\n\n\n\n\n\u0000\u0000\u0003S\n\n\n\n\t\n\t\t\n\t\n\n\n\n\u0000\u0000\u0002\u00c1\n\n\n\n\n\n\u0000\u0000\u0002J\n\n\n\n\n\u0000\u0000\u0002E\n\n\n\n\n\u0000\u0000\u0003>\n Artboard 1\n \n\n\u0000\u0000\u0002E\n\n\n\n\n\u0000\u0000\u0003]\n\n\n\n\n\n\u0000\u0000\u0002\u008c\n\n\n\n\n\u0000\u0000\u0003\u00c1\n\n\n\n\t\n\t\t\n\t\t\n\t\n\n\n\u0000\u0000\u0004K\n\n\n\n\n\n\n\u0000\u0000\u0003G\n\n\n\n\n\n\u0000\u0000\u0002R\n\n\n\n\n\u0000\u0000\u0002I\n\n\n\n\n\u0000\u0000\u0002\u00cd\n\n\n\n\n\u0000\u0000\u0003\u00c5\n\n\n\n\n\n\n\u0000\u0000\u0003\u00a2\n\n\n\n\t\n\t\t\n\t\n\n\n\n\u0000\u0000\u0006\u000f\n\n\n\n\n\n\n\n\n\n\n\u0000\u0000\t\u00ab\n\n\n\n\t\n\t\n\t\n\t\n\t\n\t\n\n\n\u0000\u0000\u0002\u00f7\n\n\n\n\n\n\n\n\n\n\u0000\u0000\u0002\u00ba\n\n\n\n\n\n\u0000\u0000\u0002\u00f8\n\n\n\n\n\u0000\u0000\u0003\u0081\n\n\n\n\t\n\t\t\n\t\t\n\t\t\n\t\t\n\t\n\n\n\u0000\u0000\u0003\u00a8\n\n\n\n\n\n\n\u0000\u0000\u0006\u008e\n\n\n\n\t\n\n\n\n\u0000\u0000\u0002E\n\n\n\n\n\u0000\u0000\u0006.\n\n\n\n\n\n\n\n\n\n\n\n\n\u0000\u0000\u0004L\n\n\n\n\t\n\t\n\t\n\t\n\t\n\t\n\n\n\u0000\u0000\u0006\u0013\n\n\n\n\n\n\n\n\n\n\n\n\u0000\u0000\u0003[\n\n\n\n\n\n\u0000\u0000\u0002E\n\n\n\n\n\u0000\u0000\u0002v\n Artboard 1\n \n\n\u0000\u0000\u0002\u00fc\n\n\n\n\n\n\u0000\u0000\u0003\u008c\n\n\n\n\n\n\u0000\u0000\u0003\u00d1\n\n\n\n\n\n\n\n\u0000\u0000\u0002\u0081\n\n\n\n\n\n\u0000\u0000\u0003\u00b0\n Artboard 1\n \n\n\u0000\u0000\u0003\u00dd\n\n\n\n\n\n\n\u0000\u0000\u0003g\n\n\n\n\t\n\t\t\n\t\n\n\n\u0000\u0000\u0002\u0090\u0000\u0000\u0004\u0001\n\n\n\n\n\n\n\u0000\u0000\u0004S\n\n\n\n\t\n\t\t\n\t\n\n\n\t\n\t\t\n\t\n\n\n\n\u0000\u0000\u0001\u0094\n \n \n \n\n\u0000\u0000\u0003\u0095\n\n\n\n\n\u0000\u0000\u0005\u00a9\n\n\n\n\t\n\n\n\u0000\u0000\u0003]\n\n\n\n\n\n\u0000\u0000\u0004+\n\n\n\n\t\n\t\t\n\t\n\n\n\t\n\t\t\n\t\n\n\n\n\u0000\u0000\u0002\u00c5\n\n\n\n\t\n\t\t\n\t\n\n\n\u0000\u0000\u0004Y\n\n\n\n\t\n\n\n\u0000\u0000\u0001\u0081\n \n \n \n\n\u0000\u0000\u0002E\n\n\n\n\n\u0000\u0000\u00050\n\n\n\n\n\n\n\n\n\n\u0000\u0000\u0002\u00b9", "\u0000\u0006\u0007\u00ae\u00c3\u00c3\u0000t\u0000h\u0000e\u0000m\u0000e\u0000s\u0000\u0005\u0000r\u00fd\u00f4\u0000l\u0000i\u0000g\u0000h\u0000t\u0000\u0004\u0000\u0006\u00a8\u008b\u0000d\u0000a\u0000r\u0000k\u0000\u000e\b{\u0095\u0087\u0000s\u0000t\u0000e\u0000p\u0000_\u0000r\u0000i\u0000g\u0000h\u0000t\u0000.\u0000s\u0000v\u0000g\u0000\u000b\u0006)\u0096\u0007\u0000p\u0000o\u0000p\u0000_\u0000o\u0000u\u0000t\u0000.\u0000s\u0000v\u0000g\u0000\u000e\u0005\u009a\b\u00e7\u0000n\u0000e\u0000w\u0000_\u0000l\u0000a\u0000b\u0000e\u0000l\u0000s\u0000.\u0000s\u0000v\u0000g\u0000\b\b\u00f7W\u0007\u0000g\u0000r\u0000i\u0000d\u0000.\u0000s\u0000v\u0000g\u0000\r\u0001\u0088\u00ef\u00c7\u0000t\u0000r\u0000a\u0000n\u0000s\u0000p\u0000o\u0000s\u0000e\u0000.\u0000s\u0000v\u0000g\u0000\u0013\u0003Q\u00b0\u00c7\u0000l\u0000o\u0000n\u0000g\u0000_\u0000l\u0000e\u0000f\u0000t\u0000_\u0000a\u0000r\u0000r\u0000o\u0000w\u0000.\u0000s\u0000v\u0000g\u0000\n\b\u008b\u000b\u00a7\u0000s\u0000q\u0000u\u0000a\u0000r\u0000e\u0000.\u0000s\u0000v\u0000g\u0000\u000e\u000e\u00de\u00f7G\u0000l\u0000e\u0000f\u0000t\u0000_\u0000a\u0000r\u0000r\u0000o\u0000w\u0000.\u0000s\u0000v\u0000g\u0000\b\u0000/Wg\u0000f\u0000i\u0000l\u0000l\u0000.\u0000s\u0000v\u0000g\u0000\f\u0006\u00e6\u00eb\u00e7\u0000u\u0000p\u0000_\u0000a\u0000r\u0000r\u0000o\u0000w\u0000.\u0000s\u0000v\u0000g\u0000\u0010\u000e\u0017*\u0087\u0000c\u0000h\u0000e\u0000v\u0000r\u0000o\u0000n\u0000_\u0000d\u0000o\u0000w\u0000n\u0000.\u0000s\u0000v\u0000g\u0000\u000f\rD\u0018g\u0000n\u0000e\u0000w\u0000_\u0000s\u0000u\u0000r\u0000f\u0000a\u0000c\u0000e\u0000.\u0000s\u0000v\u0000g\u0000\u0007\u0007\u00a7Z\u0007\u0000a\u0000d\u0000d\u0000.\u0000s\u0000v\u0000g\u0000\r\u000eN\u009bg\u0000n\u0000e\u0000w\u0000_\u0000i\u0000m\u0000a\u0000g\u0000e\u0000.\u0000s\u0000v\u0000g\u0000\u000e\u0001\u0087]\u00e7\u0000v\u0000i\u0000s\u0000i\u0000b\u0000i\u0000l\u0000i\u0000t\u0000y\u0000.\u0000s\u0000v\u0000g\u0000\u000e\u000fkz\u00e7\u0000n\u0000e\u0000w\u0000_\u0000s\u0000h\u0000a\u0000p\u0000e\u0000s\u0000.\u0000s\u0000v\u0000g\u0000\t\u0005\u00c6\u00b2\u00c7\u0000m\u0000i\u0000n\u0000u\u0000s\u0000.\u0000s\u0000v\u0000g\u0000\u0006\u0003gZ\u00c7\u00002\u0000D\u0000.\u0000s\u0000v\u0000g\u0000\u0012\u0002\u00eaZ\u0007\u0000v\u0000i\u0000s\u0000i\u0000b\u0000i\u0000l\u0000i\u0000t\u0000y\u0000_\u0000o\u0000f\u0000f\u0000.\u0000s\u0000v\u0000g\u0000\n\u000b\u00aa;\u00c7\u0000d\u0000i\u0000r\u0000e\u0000c\u0000t\u0000.\u0000s\u0000v\u0000g\u0000\b\b\u00abT\u0007\u0000p\u0000a\u0000t\u0000h\u0000.\u0000s\u0000v\u0000g\u0000\n\u0001\u00cb\u0085\u0087\u0000p\u0000i\u0000c\u0000k\u0000e\u0000r\u0000.\u0000s\u0000v\u0000g\u0000\u000e\f\u001a\u00ad\u00e7\u0000n\u0000e\u0000w\u0000_\u0000p\u0000o\u0000i\u0000n\u0000t\u0000s\u0000.\u0000s\u0000v\u0000g\u0000\r\u000e\u008f\u0097g\u0000s\u0000t\u0000e\u0000p\u0000_\u0000l\u0000e\u0000f\u0000t\u0000.\u0000s\u0000v\u0000g\u0000\u0015\u000b>\u000e\u0007\u0000c\u0000o\u0000p\u0000y\u0000_\u0000t\u0000o\u0000_\u0000c\u0000l\u0000i\u0000p\u0000b\u0000o\u0000a\u0000r\u0000d\u0000.\u0000s\u0000v\u0000g\u0000\n\f\u00ad\u0002\u0087\u0000d\u0000e\u0000l\u0000e\u0000t\u0000e\u0000.\u0000s\u0000v\u0000g\u0000\u000e\u000b\u00c1\u00fc\u00e7\u0000m\u0000o\u0000v\u0000e\u0000_\u0000f\u0000r\u0000o\u0000n\u0000t\u0000.\u0000s\u0000v\u0000g\u0000\b\u0006/U\u00e7\u0000r\u0000o\u0000l\u0000l\u0000.\u0000s\u0000v\u0000g\u0000\u000e\u0004\u00a2\u00f1'\u0000d\u0000o\u0000w\u0000n\u0000_\u0000a\u0000r\u0000r\u0000o\u0000w\u0000.\u0000s\u0000v\u0000g\u0000\u000f\fN\u00fc\u0087\u0000n\u0000e\u0000w\u0000_\u0000v\u0000e\u0000c\u0000t\u0000o\u0000r\u0000s\u0000.\u0000s\u0000v\u0000g\u0000\u0006\u0003wZ\u00c7\u00003\u0000D\u0000.\u0000s\u0000v\u0000g\u0000\u000b\u0007P<\u00c7\u0000e\u0000l\u0000l\u0000i\u0000p\u0000s\u0000e\u0000.\u0000s\u0000v\u0000g\u0000\u000e\u000bXl\u00a7\u0000c\u0000h\u0000e\u0000v\u0000r\u0000o\u0000n\u0000_\u0000u\u0000p\u0000.\u0000s\u0000v\u0000g\u0000\r\u0002\r\u0090\u0007\u0000d\u0000r\u0000o\u0000p\u0000_\u0000d\u0000o\u0000w\u0000n\u0000.\u0000s\u0000v\u0000g\u0000\t\b\u0098\u0083\u00c7\u0000e\u0000r\u0000a\u0000s\u0000e\u0000.\u0000s\u0000v\u0000g\u0000\u0010\u0001,9\u00a7\u0000d\u0000e\u0000l\u0000e\u0000t\u0000e\u0000_\u0000s\u0000h\u0000a\u0000p\u0000e\u0000.\u0000s\u0000v\u0000g\u0000\b\u0006`J\u00e7\u0000z\u0000o\u0000o\u0000m\u0000.\u0000s\u0000v\u0000g\u0000\r\u000fG0\u0007\u0000m\u0000o\u0000v\u0000e\u0000_\u0000b\u0000a\u0000c\u0000k\u0000.\u0000s\u0000v\u0000g\u0000\u0014\u000b\u00a3q\u00a7\u0000l\u0000o\u0000n\u0000g\u0000_\u0000r\u0000i\u0000g\u0000h\u0000t\u0000_\u0000a\u0000r\u0000r\u0000o\u0000w\u0000.\u0000s\u0000v\u0000g\u0000\u000b\r\u00d7\u00adG\u0000s\u0000h\u0000u\u0000f\u0000f\u0000l\u0000e\u0000.\u0000s\u0000v\u0000g\u0000\b\u0000HT\u00a7\u0000l\u0000i\u0000n\u0000e\u0000.\u0000s\u0000v\u0000g\u0000\n\u000b\u00a8b\u0087\u0000s\u0000e\u0000l\u0000e\u0000c\u0000t\u0000.\u0000s\u0000v\u0000g\u0000\b\u0004\u00d2T\u00c7\u0000i\u0000n\u0000f\u0000o\u0000.\u0000s\u0000v\u0000g\u0000\u000b\u0006\u00f4\u0091\u0087\u0000c\u0000o\u0000n\u0000s\u0000o\u0000l\u0000e\u0000.\u0000s\u0000v\u0000g\u0000\u0011\u0004.wG\u0000v\u0000e\u0000r\u0000t\u0000e\u0000x\u0000_\u0000i\u0000n\u0000s\u0000e\u0000r\u0000t\u0000.\u0000s\u0000v\u0000g\u0000\t\u000b\u009e\u0089\u0007\u0000c\u0000h\u0000e\u0000c\u0000k\u0000.\u0000s\u0000v\u0000g\u0000\b\u00068W'\u0000h\u0000o\u0000m\u0000e\u0000.\u0000s\u0000v\u0000g\u0000\r\u000fU\u000b\u00a7\u0000r\u0000e\u0000c\u0000t\u0000a\u0000n\u0000g\u0000l\u0000e\u0000.\u0000s\u0000v\u0000g\u0000\u0010\u0000\u00e1-\u00a7\u0000c\u0000h\u0000e\u0000v\u0000r\u0000o\u0000n\u0000_\u0000l\u0000e\u0000f\u0000t\u0000.\u0000s\u0000v\u0000g\u0000\u0011\u000ezm\u00e7\u0000v\u0000e\u0000r\u0000t\u0000e\u0000x\u0000_\u0000r\u0000e\u0000m\u0000o\u0000v\u0000e\u0000.\u0000s\u0000v\u0000g\u0000\b\u0003\u00c6T'\u0000p\u0000l\u0000u\u0000s\u0000.\u0000s\u0000v\u0000g\u0000\t\u0000W\u00b7\u00c7\u0000p\u0000a\u0000i\u0000n\u0000t\u0000.\u0000s\u0000v\u0000g\u0000\n\n-\u001b\u00c7\u0000c\u0000i\u0000r\u0000c\u0000l\u0000e\u0000.\u0000s\u0000v\u0000g\u0000\u000f\u0002\u009f\b\u0007\u0000r\u0000i\u0000g\u0000h\u0000t\u0000_\u0000a\u0000r\u0000r\u0000o\u0000w\u0000.\u0000s\u0000v\u0000g\u0000\u000b\u000e\u00cf\u009d'\u0000p\u0000o\u0000l\u0000y\u0000g\u0000o\u0000n\u0000.\u0000s\u0000v\u0000g\u0000\u000b\u0000\u00b5Hg\u0000w\u0000a\u0000r\u0000n\u0000i\u0000n\u0000g\u0000.\u0000s\u0000v\u0000g", "\u0000\u0000\u0000\u0000\u0000\u0002\u0000\u0000\u0000\u0001\u0000\u0000\u0000\u0001\u0000\u0000\u0000\u0000\u0000\u0002\u0000\u0000\u0000\u0002\u0000\u0000\u0000\u0002\u0000\u0000\u0000\"\u0000\u0002\u0000\u0000\u00008\u0000\u0000\u0000<\u0000\u0000\u0000\u0012\u0000\u0002\u0000\u0000\u00008\u0000\u0000\u0000\u0004\u0000\u0000\u0001.\u0000\u0000\u0000\u0000\u0000\u0001\u0000\u0000\u00de\u00e9\u0000\u0000\u0004\u00fa\u0000\u0000\u0000\u0000\u0000\u0001\u0000\u0001V\u0083\u0000\u0000\u00066\u0000\u0000\u0000\u0000\u0000\u0001\u0000\u0001}\u00f6\u0000\u0000\u0006\u00a8\u0000\u0000\u0000\u0000\u0000\u0001\u0000\u0001\u008bU\u0000\u0000\u0005\u00d2\u0000\u0000\u0000\u0000\u0000\u0001\u0000\u0001s\u009d\u0000\u0000\u0004T\u0000\u0000\u0000\u0000\u0000\u0001\u0000\u0001E\u00e5\u0000\u0000\u0001\u00e0\u0000\u0000\u0000\u0000\u0000\u0001\u0000\u0000\u00f2y\u0000\u0000\u0000\u00a6\u0000\u0000\u0000\u0000\u0000\u0001\u0000\u0000\u00d46\u0000\u0000\u0002\u00a8\u0000\u0000\u0000\u0000\u0000\u0001\u0000\u0001\n\u00ba\u0000\u0000\u0004\u001c\u0000\u0000\u0000\u0000\u0000\u0001\u0000\u0001A\"\u0000\u0000\u0006h\u0000\u0000\u0000\u0000\u0000\u0001\u0000\u0001\u0083\u00d8\u0000\u0000\u0002N\u0000\u0000\u0000\u0000\u0000\u0001\u0000\u0000\u00fd8\u0000\u0000\u0000\u00c6\u0000\u0000\u0000\u0000\u0000\u0001\u0000\u0000\u00d7\u008d\u0000\u0000\u0002<\u0000\u0000\u0000\u0000\u0000\u0001\u0000\u0000\u00fag\u0000\u0000\u0003\u00cc\u0000\u0000\u0000\u0000\u0000\u0001\u0000\u00013\\\u0000\u0000\u0006 \u0000\u0000\u0000\u0000\u0000\u0001\u0000\u0001{-\u0000\u0000\u0005\\\u0000\u0000\u0000\u0000\u0000\u0001\u0000\u0001dh\u0000\u0000\u0003\u0086\u0000\u0000\u0000\u0000\u0000\u0001\u0000\u0001*\u00e1\u0000\u0000\u0005*\u0000\u0000\u0000\u0000\u0000\u0001\u0000\u0001]\u00cf\u0000\u0000\u0000n\u0000\u0000\u0000\u0000\u0000\u0001\u0000\u0000\u00cc\u00e3\u0000\u0000\u0002$\u0000\u0000\u0000\u0000\u0000\u0001\u0000\u0000\u00f8\u001a\u0000\u0000\u0000R\u0000\u0000\u0000\u0000\u0000\u0001\u0000\u0000\u00c9\u0088\u0000\u0000\u0003p\u0000\u0000\u0000\u0000\u0000\u0001\u0000\u0001$O\u0000\u0000\u0005\u009c\u0000\u0000\u0000\u0000\u0000\u0001\u0000\u0001jW\u0000\u0000\u0004z\u0000\u0000\u0000\u0000\u0000\u0001\u0000\u0001H\u00e5\u0000\u0000\u0001D\u0000\u0000\u0000\u0000\u0000\u0001\u0000\u0000\u00e2+\u0000\u0000\u0005@\u0000\u0000\u0000\u0000\u0000\u0001\u0000\u0001`c\u0000\u0000\u0003\u00de\u0000\u0000\u0000\u0000\u0000\u0001\u0000\u00017\u00ac\u0000\u0000\u0001\u00ac\u0000\u0000\u0000\u0000\u0000\u0001\u0000\u0000\u00eae\u0000\u0000\u00000\u0000\u0000\u0000\u0000\u0000\u0001\u0000\u0000\u00c7\u000f\u0000\u0000\u0000\u00f2\u0000\u0000\u0000\u0000\u0000\u0001\u0000\u0000\u00daR\u0000\u0000\u0004<\u0000\u0000\u0000\u0000\u0000\u0001\u0000\u0001Ck\u0000\u0000\u0002\u0092\u0000\u0000\u0000\u0000\u0000\u0001\u0000\u0001\u0004\u00a7\u0000\u0000\u0000\u0090\u0000\u0000\u0000\u0000\u0000\u0001\u0000\u0000\u00d0P\u0000\u0000\u0006N\u0000\u0000\u0000\u0000\u0000\u0001\u0000\u0001\u0082S\u0000\u0000\u0003\u0004\u0000\u0000\u0000\u0000\u0000\u0001\u0000\u0001\u001a\"\u0000\u0000\u0003\u00fa\u0000\u0000\u0000\u0000\u0000\u0001\u0000\u0001=\u00c3\u0000\u0000\u0005\u0084\u0000\u0000\u0000\u0000\u0000\u0001\u0000\u0001h\u00bf\u0000\u0000\u0004\u00b0\u0000\u0000\u0000\u0000\u0000\u0001\u0000\u0001PJ\u0000\u0000\u0005\u0010\u0000\u0000\u0000\u0000\u0000\u0001\u0000\u0001Zd\u0000\u0000\u0002x\u0000\u0000\u0000\u0000\u0000\u0001\u0000\u0001\u0001\u0001\u0000\u0000\u0003N\u0000\u0000\u0000\u0000\u0000\u0001\u0000\u0001 \u00a3\u0000\u0000\u0002\u00c2\u0000\u0000\u0000\u0000\u0000\u0001\u0000\u0001\u0014i\u0000\u0000\u0003\u00a8\u0000\u0000\u0000\u0000\u0000\u0001\u0000\u0001-*\u0000\u0000\u00034\u0000\u0000\u0000\u0000\u0000\u0001\u0000\u0001\u001d\u001e\u0000\u0000\u0001\u0088\u0000\u0000\u0000\u0000\u0000\u0001\u0000\u0000\u00e7\u00d5\u0000\u0000\u0004\u00de\u0000\u0000\u0000\u0000\u0000\u0001\u0000\u0001R\u00cf\u0000\u0000\u0001b\u0000\u0000\u0000\u0000\u0000\u0001\u0000\u0000\u00e4t\u0000\u0000\u0001\u00c0\u0000\u0000\u0000\u0000\u0000\u0001\u0000\u0000\u00ee*\u0000\u0000\u0005\u00f8\u0000\u0000\u0000\u0000\u0000\u0001\u0000\u0001v\u00fe\u0000\u0000\u0002\u00e4\u0000\u0000\u0000\u0000\u0000\u0001\u0000\u0001\u0017d\u0000\u0000\u0006\u008c\u0000\u0000\u0000\u0000\u0000\u0001\u0000\u0001\u0086!\u0000\u0000\u0001\f\u0000\u0000\u0000\u0000\u0000\u0001\u0000\u0000\u00dc\u00a0\u0000\u0000\u0004\u0090\u0000\u0000\u0000\u0000\u0000\u0001\u0000\u0001Lu\u0000\u0000\u0005\u00b2\u0000\u0000\u0000\u0000\u0000\u0001\u0000\u0001m\u00f0\u0000\u0000\u0002\u0002\u0000\u0000\u0000\u0000\u0000\u0001\u0000\u0000\u00f5\u00c4\u0000\u0000\u0001.\u0000\u0000\u0000\u0000\u0000\u0001\u0000\u0000\u0017\u00da\u0000\u0000\u0004\u00fa\u0000\u0000\u0000\u0000\u0000\u0001\u0000\u0000\u008f\u0080\u0000\u0000\u00066\u0000\u0000\u0000\u0000\u0000\u0001\u0000\u0000\u00b6\u00f3\u0000\u0000\u0006\u00a8\u0000\u0000\u0000\u0000\u0000\u0001\u0000\u0000\u00c4R\u0000\u0000\u0005\u00d2\u0000\u0000\u0000\u0000\u0000\u0001\u0000\u0000\u00ac\u009a\u0000\u0000\u0004T\u0000\u0000\u0000\u0000\u0000\u0001\u0000\u0000~\u00e2\u0000\u0000\u0001\u00e0\u0000\u0000\u0000\u0000\u0000\u0001\u0000\u0000+j\u0000\u0000\u0000\u00a6\u0000\u0000\u0000\u0000\u0000\u0001\u0000\u0000\r'\u0000\u0000\u0002\u00a8\u0000\u0000\u0000\u0000\u0000\u0001\u0000\u0000C\u00b7\u0000\u0000\u0004\u001c\u0000\u0000\u0000\u0000\u0000\u0001\u0000\u0000z\u001f\u0000\u0000\u0006h\u0000\u0000\u0000\u0000\u0000\u0001\u0000\u0000\u00bc\u00d5\u0000\u0000\u0002N\u0000\u0000\u0000\u0000\u0000\u0001\u0000\u000065\u0000\u0000\u0000\u00c6\u0000\u0000\u0000\u0000\u0000\u0001\u0000\u0000\u0010~\u0000\u0000\u0002<\u0000\u0000\u0000\u0000\u0000\u0001\u0000\u00003d\u0000\u0000\u0003\u00cc\u0000\u0000\u0000\u0000\u0000\u0001\u0000\u0000lY\u0000\u0000\u0006 \u0000\u0000\u0000\u0000\u0000\u0001\u0000\u0000\u00b4*\u0000\u0000\u0005\\\u0000\u0000\u0000\u0000\u0000\u0001\u0000\u0000\u009de\u0000\u0000\u0003\u0086\u0000\u0000\u0000\u0000\u0000\u0001\u0000\u0000c\u00de\u0000\u0000\u0005*\u0000\u0000\u0000\u0000\u0000\u0001\u0000\u0000\u0096\u00cc\u0000\u0000\u0000n\u0000\u0000\u0000\u0000\u0000\u0001\u0000\u0000\u0005\u00d4\u0000\u0000\u0002$\u0000\u0000\u0000\u0000\u0000\u0001\u0000\u00001\u0017\u0000\u0000\u0000R\u0000\u0000\u0000\u0000\u0000\u0001\u0000\u0000\u0002y\u0000\u0000\u0003p\u0000\u0000\u0000\u0000\u0000\u0001\u0000\u0000]L\u0000\u0000\u0005\u009c\u0000\u0000\u0000\u0000\u0000\u0001\u0000\u0000\u00a3T\u0000\u0000\u0004z\u0000\u0000\u0000\u0000\u0000\u0001\u0000\u0000\u0081\u00e2\u0000\u0000\u0001D\u0000\u0000\u0000\u0000\u0000\u0001\u0000\u0000\u001b\u001c\u0000\u0000\u0005@\u0000\u0000\u0000\u0000\u0000\u0001\u0000\u0000\u0099`\u0000\u0000\u0003\u00de\u0000\u0000\u0000\u0000\u0000\u0001\u0000\u0000p\u00a9\u0000\u0000\u0001\u00ac\u0000\u0000\u0000\u0000\u0000\u0001\u0000\u0000#V\u0000\u0000\u00000\u0000\u0000\u0000\u0000\u0000\u0001\u0000\u0000\u0000\u0000\u0000\u0000\u0000\u00f2\u0000\u0000\u0000\u0000\u0000\u0001\u0000\u0000\u0013C\u0000\u0000\u0004<\u0000\u0000\u0000\u0000\u0000\u0001\u0000\u0000|h\u0000\u0000\u0002\u0092\u0000\u0000\u0000\u0000\u0000\u0001\u0000\u0000=\u00a4\u0000\u0000\u0000\u0090\u0000\u0000\u0000\u0000\u0000\u0001\u0000\u0000\tA\u0000\u0000\u0006N\u0000\u0000\u0000\u0000\u0000\u0001\u0000\u0000\u00bbP\u0000\u0000\u0003\u0004\u0000\u0000\u0000\u0000\u0000\u0001\u0000\u0000S\u001f\u0000\u0000\u0003\u00fa\u0000\u0000\u0000\u0000\u0000\u0001\u0000\u0000v\u00c0\u0000\u0000\u0005\u0084\u0000\u0000\u0000\u0000\u0000\u0001\u0000\u0000\u00a1\u00bc\u0000\u0000\u0004\u00b0\u0000\u0000\u0000\u0000\u0000\u0001\u0000\u0000\u0089G\u0000\u0000\u0005\u0010\u0000\u0000\u0000\u0000\u0000\u0001\u0000\u0000\u0093a\u0000\u0000\u0002x\u0000\u0000\u0000\u0000\u0000\u0001\u0000\u00009\u00fe\u0000\u0000\u0003N\u0000\u0000\u0000\u0000\u0000\u0001\u0000\u0000Y\u00a0\u0000\u0000\u0002\u00c2\u0000\u0000\u0000\u0000\u0000\u0001\u0000\u0000Mf\u0000\u0000\u0003\u00a8\u0000\u0000\u0000\u0000\u0000\u0001\u0000\u0000f'\u0000\u0000\u00034\u0000\u0000\u0000\u0000\u0000\u0001\u0000\u0000V\u001b\u0000\u0000\u0001\u0088\u0000\u0000\u0000\u0000\u0000\u0001\u0000\u0000 \u00c6\u0000\u0000\u0004\u00de\u0000\u0000\u0000\u0000\u0000\u0001\u0000\u0000\u008b\u00cc\u0000\u0000\u0001b\u0000\u0000\u0000\u0000\u0000\u0001\u0000\u0000\u001de\u0000\u0000\u0001\u00c0\u0000\u0000\u0000\u0000\u0000\u0001\u0000\u0000'\u001b\u0000\u0000\u0005\u00f8\u0000\u0000\u0000\u0000\u0000\u0001\u0000\u0000\u00af\u00fb\u0000\u0000\u0002\u00e4\u0000\u0000\u0000\u0000\u0000\u0001\u0000\u0000Pa\u0000\u0000\u0006\u008c\u0000\u0000\u0000\u0000\u0000\u0001\u0000\u0000\u00bf\u001e\u0000\u0000\u0001\f\u0000\u0000\u0000\u0000\u0000\u0001\u0000\u0000\u0015\u0091\u0000\u0000\u0004\u0090\u0000\u0000\u0000\u0000\u0000\u0001\u0000\u0000\u0085r\u0000\u0000\u0005\u00b2\u0000\u0000\u0000\u0000\u0000\u0001\u0000\u0000\u00a6\u00ed\u0000\u0000\u0002\u0002\u0000\u0000\u0000\u0000\u0000\u0001\u0000\u0000.\u00c1", "\u0000\u0000\u0000\u0000\u0000\u0002\u0000\u0000\u0000\u0001\u0000\u0000\u0000\u0001\u0000\u0000\u0000\u0000\u0000\u0000\u0000\u0000\u0000\u0000\u0000\u0000\u0000\u0002\u0000\u0000\u0000\u0002\u0000\u0000\u0000\u0002\u0000\u0000\u0000\u0000\u0000\u0000\u0000\u0000\u0000\u0000\u0000\"\u0000\u0002\u0000\u0000\u00008\u0000\u0000\u0000<\u0000\u0000\u0000\u0000\u0000\u0000\u0000\u0000\u0000\u0000\u0000\u0012\u0000\u0002\u0000\u0000\u00008\u0000\u0000\u0000\u0004\u0000\u0000\u0000\u0000\u0000\u0000\u0000\u0000\u0000\u0000\u0001.\u0000\u0000\u0000\u0000\u0000\u0001\u0000\u0000\u00de\u00e9\u0000\u0000\u0001|\u00cf/\u00cb\u0097\u0000\u0000\u0004\u00fa\u0000\u0000\u0000\u0000\u0000\u0001\u0000\u0001V\u0083\u0000\u0000\u0001|\u00cf/\u00cb\u0091\u0000\u0000\u00066\u0000\u0000\u0000\u0000\u0000\u0001\u0000\u0001}\u00f6\u0000\u0000\u0001|\u00cf/\u00cb\u008b\u0000\u0000\u0006\u00a8\u0000\u0000\u0000\u0000\u0000\u0001\u0000\u0001\u008bU\u0000\u0000\u0001|\u00cf/\u00cb\u008e\u0000\u0000\u0005\u00d2\u0000\u0000\u0000\u0000\u0000\u0001\u0000\u0001s\u009d\u0000\u0000\u0001|\u00cf/\u00cb\u008d\u0000\u0000\u0004T\u0000\u0000\u0000\u0000\u0000\u0001\u0000\u0001E\u00e5\u0000\u0000\u0001|\u00cf/\u00cb\u008b\u0000\u0000\u0001\u00e0\u0000\u0000\u0000\u0000\u0000\u0001\u0000\u0000\u00f2y\u0000\u0000\u0001|\u00cf/\u00cb\u008b\u0000\u0000\u0000\u00a6\u0000\u0000\u0000\u0000\u0000\u0001\u0000\u0000\u00d46\u0000\u0000\u0001|\u00cf/\u00cb\u0098\u0000\u0000\u0002\u00a8\u0000\u0000\u0000\u0000\u0000\u0001\u0000\u0001\n\u00ba\u0000\u0000\u0001|\u00cf/\u00cb\u0095\u0000\u0000\u0004\u001c\u0000\u0000\u0000\u0000\u0000\u0001\u0000\u0001A\"\u0000\u0000\u0001|\u00cf/\u00cb\u0089\u0000\u0000\u0006h\u0000\u0000\u0000\u0000\u0000\u0001\u0000\u0001\u0083\u00d8\u0000\u0000\u0001|\u00cf/\u00cb\u008c\u0000\u0000\u0002N\u0000\u0000\u0000\u0000\u0000\u0001\u0000\u0000\u00fd8\u0000\u0000\u0001|\u00cf/\u00cb\u0092\u0000\u0000\u0000\u00c6\u0000\u0000\u0000\u0000\u0000\u0001\u0000\u0000\u00d7\u008d\u0000\u0000\u0001|\u00cf/\u00cb\u0096\u0000\u0000\u0002<\u0000\u0000\u0000\u0000\u0000\u0001\u0000\u0000\u00fag\u0000\u0000\u0001|\u00cf/\u00cb\u0097\u0000\u0000\u0003\u00cc\u0000\u0000\u0000\u0000\u0000\u0001\u0000\u00013\\\u0000\u0000\u0001|\u00cf/\u00cb\u0094\u0000\u0000\u0006 \u0000\u0000\u0000\u0000\u0000\u0001\u0000\u0001{-\u0000\u0000\u0001|\u00cf/\u00cb\u008d\u0000\u0000\u0005\\\u0000\u0000\u0000\u0000\u0000\u0001\u0000\u0001dh\u0000\u0000\u0001|\u00cf/\u00cb\u008e\u0000\u0000\u0003\u0086\u0000\u0000\u0000\u0000\u0000\u0001\u0000\u0001*\u00e1\u0000\u0000\u0001|\u00cf/\u00cb\u008f\u0000\u0000\u0005*\u0000\u0000\u0000\u0000\u0000\u0001\u0000\u0001]\u00cf\u0000\u0000\u0001|\u00cf/\u00cb\u008f\u0000\u0000\u0000n\u0000\u0000\u0000\u0000\u0000\u0001\u0000\u0000\u00cc\u00e3\u0000\u0000\u0001|\u00cf/\u00cb\u008c\u0000\u0000\u0002$\u0000\u0000\u0000\u0000\u0000\u0001\u0000\u0000\u00f8\u001a\u0000\u0000\u0001|\u00cf/\u00cb\u0098\u0000\u0000\u0000R\u0000\u0000\u0000\u0000\u0000\u0001\u0000\u0000\u00c9\u0088\u0000\u0000\u0001|\u00cf/\u00cb\u0097\u0000\u0000\u0003p\u0000\u0000\u0000\u0000\u0000\u0001\u0000\u0001$O\u0000\u0000\u0001|\u00cf/\u00cb\u0095\u0000\u0000\u0005\u009c\u0000\u0000\u0000\u0000\u0000\u0001\u0000\u0001jW\u0000\u0000\u0001|\u00cf/\u00cb\u008a\u0000\u0000\u0004z\u0000\u0000\u0000\u0000\u0000\u0001\u0000\u0001H\u00e5\u0000\u0000\u0001|\u00cf/\u00cb\u008c\u0000\u0000\u0001D\u0000\u0000\u0000\u0000\u0000\u0001\u0000\u0000\u00e2+\u0000\u0000\u0001|\u00cf/\u00cb\u0092\u0000\u0000\u0005@\u0000\u0000\u0000\u0000\u0000\u0001\u0000\u0001`c\u0000\u0000\u0001|\u00cf/\u00cb\u0096\u0000\u0000\u0003\u00de\u0000\u0000\u0000\u0000\u0000\u0001\u0000\u00017\u00ac\u0000\u0000\u0001|\u00cf/\u00cb\u008d\u0000\u0000\u0001\u00ac\u0000\u0000\u0000\u0000\u0000\u0001\u0000\u0000\u00eae\u0000\u0000\u0001|\u00cf/\u00cb\u008f\u0000\u0000\u00000\u0000\u0000\u0000\u0000\u0000\u0001\u0000\u0000\u00c7\u000f\u0000\u0000\u0001|\u00cf/\u00cb\u008d\u0000\u0000\u0000\u00f2\u0000\u0000\u0000\u0000\u0000\u0001\u0000\u0000\u00daR\u0000\u0000\u0001|\u00cf/\u00cb\u0090\u0000\u0000\u0004<\u0000\u0000\u0000\u0000\u0000\u0001\u0000\u0001Ck\u0000\u0000\u0001|\u00cf/\u00cb\u0090\u0000\u0000\u0002\u0092\u0000\u0000\u0000\u0000\u0000\u0001\u0000\u0001\u0004\u00a7\u0000\u0000\u0001|\u00cf/\u00cb\u0089\u0000\u0000\u0000\u0090\u0000\u0000\u0000\u0000\u0000\u0001\u0000\u0000\u00d0P\u0000\u0000\u0001|\u00cf/\u00cb\u0088\u0000\u0000\u0006N\u0000\u0000\u0000\u0000\u0000\u0001\u0000\u0001\u0082S\u0000\u0000\u0001|\u00cf/\u00cb\u008a\u0000\u0000\u0003\u0004\u0000\u0000\u0000\u0000\u0000\u0001\u0000\u0001\u001a\"\u0000\u0000\u0001|\u00cf/\u00cb\u0096\u0000\u0000\u0003\u00fa\u0000\u0000\u0000\u0000\u0000\u0001\u0000\u0001=\u00c3\u0000\u0000\u0001|\u00cf/\u00cb\u0090\u0000\u0000\u0005\u0084\u0000\u0000\u0000\u0000\u0000\u0001\u0000\u0001h\u00bf\u0000\u0000\u0001|\u00cf/\u00cb\u008e\u0000\u0000\u0004\u00b0\u0000\u0000\u0000\u0000\u0000\u0001\u0000\u0001PJ\u0000\u0000\u0001|\u00cf/\u00cb\u008f\u0000\u0000\u0005\u0010\u0000\u0000\u0000\u0000\u0000\u0001\u0000\u0001Zd\u0000\u0000\u0001|\u00cf/\u00cb\u0092\u0000\u0000\u0002x\u0000\u0000\u0000\u0000\u0000\u0001\u0000\u0001\u0001\u0001\u0000\u0000\u0001|\u00cf/\u00cb\u0090\u0000\u0000\u0003N\u0000\u0000\u0000\u0000\u0000\u0001\u0000\u0001 \u00a3\u0000\u0000\u0001|\u00cf/\u00cb\u0093\u0000\u0000\u0002\u00c2\u0000\u0000\u0000\u0000\u0000\u0001\u0000\u0001\u0014i\u0000\u0000\u0001|\u00cf/\u00cb\u008a\u0000\u0000\u0003\u00a8\u0000\u0000\u0000\u0000\u0000\u0001\u0000\u0001-*\u0000\u0000\u0001|\u00cf/\u00cb\u008c\u0000\u0000\u00034\u0000\u0000\u0000\u0000\u0000\u0001\u0000\u0001\u001d\u001e\u0000\u0000\u0001|\u00cf/\u00cb\u0093\u0000\u0000\u0001\u0088\u0000\u0000\u0000\u0000\u0000\u0001\u0000\u0000\u00e7\u00d5\u0000\u0000\u0001|\u00cf/\u00cb\u0094\u0000\u0000\u0004\u00de\u0000\u0000\u0000\u0000\u0000\u0001\u0000\u0001R\u00cf\u0000\u0000\u0001|\u00cf/\u00cb\u0096\u0000\u0000\u0001b\u0000\u0000\u0000\u0000\u0000\u0001\u0000\u0000\u00e4t\u0000\u0000\u0001|\u00cf/\u00cb\u0092\u0000\u0000\u0001\u00c0\u0000\u0000\u0000\u0000\u0000\u0001\u0000\u0000\u00ee*\u0000\u0000\u0001|\u00cf/\u00cb\u0091\u0000\u0000\u0005\u00f8\u0000\u0000\u0000\u0000\u0000\u0001\u0000\u0001v\u00fe\u0000\u0000\u0001|\u00cf/\u00cb\u0094\u0000\u0000\u0002\u00e4\u0000\u0000\u0000\u0000\u0000\u0001\u0000\u0001\u0017d\u0000\u0000\u0001|\u00cf/\u00cb\u0093\u0000\u0000\u0006\u008c\u0000\u0000\u0000\u0000\u0000\u0001\u0000\u0001\u0086!\u0000\u0000\u0001|\u00cf/\u00cb\u0089\u0000\u0000\u0001\f\u0000\u0000\u0000\u0000\u0000\u0001\u0000\u0000\u00dc\u00a0\u0000\u0000\u0001|\u00cf/\u00cb\u008c\u0000\u0000\u0004\u0090\u0000\u0000\u0000\u0000\u0000\u0001\u0000\u0001Lu\u0000\u0000\u0001|\u00cf/\u00cb\u008b\u0000\u0000\u0005\u00b2\u0000\u0000\u0000\u0000\u0000\u0001\u0000\u0001m\u00f0\u0000\u0000\u0001|\u00cf/\u00cb\u008a\u0000\u0000\u0002\u0002\u0000\u0000\u0000\u0000\u0000\u0001\u0000\u0000\u00f5\u00c4\u0000\u0000\u0001|\u00cf/\u00cb\u0091\u0000\u0000\u0001.\u0000\u0000\u0000\u0000\u0000\u0001\u0000\u0000\u0017\u00da\u0000\u0000\u0001|\u00cf/\u00cb\u0087\u0000\u0000\u0004\u00fa\u0000\u0000\u0000\u0000\u0000\u0001\u0000\u0000\u008f\u0080\u0000\u0000\u0001|\u00cf/\u00cb\u007f\u0000\u0000\u00066\u0000\u0000\u0000\u0000\u0000\u0001\u0000\u0000\u00b6\u00f3\u0000\u0000\u0001|\u00cf/\u00cbs\u0000\u0000\u0006\u00a8\u0000\u0000\u0000\u0000\u0000\u0001\u0000\u0000\u00c4R\u0000\u0000\u0001|\u00cf/\u00cby\u0000\u0000\u0005\u00d2\u0000\u0000\u0000\u0000\u0000\u0001\u0000\u0000\u00ac\u009a\u0000\u0000\u0001|\u00cf/\u00cbw\u0000\u0000\u0004T\u0000\u0000\u0000\u0000\u0000\u0001\u0000\u0000~\u00e2\u0000\u0000\u0001|\u00cf/\u00cbs\u0000\u0000\u0001\u00e0\u0000\u0000\u0000\u0000\u0000\u0001\u0000\u0000+j\u0000\u0000\u0001|\u00cf/\u00cbr\u0000\u0000\u0000\u00a6\u0000\u0000\u0000\u0000\u0000\u0001\u0000\u0000\r'\u0000\u0000\u0001|\u00cf/\u00cb\u0088\u0000\u0000\u0002\u00a8\u0000\u0000\u0000\u0000\u0000\u0001\u0000\u0000C\u00b7\u0000\u0000\u0001|\u00cf/\u00cb\u0084\u0000\u0000\u0004\u001c\u0000\u0000\u0000\u0000\u0000\u0001\u0000\u0000z\u001f\u0000\u0000\u0001|\u00cf/\u00cbm\u0000\u0000\u0006h\u0000\u0000\u0000\u0000\u0000\u0001\u0000\u0000\u00bc\u00d5\u0000\u0000\u0001|\u00cf/\u00cbu\u0000\u0000\u0002N\u0000\u0000\u0000\u0000\u0000\u0001\u0000\u000065\u0000\u0000\u0001|\u00cf/\u00cb\u0080\u0000\u0000\u0000\u00c6\u0000\u0000\u0000\u0000\u0000\u0001\u0000\u0000\u0010~\u0000\u0000\u0001|\u00cf/\u00cb\u0086\u0000\u0000\u0002<\u0000\u0000\u0000\u0000\u0000\u0001\u0000\u00003d\u0000\u0000\u0001|\u00cf/\u00cb\u0087\u0000\u0000\u0003\u00cc\u0000\u0000\u0000\u0000\u0000\u0001\u0000\u0000lY\u0000\u0000\u0001|\u00cf/\u00cb\u0084\u0000\u0000\u0006 \u0000\u0000\u0000\u0000\u0000\u0001\u0000\u0000\u00b4*\u0000\u0000\u0001|\u00cf/\u00cbx\u0000\u0000\u0005\\\u0000\u0000\u0000\u0000\u0000\u0001\u0000\u0000\u009de\u0000\u0000\u0001|\u00cf/\u00cbz\u0000\u0000\u0003\u0086\u0000\u0000\u0000\u0000\u0000\u0001\u0000\u0000c\u00de\u0000\u0000\u0001|\u00cf/\u00cbz\u0000\u0000\u0005*\u0000\u0000\u0000\u0000\u0000\u0001\u0000\u0000\u0096\u00cc\u0000\u0000\u0001|\u00cf/\u00cb{\u0000\u0000\u0000n\u0000\u0000\u0000\u0000\u0000\u0001\u0000\u0000\u0005\u00d4\u0000\u0000\u0001|\u00cf/\u00cbv\u0000\u0000\u0002$\u0000\u0000\u0000\u0000\u0000\u0001\u0000\u00001\u0017\u0000\u0000\u0001|\u00cf/\u00cb\u0088\u0000\u0000\u0000R\u0000\u0000\u0000\u0000\u0000\u0001\u0000\u0000\u0002y\u0000\u0000\u0001|\u00cf/\u00cb\u0087\u0000\u0000\u0003p\u0000\u0000\u0000\u0000\u0000\u0001\u0000\u0000]L\u0000\u0000\u0001|\u00cf/\u00cb\u0085\u0000\u0000\u0005\u009c\u0000\u0000\u0000\u0000\u0000\u0001\u0000\u0000\u00a3T\u0000\u0000\u0001|\u00cf/\u00cbp\u0000\u0000\u0004z\u0000\u0000\u0000\u0000\u0000\u0001\u0000\u0000\u0081\u00e2\u0000\u0000\u0001|\u00cf/\u00cbv\u0000\u0000\u0001D\u0000\u0000\u0000\u0000\u0000\u0001\u0000\u0000\u001b\u001c\u0000\u0000\u0001|\u00cf/\u00cb\u0081\u0000\u0000\u0005@\u0000\u0000\u0000\u0000\u0000\u0001\u0000\u0000\u0099`\u0000\u0000\u0001|\u00cf/\u00cb\u0086\u0000\u0000\u0003\u00de\u0000\u0000\u0000\u0000\u0000\u0001\u0000\u0000p\u00a9\u0000\u0000\u0001|\u00cf/\u00cbw\u0000\u0000\u0001\u00ac\u0000\u0000\u0000\u0000\u0000\u0001\u0000\u0000#V\u0000\u0000\u0001|\u00cf/\u00cb{\u0000\u0000\u00000\u0000\u0000\u0000\u0000\u0000\u0001\u0000\u0000\u0000\u0000\u0000\u0000\u0001|\u00cf/\u00cbw\u0000\u0000\u0000\u00f2\u0000\u0000\u0000\u0000\u0000\u0001\u0000\u0000\u0013C\u0000\u0000\u0001|\u00cf/\u00cb}\u0000\u0000\u0004<\u0000\u0000\u0000\u0000\u0000\u0001\u0000\u0000|h\u0000\u0000\u0001|\u00cf/\u00cb}\u0000\u0000\u0002\u0092\u0000\u0000\u0000\u0000\u0000\u0001\u0000\u0000=\u00a4\u0000\u0000\u0001|\u00cf/\u00cbl\u0000\u0000\u0000\u0090\u0000\u0000\u0000\u0000\u0000\u0001\u0000\u0000\tA\u0000\u0000\u0001|\u00cf/\u00cbl\u0000\u0000\u0006N\u0000\u0000\u0000\u0000\u0000\u0001\u0000\u0000\u00bbP\u0000\u0000\u0001|\u00cf/\u00cbo\u0000\u0000\u0003\u0004\u0000\u0000\u0000\u0000\u0000\u0001\u0000\u0000S\u001f\u0000\u0000\u0001|\u00cf/\u00cb\u0085\u0000\u0000\u0003\u00fa\u0000\u0000\u0000\u0000\u0000\u0001\u0000\u0000v\u00c0\u0000\u0000\u0001|\u00cf/\u00cb|\u0000\u0000\u0005\u0084\u0000\u0000\u0000\u0000\u0000\u0001\u0000\u0000\u00a1\u00bc\u0000\u0000\u0001|\u00cf/\u00cby\u0000\u0000\u0004\u00b0\u0000\u0000\u0000\u0000\u0000\u0001\u0000\u0000\u0089G\u0000\u0000\u0001|\u00cf/\u00cb|\u0000\u0000\u0005\u0010\u0000\u0000\u0000\u0000\u0000\u0001\u0000\u0000\u0093a\u0000\u0000\u0001|\u00cf/\u00cb\u0081\u0000\u0000\u0002x\u0000\u0000\u0000\u0000\u0000\u0001\u0000\u00009\u00fe\u0000\u0000\u0001|\u00cf/\u00cb~\u0000\u0000\u0003N\u0000\u0000\u0000\u0000\u0000\u0001\u0000\u0000Y\u00a0\u0000\u0000\u0001|\u00cf/\u00cb\u0082\u0000\u0000\u0002\u00c2\u0000\u0000\u0000\u0000\u0000\u0001\u0000\u0000Mf\u0000\u0000\u0001|\u00cf/\u00cbp\u0000\u0000\u0003\u00a8\u0000\u0000\u0000\u0000\u0000\u0001\u0000\u0000f'\u0000\u0000\u0001|\u00cf/\u00cbt\u0000\u0000\u00034\u0000\u0000\u0000\u0000\u0000\u0001\u0000\u0000V\u001b\u0000\u0000\u0001|\u00cf/\u00cb\u0082\u0000\u0000\u0001\u0088\u0000\u0000\u0000\u0000\u0000\u0001\u0000\u0000 \u00c6\u0000\u0000\u0001|\u00cf/\u00cb\u0083\u0000\u0000\u0004\u00de\u0000\u0000\u0000\u0000\u0000\u0001\u0000\u0000\u008b\u00cc\u0000\u0000\u0001|\u00cf/\u00cb\u0086\u0000\u0000\u0001b\u0000\u0000\u0000\u0000\u0000\u0001\u0000\u0000\u001de\u0000\u0000\u0001|\u00cf/\u00cb\u0080\u0000\u0000\u0001\u00c0\u0000\u0000\u0000\u0000\u0000\u0001\u0000\u0000'\u001b\u0000\u0000\u0001|\u00cf/\u00cb\u0080\u0000\u0000\u0005\u00f8\u0000\u0000\u0000\u0000\u0000\u0001\u0000\u0000\u00af\u00fb\u0000\u0000\u0001|\u00cf/\u00cb\u0083\u0000\u0000\u0002\u00e4\u0000\u0000\u0000\u0000\u0000\u0001\u0000\u0000Pa\u0000\u0000\u0001|\u00cf/\u00cb\u0082\u0000\u0000\u0006\u008c\u0000\u0000\u0000\u0000\u0000\u0001\u0000\u0000\u00bf\u001e\u0000\u0000\u0001|\u00cf/\u00cbn\u0000\u0000\u0001\f\u0000\u0000\u0000\u0000\u0000\u0001\u0000\u0000\u0015\u0091\u0000\u0000\u0001|\u00cf/\u00cbu\u0000\u0000\u0004\u0090\u0000\u0000\u0000\u0000\u0000\u0001\u0000\u0000\u0085r\u0000\u0000\u0001|\u00cf/\u00cbq\u0000\u0000\u0005\u00b2\u0000\u0000\u0000\u0000\u0000\u0001\u0000\u0000\u00a6\u00ed\u0000\u0000\u0001|\u00cf/\u00cbo\u0000\u0000\u0002\u0002\u0000\u0000\u0000\u0000\u0000\u0001\u0000\u0000.\u00c1\u0000\u0000\u0001|\u00cf/\u00cb~" ], "napari/resources/_qt_resources_0.4.16rc2.dev71+gdc77a47c_pyqt5_5.15.2.py": [ "\u0000\u0000\u0003\u0095\n\n\n\n\n\u0000\u0000\u0003i\n\n\n\n\n\n\u0000\u0000\u0003\u00a8\n\n\n\n\n\n\n\u0000\u0000\u0002R\n\n\n\n\n\u0000\u0000\u0002\u008c\n\n\n\n\n\u0000\u0000\u0003\u00e2\n\n\n\n\n\n\n\n\n\n\n\n\n\u0000\u0000\u0002J\n\n\n\n\n\u0000\u0000\u0003[\n\n\n\n\n\n\u0000\u0000\u0003\u008c\n\n\n\n\n\n\u0000\u0000\u0003\u0081\n\n\n\n\t\n\t\t\n\t\t\n\t\t\n\t\t\n\t\n\n\n\u0000\u0000\u0002\u00b9\u0000\u0000\u0004+\n\n\n\n\t\n\t\t\n\t\n\n\n\t\n\t\t\n\t\n\n\n\n\u0000\u0000\u0003]\n\n\n\n\n\n\u0000\u0000\u0003\u00c1\n\n\n\n\t\n\t\t\n\t\t\n\t\n\n\n\u0000\u0000\u0002I\n\n\n\n\n\u0000\u0000\u0003G\n\n\n\n\n\n\u0000\u0000\u0002v\n Artboard 1\n \n\n\u0000\u0000\u0002E\n\n\n\n\n\u0000\u0000\u0006\u0013\n\n\n\n\n\n\n\n\n\n\n\n\u0000\u0000\u0004K\n\n\n\n\n\n\n\u0000\u0000\u0002\u0090\u0000\u0000\t\u00ab\n\n\n\n\t\n\t\n\t\n\t\n\t\n\t\n\n\n\u0000\u0000\u0002\u00cd\n\n\n\n\n\u0000\u0000\u0002\u0081\n\n\n\n\n\n\u0000\u0000\u0002\u00c5\n\n\n\n\t\n\t\t\n\t\n\n\n\u0000\u0000\u0003S\n\n\n\n\t\n\t\t\n\t\n\n\n\n\u0000\u0000\u0002E\n\n\n\n\n\u0000\u0000\u0002\u00ba\n\n\n\n\n\n\u0000\u0000\u0004S\n\n\n\n\t\n\t\t\n\t\n\n\n\t\n\t\t\n\t\n\n\n\n\u0000\u0000\u0002E\n\n\n\n\n\u0000\u0000\u0002E\n\n\n\n\n\u0000\u0000\u0002\u00f7\n\n\n\n\n\n\n\n\n\n\u0000\u0000\u0002\u00fc\n\n\n\n\n\n\u0000\u0000\u00050\n\n\n\n\n\n\n\n\n\n\u0000\u0000\u0006.\n\n\n\n\n\n\n\n\n\n\n\n\n\u0000\u0000\u0003g\n\n\n\n\t\n\t\t\n\t\n\n\n\u0000\u0000\u0002\u00f8\n\n\n\n\n\u0000\u0000\u0003\u00c5\n\n\n\n\n\n\n\u0000\u0000\u0003\u00a2\n\n\n\n\t\n\t\t\n\t\n\n\n\n\u0000\u0000\u0003W\n\n\n\n\n\n\u0000\u0000\u0003]\n\n\n\n\n\n\u0000\u0000\u0002u\n\n\n\n\n\n\u0000\u0000\u0006\u008e\n\n\n\n\t\n\n\n\n\u0000\u0000\u0003\u00b0\n Artboard 1\n \n\n\u0000\u0000\u0002\u00c1\n\n\n\n\n\n\u0000\u0000\u0001\u0094\n \n \n \n\n\u0000\u0000\u0003\u00d1\n\n\n\n\n\n\n\n\u0000\u0000\u0002E\n\n\n\n\n\u0000\u0000\u0003\u00dd\n\n\n\n\n\n\n\u0000\u0000\u0005\u00a9\n\n\n\n\t\n\n\n\u0000\u0000\u0006\u000f\n\n\n\n\n\n\n\n\n\n\n\u0000\u0000\u0004\u0001\n\n\n\n\n\n\n\u0000\u0000\u0004Y\n\n\n\n\t\n\n\n\u0000\u0000\u0004L\n\n\n\n\t\n\t\n\t\n\t\n\t\n\t\n\n\n\u0000\u0000\u0001\u0081\n \n \n \n\n\u0000\u0000\u0003>\n Artboard 1\n \n\n\u0000\u0000\u0003\u0095\n\n\n\n\n\u0000\u0000\u0003i\n\n\n\n\n\n\u0000\u0000\u0003\u00a8\n\n\n\n\n\n\n\u0000\u0000\u0002R\n\n\n\n\n\u0000\u0000\u0002\u008c\n\n\n\n\n\u0000\u0000\u0003\u00e2\n\n\n\n\n\n\n\n\n\n\n\n\n\u0000\u0000\u0002J\n\n\n\n\n\u0000\u0000\u0003[\n\n\n\n\n\n\u0000\u0000\u0003\u008c\n\n\n\n\n\n\u0000\u0000\u0003\u0081\n\n\n\n\t\n\t\t\n\t\t\n\t\t\n\t\t\n\t\n\n\n\u0000\u0000\u0002\u00b9\u0000\u0000\u0004+\n\n\n\n\t\n\t\t\n\t\n\n\n\t\n\t\t\n\t\n\n\n\n\u0000\u0000\u0003]\n\n\n\n\n\n\u0000\u0000\u0003\u00c1\n\n\n\n\t\n\t\t\n\t\t\n\t\n\n\n\u0000\u0000\u0002I\n\n\n\n\n\u0000\u0000\u0003S\n\n\n\n\n\n\u0000\u0000\u0002v\n Artboard 1\n \n\n\u0000\u0000\u0002E\n\n\n\n\n\u0000\u0000\u0006\u0013\n\n\n\n\n\n\n\n\n\n\n\n\u0000\u0000\u0004K\n\n\n\n\n\n\n\u0000\u0000\u0002\u0090\u0000\u0000\t\u00ab\n\n\n\n\t\n\t\n\t\n\t\n\t\n\t\n\n\n\u0000\u0000\u0002\u00cd\n\n\n\n\n\u0000\u0000\u0002\u0081\n\n\n\n\n\n\u0000\u0000\u0002\u00c5\n\n\n\n\t\n\t\t\n\t\n\n\n\u0000\u0000\u0003S\n\n\n\n\t\n\t\t\n\t\n\n\n\n\u0000\u0000\u0002E\n\n\n\n\n\u0000\u0000\u0002\u00ba\n\n\n\n\n\n\u0000\u0000\u0004S\n\n\n\n\t\n\t\t\n\t\n\n\n\t\n\t\t\n\t\n\n\n\n\u0000\u0000\u0002E\n\n\n\n\n\u0000\u0000\u0002E\n\n\n\n\n\u0000\u0000\u0002\u00f7\n\n\n\n\n\n\n\n\n\n\u0000\u0000\u0002\u00fc\n\n\n\n\n\n\u0000\u0000\u00050\n\n\n\n\n\n\n\n\n\n\u0000\u0000\u0006.\n\n\n\n\n\n\n\n\n\n\n\n\n\u0000\u0000\u0003g\n\n\n\n\t\n\t\t\n\t\n\n\n\u0000\u0000\u0002\u00f8\n\n\n\n\n\u0000\u0000\u0003\u00c5\n\n\n\n\n\n\n\u0000\u0000\u0003\u00a2\n\n\n\n\t\n\t\t\n\t\n\n\n\n\u0000\u0000\u0003W\n\n\n\n\n\n\u0000\u0000\u0003]\n\n\n\n\n\n\u0000\u0000\u0002u\n\n\n\n\n\n\u0000\u0000\u0006\u008e\n\n\n\n\t\n\n\n\n\u0000\u0000\u0003\u00b0\n Artboard 1\n \n\n\u0000\u0000\u0002\u00c1\n\n\n\n\n\n\u0000\u0000\u0001\u0094\n \n \n \n\n\u0000\u0000\u0003\u00d1\n\n\n\n\n\n\n\n\u0000\u0000\u0002E\n\n\n\n\n\u0000\u0000\u0003\u00dd\n\n\n\n\n\n\n\u0000\u0000\u0005\u00a9\n\n\n\n\t\n\n\n\u0000\u0000\u0006\u000f\n\n\n\n\n\n\n\n\n\n\n\u0000\u0000\u0004\u0001\n\n\n\n\n\n\n\u0000\u0000\u0004Y\n\n\n\n\t\n\n\n\u0000\u0000\u0004L\n\n\n\n\t\n\t\n\t\n\t\n\t\n\t\n\n\n\u0000\u0000\u0001\u0081\n \n \n \n\n\u0000\u0000\u0003>\n Artboard 1\n \n\n", "\u0000\u0006\u0007\u00ae\u00c3\u00c3\u0000t\u0000h\u0000e\u0000m\u0000e\u0000s\u0000\u0004\u0000\u0006\u00a8\u008b\u0000d\u0000a\u0000r\u0000k\u0000\u0005\u0000r\u00fd\u00f4\u0000l\u0000i\u0000g\u0000h\u0000t\u0000\b\u00068W'\u0000h\u0000o\u0000m\u0000e\u0000.\u0000s\u0000v\u0000g\u0000\u000e\u0005\u009a\b\u00e7\u0000n\u0000e\u0000w\u0000_\u0000l\u0000a\u0000b\u0000e\u0000l\u0000s\u0000.\u0000s\u0000v\u0000g\u0000\u000e\u000b\u00c1\u00fc\u00e7\u0000m\u0000o\u0000v\u0000e\u0000_\u0000f\u0000r\u0000o\u0000n\u0000t\u0000.\u0000s\u0000v\u0000g\u0000\u000e\u000fkz\u00e7\u0000n\u0000e\u0000w\u0000_\u0000s\u0000h\u0000a\u0000p\u0000e\u0000s\u0000.\u0000s\u0000v\u0000g\u0000\u000f\rD\u0018g\u0000n\u0000e\u0000w\u0000_\u0000s\u0000u\u0000r\u0000f\u0000a\u0000c\u0000e\u0000.\u0000s\u0000v\u0000g\u0000\b\b\u00f7W\u0007\u0000g\u0000r\u0000i\u0000d\u0000.\u0000s\u0000v\u0000g\u0000\n\b\u008b\u000b\u00a7\u0000s\u0000q\u0000u\u0000a\u0000r\u0000e\u0000.\u0000s\u0000v\u0000g\u0000\u000e\u000bXl\u00a7\u0000c\u0000h\u0000e\u0000v\u0000r\u0000o\u0000n\u0000_\u0000u\u0000p\u0000.\u0000s\u0000v\u0000g\u0000\b\u0006`J\u00e7\u0000z\u0000o\u0000o\u0000m\u0000.\u0000s\u0000v\u0000g\u0000\n\f\u00ad\u0002\u0087\u0000d\u0000e\u0000l\u0000e\u0000t\u0000e\u0000.\u0000s\u0000v\u0000g\u0000\u000b\u0000\u00b5Hg\u0000w\u0000a\u0000r\u0000n\u0000i\u0000n\u0000g\u0000.\u0000s\u0000v\u0000g\u0000\u0011\u000ezm\u00e7\u0000v\u0000e\u0000r\u0000t\u0000e\u0000x\u0000_\u0000r\u0000e\u0000m\u0000o\u0000v\u0000e\u0000.\u0000s\u0000v\u0000g\u0000\u0010\u000e\u0017*\u0087\u0000c\u0000h\u0000e\u0000v\u0000r\u0000o\u0000n\u0000_\u0000d\u0000o\u0000w\u0000n\u0000.\u0000s\u0000v\u0000g\u0000\u0007\u0007\u00a7Z\u0007\u0000a\u0000d\u0000d\u0000.\u0000s\u0000v\u0000g\u0000\t\u0005\u00c6\u00b2\u00c7\u0000m\u0000i\u0000n\u0000u\u0000s\u0000.\u0000s\u0000v\u0000g\u0000\u000e\u0001\u0087]\u00e7\u0000v\u0000i\u0000s\u0000i\u0000b\u0000i\u0000l\u0000i\u0000t\u0000y\u0000.\u0000s\u0000v\u0000g\u0000\t\b\u0098\u0083\u00c7\u0000e\u0000r\u0000a\u0000s\u0000e\u0000.\u0000s\u0000v\u0000g\u0000\u000f\u0002\u009f\b\u0007\u0000r\u0000i\u0000g\u0000h\u0000t\u0000_\u0000a\u0000r\u0000r\u0000o\u0000w\u0000.\u0000s\u0000v\u0000g\u0000\u000b\u0007P<\u00c7\u0000e\u0000l\u0000l\u0000i\u0000p\u0000s\u0000e\u0000.\u0000s\u0000v\u0000g\u0000\r\u000eN\u009bg\u0000n\u0000e\u0000w\u0000_\u0000i\u0000m\u0000a\u0000g\u0000e\u0000.\u0000s\u0000v\u0000g\u0000\b\u0004\u00d2T\u00c7\u0000i\u0000n\u0000f\u0000o\u0000.\u0000s\u0000v\u0000g\u0000\n\u0001\u00cb\u0085\u0087\u0000p\u0000i\u0000c\u0000k\u0000e\u0000r\u0000.\u0000s\u0000v\u0000g\u0000\u0006\u0003gZ\u00c7\u00002\u0000D\u0000.\u0000s\u0000v\u0000g\u0000\u0014\u000b\u00a3q\u00a7\u0000l\u0000o\u0000n\u0000g\u0000_\u0000r\u0000i\u0000g\u0000h\u0000t\u0000_\u0000a\u0000r\u0000r\u0000o\u0000w\u0000.\u0000s\u0000v\u0000g\u0000\b\u0003\u00c6T'\u0000p\u0000l\u0000u\u0000s\u0000.\u0000s\u0000v\u0000g\u0000\r\u0001\u0088\u00ef\u00c7\u0000t\u0000r\u0000a\u0000n\u0000s\u0000p\u0000o\u0000s\u0000e\u0000.\u0000s\u0000v\u0000g\u0000\u000e\u000e\u00de\u00f7G\u0000l\u0000e\u0000f\u0000t\u0000_\u0000a\u0000r\u0000r\u0000o\u0000w\u0000.\u0000s\u0000v\u0000g\u0000\r\u000e\u008f\u0097g\u0000s\u0000t\u0000e\u0000p\u0000_\u0000l\u0000e\u0000f\u0000t\u0000.\u0000s\u0000v\u0000g\u0000\u0011\u0004.wG\u0000v\u0000e\u0000r\u0000t\u0000e\u0000x\u0000_\u0000i\u0000n\u0000s\u0000e\u0000r\u0000t\u0000.\u0000s\u0000v\u0000g\u0000\r\u0002\r\u0090\u0007\u0000d\u0000r\u0000o\u0000p\u0000_\u0000d\u0000o\u0000w\u0000n\u0000.\u0000s\u0000v\u0000g\u0000\u000e\u0004\u00a2\u00f1'\u0000d\u0000o\u0000w\u0000n\u0000_\u0000a\u0000r\u0000r\u0000o\u0000w\u0000.\u0000s\u0000v\u0000g\u0000\u000e\f\u001a\u00ad\u00e7\u0000n\u0000e\u0000w\u0000_\u0000p\u0000o\u0000i\u0000n\u0000t\u0000s\u0000.\u0000s\u0000v\u0000g\u0000\u0010\u0001,9\u00a7\u0000d\u0000e\u0000l\u0000e\u0000t\u0000e\u0000_\u0000s\u0000h\u0000a\u0000p\u0000e\u0000.\u0000s\u0000v\u0000g\u0000\u000b\u000e\u00cf\u009d'\u0000p\u0000o\u0000l\u0000y\u0000g\u0000o\u0000n\u0000.\u0000s\u0000v\u0000g\u0000\u000f\fN\u00fc\u0087\u0000n\u0000e\u0000w\u0000_\u0000v\u0000e\u0000c\u0000t\u0000o\u0000r\u0000s\u0000.\u0000s\u0000v\u0000g\u0000\n\u000b\u00a8b\u0087\u0000s\u0000e\u0000l\u0000e\u0000c\u0000t\u0000.\u0000s\u0000v\u0000g\u0000\u0015\u000b>\u000e\u0007\u0000c\u0000o\u0000p\u0000y\u0000_\u0000t\u0000o\u0000_\u0000c\u0000l\u0000i\u0000p\u0000b\u0000o\u0000a\u0000r\u0000d\u0000.\u0000s\u0000v\u0000g\u0000\u0012\u0002\u00eaZ\u0007\u0000v\u0000i\u0000s\u0000i\u0000b\u0000i\u0000l\u0000i\u0000t\u0000y\u0000_\u0000o\u0000f\u0000f\u0000.\u0000s\u0000v\u0000g\u0000\n\u000b\u00aa;\u00c7\u0000d\u0000i\u0000r\u0000e\u0000c\u0000t\u0000.\u0000s\u0000v\u0000g\u0000\u000b\u0006)\u0096\u0007\u0000p\u0000o\u0000p\u0000_\u0000o\u0000u\u0000t\u0000.\u0000s\u0000v\u0000g\u0000\u0010\u0000\u00e1-\u00a7\u0000c\u0000h\u0000e\u0000v\u0000r\u0000o\u0000n\u0000_\u0000l\u0000e\u0000f\u0000t\u0000.\u0000s\u0000v\u0000g\u0000\u000e\b{\u0095\u0087\u0000s\u0000t\u0000e\u0000p\u0000_\u0000r\u0000i\u0000g\u0000h\u0000t\u0000.\u0000s\u0000v\u0000g\u0000\b\u0006/U\u00e7\u0000r\u0000o\u0000l\u0000l\u0000.\u0000s\u0000v\u0000g\u0000\u000b\r\u00d7\u00adG\u0000s\u0000h\u0000u\u0000f\u0000f\u0000l\u0000e\u0000.\u0000s\u0000v\u0000g\u0000\u0013\u0003Q\u00b0\u00c7\u0000l\u0000o\u0000n\u0000g\u0000_\u0000l\u0000e\u0000f\u0000t\u0000_\u0000a\u0000r\u0000r\u0000o\u0000w\u0000.\u0000s\u0000v\u0000g\u0000\t\u000b\u009e\u0089\u0007\u0000c\u0000h\u0000e\u0000c\u0000k\u0000.\u0000s\u0000v\u0000g\u0000\r\u000fG0\u0007\u0000m\u0000o\u0000v\u0000e\u0000_\u0000b\u0000a\u0000c\u0000k\u0000.\u0000s\u0000v\u0000g\u0000\f\u0006\u00e6\u00eb\u00e7\u0000u\u0000p\u0000_\u0000a\u0000r\u0000r\u0000o\u0000w\u0000.\u0000s\u0000v\u0000g\u0000\b\u0000HT\u00a7\u0000l\u0000i\u0000n\u0000e\u0000.\u0000s\u0000v\u0000g\u0000\r\u000fU\u000b\u00a7\u0000r\u0000e\u0000c\u0000t\u0000a\u0000n\u0000g\u0000l\u0000e\u0000.\u0000s\u0000v\u0000g\u0000\b\b\u00abT\u0007\u0000p\u0000a\u0000t\u0000h\u0000.\u0000s\u0000v\u0000g\u0000\u000b\u0006\u00f4\u0091\u0087\u0000c\u0000o\u0000n\u0000s\u0000o\u0000l\u0000e\u0000.\u0000s\u0000v\u0000g\u0000\t\u0000W\u00b7\u00c7\u0000p\u0000a\u0000i\u0000n\u0000t\u0000.\u0000s\u0000v\u0000g\u0000\u0006\u0003wZ\u00c7\u00003\u0000D\u0000.\u0000s\u0000v\u0000g\u0000\n\n-\u001b\u00c7\u0000c\u0000i\u0000r\u0000c\u0000l\u0000e\u0000.\u0000s\u0000v\u0000g\u0000\b\u0000/Wg\u0000f\u0000i\u0000l\u0000l\u0000.\u0000s\u0000v\u0000g", "\u0000\u0000\u0000\u0000\u0000\u0002\u0000\u0000\u0000\u0001\u0000\u0000\u0000\u0001\u0000\u0000\u0000\u0000\u0000\u0002\u0000\u0000\u0000\u0002\u0000\u0000\u0000\u0002\u0000\u0000\u0000\u0012\u0000\u0002\u0000\u0000\u00008\u0000\u0000\u0000<\u0000\u0000\u0000 \u0000\u0002\u0000\u0000\u00008\u0000\u0000\u0000\u0004\u0000\u0000\u0006\u00ae\u0000\u0000\u0000\u0000\u0000\u0001\u0000\u0000\u00c3\u00c1\u0000\u0000\u0006\u0002\u0000\u0000\u0000\u0000\u0000\u0001\u0000\u0000\u00a5\u00e9\u0000\u0000\u0006j\u0000\u0000\u0000\u0000\u0000\u0001\u0000\u0000\u00b9\u008f\u0000\u0000\u0001R\u0000\u0000\u0000\u0000\u0000\u0001\u0000\u0000 @\u0000\u0000\u0005\u0006\u0000\u0000\u0000\u0000\u0000\u0001\u0000\u0000\u008bN\u0000\u0000\u0003\u00f6\u0000\u0000\u0000\u0000\u0000\u0001\u0000\u0000k\u00b7\u0000\u0000\u0001\u00e8\u0000\u0000\u0000\u0000\u0000\u0001\u0000\u00000\u009f\u0000\u0000\u0003\b\u0000\u0000\u0000\u0000\u0000\u0001\u0000\u0000Wu\u0000\u0000\u0002\u0098\u0000\u0000\u0000\u0000\u0000\u0001\u0000\u0000E\u00a7\u0000\u0000\u0003\u0092\u0000\u0000\u0000\u0000\u0000\u0001\u0000\u0000d*\u0000\u0000\u0002\"\u0000\u0000\u0000\u0000\u0000\u0001\u0000\u00006d\u0000\u0000\u0004\u00a6\u0000\u0000\u0000\u0000\u0000\u0001\u0000\u0000\u0080\u0084\u0000\u0000\u0005\u0080\u0000\u0000\u0000\u0000\u0000\u0001\u0000\u0000\u009bn\u0000\u0000\u0002\u00b2\u0000\u0000\u0000\u0000\u0000\u0001\u0000\u0000OV\u0000\u0000\u0006\u0082\u0000\u0000\u0000\u0000\u0000\u0001\u0000\u0000\u00bd\u00ec\u0000\u0000\u0002\u00f2\u0000\u0000\u0000\u0000\u0000\u0001\u0000\u0000T\u00ac\u0000\u0000\u0003j\u0000\u0000\u0000\u0000\u0000\u0001\u0000\u0000_\u00d3\u0000\u0000\u0003\u00b2\u0000\u0000\u0000\u0000\u0000\u0001\u0000\u0000fs\u0000\u0000\u0002\u0082\u0000\u0000\u0000\u0000\u0000\u0001\u0000\u0000C\u0013\u0000\u0000\u0000F\u0000\u0000\u0000\u0000\u0000\u0001\u0000\u0000\u0003\u0099\u0000\u0000\u0001\u00d0\u0000\u0000\u0000\u0000\u0000\u0001\u0000\u0000.R\u0000\u0000\u0004\u00ea\u0000\u0000\u0000\u0000\u0000\u0001\u0000\u0000\u0087\u00f3\u0000\u0000\u0005N\u0000\u0000\u0000\u0000\u0000\u0001\u0000\u0000\u0091(\u0000\u0000\u00000\u0000\u0000\u0000\u0000\u0000\u0001\u0000\u0000\u0000\u0000\u0000\u0000\u0001\"\u0000\u0000\u0000\u0000\u0000\u0001\u0000\u0000\u0019+\u0000\u0000\u0005\u00e4\u0000\u0000\u0000\u0000\u0000\u0001\u0000\u0000\u00a3\u00a0\u0000\u0000\u0006N\u0000\u0000\u0000\u0000\u0000\u0001\u0000\u0000\u00b5\u008a\u0000\u0000\u0002F\u0000\u0000\u0000\u0000\u0000\u0001\u0000\u00008\u00ad\u0000\u0000\u0001\u00bc\u0000\u0000\u0000\u0000\u0000\u0001\u0000\u0000*\u008d\u0000\u0000\u0005,\u0000\u0000\u0000\u0000\u0000\u0001\u0000\u0000\u008e\u00af\u0000\u0000\u0000\u00e6\u0000\u0000\u0000\u0000\u0000\u0001\u0000\u0000\u0013~\u0000\u0000\u0002\n\u0000\u0000\u0000\u0000\u0000\u0001\u0000\u00003\u00ea\u0000\u0000\u00068\u0000\u0000\u0000\u0000\u0000\u0001\u0000\u0000\u00afw\u0000\u0000\u0000\u00d0\u0000\u0000\u0000\u0000\u0000\u0001\u0000\u0000\u000f\u0098\u0000\u0000\u0006\u0094\u0000\u0000\u0000\u0000\u0000\u0001\u0000\u0000\u00c2<\u0000\u0000\u0004v\u0000\u0000\u0000\u0000\u0000\u0001\u0000\u0000}\u0088\u0000\u0000\u0001\u0000\u0000\u0000\u0000\u0000\u0000\u0001\u0000\u0000\u0015\u00cc\u0000\u0000\u0005\u00ac\u0000\u0000\u0000\u0000\u0000\u0001\u0000\u0000\u009e3\u0000\u0000\u0002\u00c4\u0000\u0000\u0000\u0000\u0000\u0001\u0000\u0000R'\u0000\u0000\u0004\\\u0000\u0000\u0000\u0000\u0000\u0001\u0000\u0000z\u001d\u0000\u0000\u0004\u00d0\u0000\u0000\u0000\u0000\u0000\u0001\u0000\u0000\u0084M\u0000\u0000\u0000h\u0000\u0000\u0000\u0000\u0000\u0001\u0000\u0000\u0007\u0006\u0000\u0000\u0003\u00d4\u0000\u0000\u0000\u0000\u0000\u0001\u0000\u0000h\u00bc\u0000\u0000\u00048\u0000\u0000\u0000\u0000\u0000\u0001\u0000\u0000s\u00eb\u0000\u0000\u00018\u0000\u0000\u0000\u0000\u0000\u0001\u0000\u0000\u001c\u00bb\u0000\u0000\u0000\u00ac\u0000\u0000\u0000\u0000\u0000\u0001\u0000\u0000\r\b\u0000\u0000\u0005d\u0000\u0000\u0000\u0000\u0000\u0001\u0000\u0000\u0097\u00ba\u0000\u0000\u0001\u0096\u0000\u0000\u0000\u0000\u0000\u0001\u0000\u0000',\u0000\u0000\u0002b\u0000\u0000\u0000\u0000\u0000\u0001\u0000\u0000>\u00c4\u0000\u0000\u0001n\u0000\u0000\u0000\u0000\u0000\u0001\u0000\u0000\"\u00fd\u0000\u0000\u0003J\u0000\u0000\u0000\u0000\u0000\u0001\u0000\u0000]\u0015\u0000\u0000\u0004\u001c\u0000\u0000\u0000\u0000\u0000\u0001\u0000\u0000n\u00b7\u0000\u0000\u0003(\u0000\u0000\u0000\u0000\u0000\u0001\u0000\u0000Z\u00cc\u0000\u0000\u0005\u00c4\u0000\u0000\u0000\u0000\u0000\u0001\u0000\u0000\u009f\u00cb\u0000\u0000\u0006\u0018\u0000\u0000\u0000\u0000\u0000\u0001\u0000\u0000\u00a9\u00ca\u0000\u0000\u0000\u008a\u0000\u0000\u0000\u0000\u0000\u0001\u0000\u0000\n\u00b2\u0000\u0000\u0006\u00ae\u0000\u0000\u0000\u0000\u0000\u0001\u0000\u0001\u008a\u00d0\u0000\u0000\u0006\u0002\u0000\u0000\u0000\u0000\u0000\u0001\u0000\u0001l\u00f8\u0000\u0000\u0006j\u0000\u0000\u0000\u0000\u0000\u0001\u0000\u0001\u0080\u009e\u0000\u0000\u0001R\u0000\u0000\u0000\u0000\u0000\u0001\u0000\u0000\u00e7C\u0000\u0000\u0005\u0006\u0000\u0000\u0000\u0000\u0000\u0001\u0000\u0001R]\u0000\u0000\u0003\u00f6\u0000\u0000\u0000\u0000\u0000\u0001\u0000\u00012\u00c6\u0000\u0000\u0001\u00e8\u0000\u0000\u0000\u0000\u0000\u0001\u0000\u0000\u00f7\u00a2\u0000\u0000\u0003\b\u0000\u0000\u0000\u0000\u0000\u0001\u0000\u0001\u001e\u0084\u0000\u0000\u0002\u0098\u0000\u0000\u0000\u0000\u0000\u0001\u0000\u0001\f\u00b6\u0000\u0000\u0003\u0092\u0000\u0000\u0000\u0000\u0000\u0001\u0000\u0001+9\u0000\u0000\u0002\"\u0000\u0000\u0000\u0000\u0000\u0001\u0000\u0000\u00fds\u0000\u0000\u0004\u00a6\u0000\u0000\u0000\u0000\u0000\u0001\u0000\u0001G\u0093\u0000\u0000\u0005\u0080\u0000\u0000\u0000\u0000\u0000\u0001\u0000\u0001b}\u0000\u0000\u0002\u00b2\u0000\u0000\u0000\u0000\u0000\u0001\u0000\u0001\u0016e\u0000\u0000\u0006\u0082\u0000\u0000\u0000\u0000\u0000\u0001\u0000\u0001\u0084\u00fb\u0000\u0000\u0002\u00f2\u0000\u0000\u0000\u0000\u0000\u0001\u0000\u0001\u001b\u00bb\u0000\u0000\u0003j\u0000\u0000\u0000\u0000\u0000\u0001\u0000\u0001&\u00e2\u0000\u0000\u0003\u00b2\u0000\u0000\u0000\u0000\u0000\u0001\u0000\u0001-\u0082\u0000\u0000\u0002\u0082\u0000\u0000\u0000\u0000\u0000\u0001\u0000\u0001\n\"\u0000\u0000\u0000F\u0000\u0000\u0000\u0000\u0000\u0001\u0000\u0000\u00ca\u009c\u0000\u0000\u0001\u00d0\u0000\u0000\u0000\u0000\u0000\u0001\u0000\u0000\u00f5U\u0000\u0000\u0004\u00ea\u0000\u0000\u0000\u0000\u0000\u0001\u0000\u0001O\u0002\u0000\u0000\u0005N\u0000\u0000\u0000\u0000\u0000\u0001\u0000\u0001X7\u0000\u0000\u00000\u0000\u0000\u0000\u0000\u0000\u0001\u0000\u0000\u00c7\u0003\u0000\u0000\u0001\"\u0000\u0000\u0000\u0000\u0000\u0001\u0000\u0000\u00e0.\u0000\u0000\u0005\u00e4\u0000\u0000\u0000\u0000\u0000\u0001\u0000\u0001j\u00af\u0000\u0000\u0006N\u0000\u0000\u0000\u0000\u0000\u0001\u0000\u0001|\u0099\u0000\u0000\u0002F\u0000\u0000\u0000\u0000\u0000\u0001\u0000\u0000\u00ff\u00bc\u0000\u0000\u0001\u00bc\u0000\u0000\u0000\u0000\u0000\u0001\u0000\u0000\u00f1\u0090\u0000\u0000\u0005,\u0000\u0000\u0000\u0000\u0000\u0001\u0000\u0001U\u00be\u0000\u0000\u0000\u00e6\u0000\u0000\u0000\u0000\u0000\u0001\u0000\u0000\u00da\u0081\u0000\u0000\u0002\n\u0000\u0000\u0000\u0000\u0000\u0001\u0000\u0000\u00fa\u00f9\u0000\u0000\u00068\u0000\u0000\u0000\u0000\u0000\u0001\u0000\u0001v\u0086\u0000\u0000\u0000\u00d0\u0000\u0000\u0000\u0000\u0000\u0001\u0000\u0000\u00d6\u009b\u0000\u0000\u0006\u0094\u0000\u0000\u0000\u0000\u0000\u0001\u0000\u0001\u0089K\u0000\u0000\u0004v\u0000\u0000\u0000\u0000\u0000\u0001\u0000\u0001D\u0097\u0000\u0000\u0001\u0000\u0000\u0000\u0000\u0000\u0000\u0001\u0000\u0000\u00dc\u00cf\u0000\u0000\u0005\u00ac\u0000\u0000\u0000\u0000\u0000\u0001\u0000\u0001eB\u0000\u0000\u0002\u00c4\u0000\u0000\u0000\u0000\u0000\u0001\u0000\u0001\u00196\u0000\u0000\u0004\\\u0000\u0000\u0000\u0000\u0000\u0001\u0000\u0001A,\u0000\u0000\u0004\u00d0\u0000\u0000\u0000\u0000\u0000\u0001\u0000\u0001K\\\u0000\u0000\u0000h\u0000\u0000\u0000\u0000\u0000\u0001\u0000\u0000\u00ce\t\u0000\u0000\u0003\u00d4\u0000\u0000\u0000\u0000\u0000\u0001\u0000\u0001/\u00cb\u0000\u0000\u00048\u0000\u0000\u0000\u0000\u0000\u0001\u0000\u0001:\u00fa\u0000\u0000\u00018\u0000\u0000\u0000\u0000\u0000\u0001\u0000\u0000\u00e3\u00be\u0000\u0000\u0000\u00ac\u0000\u0000\u0000\u0000\u0000\u0001\u0000\u0000\u00d4\u000b\u0000\u0000\u0005d\u0000\u0000\u0000\u0000\u0000\u0001\u0000\u0001^\u00c9\u0000\u0000\u0001\u0096\u0000\u0000\u0000\u0000\u0000\u0001\u0000\u0000\u00ee/\u0000\u0000\u0002b\u0000\u0000\u0000\u0000\u0000\u0001\u0000\u0001\u0005\u00d3\u0000\u0000\u0001n\u0000\u0000\u0000\u0000\u0000\u0001\u0000\u0000\u00ea\u0000\u0000\u0000\u0003J\u0000\u0000\u0000\u0000\u0000\u0001\u0000\u0001$$\u0000\u0000\u0004\u001c\u0000\u0000\u0000\u0000\u0000\u0001\u0000\u00015\u00c6\u0000\u0000\u0003(\u0000\u0000\u0000\u0000\u0000\u0001\u0000\u0001!\u00db\u0000\u0000\u0005\u00c4\u0000\u0000\u0000\u0000\u0000\u0001\u0000\u0001f\u00da\u0000\u0000\u0006\u0018\u0000\u0000\u0000\u0000\u0000\u0001\u0000\u0001p\u00d9\u0000\u0000\u0000\u008a\u0000\u0000\u0000\u0000\u0000\u0001\u0000\u0000\u00d1\u00b5", "\u0000\u0000\u0000\u0000\u0000\u0002\u0000\u0000\u0000\u0001\u0000\u0000\u0000\u0001\u0000\u0000\u0000\u0000\u0000\u0000\u0000\u0000\u0000\u0000\u0000\u0000\u0000\u0002\u0000\u0000\u0000\u0002\u0000\u0000\u0000\u0002\u0000\u0000\u0000\u0000\u0000\u0000\u0000\u0000\u0000\u0000\u0000\u0012\u0000\u0002\u0000\u0000\u00008\u0000\u0000\u0000<\u0000\u0000\u0000\u0000\u0000\u0000\u0000\u0000\u0000\u0000\u0000 \u0000\u0002\u0000\u0000\u00008\u0000\u0000\u0000\u0004\u0000\u0000\u0000\u0000\u0000\u0000\u0000\u0000\u0000\u0000\u0006\u00ae\u0000\u0000\u0000\u0000\u0000\u0001\u0000\u0000\u00c3\u00c1\u0000\u0000\u0001\u0081>\u00d7\u00db[\u0000\u0000\u0006\u0002\u0000\u0000\u0000\u0000\u0000\u0001\u0000\u0000\u00a5\u00e9\u0000\u0000\u0001\u0081>\u00d7\u00dbU\u0000\u0000\u0006j\u0000\u0000\u0000\u0000\u0000\u0001\u0000\u0000\u00b9\u008f\u0000\u0000\u0001\u0081>\u00d7\u00dbN\u0000\u0000\u0001R\u0000\u0000\u0000\u0000\u0000\u0001\u0000\u0000 @\u0000\u0000\u0001\u0081>\u00d7\u00dbR\u0000\u0000\u0005\u0006\u0000\u0000\u0000\u0000\u0000\u0001\u0000\u0000\u008bN\u0000\u0000\u0001\u0081>\u00d7\u00dbQ\u0000\u0000\u0003\u00f6\u0000\u0000\u0000\u0000\u0000\u0001\u0000\u0000k\u00b7\u0000\u0000\u0001\u0081>\u00d7\u00dbM\u0000\u0000\u0001\u00e8\u0000\u0000\u0000\u0000\u0000\u0001\u0000\u00000\u009f\u0000\u0000\u0001\u0081>\u00d7\u00dbM\u0000\u0000\u0003\b\u0000\u0000\u0000\u0000\u0000\u0001\u0000\u0000Wu\u0000\u0000\u0001\u0081>\u00d7\u00db[\u0000\u0000\u0002\u0098\u0000\u0000\u0000\u0000\u0000\u0001\u0000\u0000E\u00a7\u0000\u0000\u0001\u0081>\u00d7\u00dbY\u0000\u0000\u0003\u0092\u0000\u0000\u0000\u0000\u0000\u0001\u0000\u0000d*\u0000\u0000\u0001\u0081>\u00d7\u00dbK\u0000\u0000\u0002\"\u0000\u0000\u0000\u0000\u0000\u0001\u0000\u00006d\u0000\u0000\u0001\u0081>\u00d7\u00dbO\u0000\u0000\u0004\u00a6\u0000\u0000\u0000\u0000\u0000\u0001\u0000\u0000\u0080\u0084\u0000\u0000\u0001\u0081>\u00d7\u00dbV\u0000\u0000\u0005\u0080\u0000\u0000\u0000\u0000\u0000\u0001\u0000\u0000\u009bn\u0000\u0000\u0001\u0081>\u00d7\u00dbZ\u0000\u0000\u0002\u00b2\u0000\u0000\u0000\u0000\u0000\u0001\u0000\u0000OV\u0000\u0000\u0001\u0081>\u00d7\u00dbZ\u0000\u0000\u0006\u0082\u0000\u0000\u0000\u0000\u0000\u0001\u0000\u0000\u00bd\u00ec\u0000\u0000\u0001\u0081>\u00d7\u00dbX\u0000\u0000\u0002\u00f2\u0000\u0000\u0000\u0000\u0000\u0001\u0000\u0000T\u00ac\u0000\u0000\u0001\u0081>\u00d7\u00dbQ\u0000\u0000\u0003j\u0000\u0000\u0000\u0000\u0000\u0001\u0000\u0000_\u00d3\u0000\u0000\u0001\u0081>\u00d7\u00dbR\u0000\u0000\u0003\u00b2\u0000\u0000\u0000\u0000\u0000\u0001\u0000\u0000fs\u0000\u0000\u0001\u0081>\u00d7\u00dbS\u0000\u0000\u0002\u0082\u0000\u0000\u0000\u0000\u0000\u0001\u0000\u0000C\u0013\u0000\u0000\u0001\u0081>\u00d7\u00dbS\u0000\u0000\u0000F\u0000\u0000\u0000\u0000\u0000\u0001\u0000\u0000\u0003\u0099\u0000\u0000\u0001\u0081>\u00d7\u00dbP\u0000\u0000\u0001\u00d0\u0000\u0000\u0000\u0000\u0000\u0001\u0000\u0000.R\u0000\u0000\u0001\u0081>\u00d7\u00db[\u0000\u0000\u0004\u00ea\u0000\u0000\u0000\u0000\u0000\u0001\u0000\u0000\u0087\u00f3\u0000\u0000\u0001\u0081>\u00d7\u00dbZ\u0000\u0000\u0005N\u0000\u0000\u0000\u0000\u0000\u0001\u0000\u0000\u0091(\u0000\u0000\u0001\u0081>\u00d7\u00dbY\u0000\u0000\u00000\u0000\u0000\u0000\u0000\u0000\u0001\u0000\u0000\u0000\u0000\u0000\u0000\u0001\u0081>\u00d7\u00dbL\u0000\u0000\u0001\"\u0000\u0000\u0000\u0000\u0000\u0001\u0000\u0000\u0019+\u0000\u0000\u0001\u0081>\u00d7\u00dbP\u0000\u0000\u0005\u00e4\u0000\u0000\u0000\u0000\u0000\u0001\u0000\u0000\u00a3\u00a0\u0000\u0000\u0001\u0081>\u00d7\u00dbV\u0000\u0000\u0006N\u0000\u0000\u0000\u0000\u0000\u0001\u0000\u0000\u00b5\u008a\u0000\u0000\u0001\u0081>\u00d7\u00dbZ\u0000\u0000\u0002F\u0000\u0000\u0000\u0000\u0000\u0001\u0000\u00008\u00ad\u0000\u0000\u0001\u0081>\u00d7\u00dbP\u0000\u0000\u0001\u00bc\u0000\u0000\u0000\u0000\u0000\u0001\u0000\u0000*\u008d\u0000\u0000\u0001\u0081>\u00d7\u00dbS\u0000\u0000\u0005,\u0000\u0000\u0000\u0000\u0000\u0001\u0000\u0000\u008e\u00af\u0000\u0000\u0001\u0081>\u00d7\u00dbQ\u0000\u0000\u0000\u00e6\u0000\u0000\u0000\u0000\u0000\u0001\u0000\u0000\u0013~\u0000\u0000\u0001\u0081>\u00d7\u00dbT\u0000\u0000\u0002\n\u0000\u0000\u0000\u0000\u0000\u0001\u0000\u00003\u00ea\u0000\u0000\u0001\u0081>\u00d7\u00dbT\u0000\u0000\u00068\u0000\u0000\u0000\u0000\u0000\u0001\u0000\u0000\u00afw\u0000\u0000\u0001\u0081>\u00d7\u00dbK\u0000\u0000\u0000\u00d0\u0000\u0000\u0000\u0000\u0000\u0001\u0000\u0000\u000f\u0098\u0000\u0000\u0001\u0081>\u00d7\u00dbK\u0000\u0000\u0006\u0094\u0000\u0000\u0000\u0000\u0000\u0001\u0000\u0000\u00c2<\u0000\u0000\u0001\u0081>\u00d7\u00dbL\u0000\u0000\u0004v\u0000\u0000\u0000\u0000\u0000\u0001\u0000\u0000}\u0088\u0000\u0000\u0001\u0081>\u00d7\u00dbY\u0000\u0000\u0001\u0000\u0000\u0000\u0000\u0000\u0000\u0001\u0000\u0000\u0015\u00cc\u0000\u0000\u0001\u0081>\u00d7\u00dbT\u0000\u0000\u0005\u00ac\u0000\u0000\u0000\u0000\u0000\u0001\u0000\u0000\u009e3\u0000\u0000\u0001\u0081>\u00d7\u00dbQ\u0000\u0000\u0002\u00c4\u0000\u0000\u0000\u0000\u0000\u0001\u0000\u0000R'\u0000\u0000\u0001\u0081>\u00d7\u00dbT\u0000\u0000\u0004\\\u0000\u0000\u0000\u0000\u0000\u0001\u0000\u0000z\u001d\u0000\u0000\u0001\u0081>\u00d7\u00dbV\u0000\u0000\u0004\u00d0\u0000\u0000\u0000\u0000\u0000\u0001\u0000\u0000\u0084M\u0000\u0000\u0001\u0081>\u00d7\u00dbU\u0000\u0000\u0000h\u0000\u0000\u0000\u0000\u0000\u0001\u0000\u0000\u0007\u0006\u0000\u0000\u0001\u0081>\u00d7\u00dbW\u0000\u0000\u0003\u00d4\u0000\u0000\u0000\u0000\u0000\u0001\u0000\u0000h\u00bc\u0000\u0000\u0001\u0081>\u00d7\u00dbL\u0000\u0000\u00048\u0000\u0000\u0000\u0000\u0000\u0001\u0000\u0000s\u00eb\u0000\u0000\u0001\u0081>\u00d7\u00dbN\u0000\u0000\u00018\u0000\u0000\u0000\u0000\u0000\u0001\u0000\u0000\u001c\u00bb\u0000\u0000\u0001\u0081>\u00d7\u00dbW\u0000\u0000\u0000\u00ac\u0000\u0000\u0000\u0000\u0000\u0001\u0000\u0000\r\b\u0000\u0000\u0001\u0081>\u00d7\u00dbX\u0000\u0000\u0005d\u0000\u0000\u0000\u0000\u0000\u0001\u0000\u0000\u0097\u00ba\u0000\u0000\u0001\u0081>\u00d7\u00dbY\u0000\u0000\u0001\u0096\u0000\u0000\u0000\u0000\u0000\u0001\u0000\u0000',\u0000\u0000\u0001\u0081>\u00d7\u00dbV\u0000\u0000\u0002b\u0000\u0000\u0000\u0000\u0000\u0001\u0000\u0000>\u00c4\u0000\u0000\u0001\u0081>\u00d7\u00dbV\u0000\u0000\u0001n\u0000\u0000\u0000\u0000\u0000\u0001\u0000\u0000\"\u00fd\u0000\u0000\u0001\u0081>\u00d7\u00dbX\u0000\u0000\u0003J\u0000\u0000\u0000\u0000\u0000\u0001\u0000\u0000]\u0015\u0000\u0000\u0001\u0081>\u00d7\u00dbW\u0000\u0000\u0004\u001c\u0000\u0000\u0000\u0000\u0000\u0001\u0000\u0000n\u00b7\u0000\u0000\u0001\u0081>\u00d7\u00dbL\u0000\u0000\u0003(\u0000\u0000\u0000\u0000\u0000\u0001\u0000\u0000Z\u00cc\u0000\u0000\u0001\u0081>\u00d7\u00dbO\u0000\u0000\u0005\u00c4\u0000\u0000\u0000\u0000\u0000\u0001\u0000\u0000\u009f\u00cb\u0000\u0000\u0001\u0081>\u00d7\u00dbM\u0000\u0000\u0006\u0018\u0000\u0000\u0000\u0000\u0000\u0001\u0000\u0000\u00a9\u00ca\u0000\u0000\u0001\u0081>\u00d7\u00dbL\u0000\u0000\u0000\u008a\u0000\u0000\u0000\u0000\u0000\u0001\u0000\u0000\n\u00b2\u0000\u0000\u0001\u0081>\u00d7\u00dbU\u0000\u0000\u0006\u00ae\u0000\u0000\u0000\u0000\u0000\u0001\u0000\u0001\u008a\u00d0\u0000\u0000\u0001\u0081>\u00d7\u00dbJ\u0000\u0000\u0006\u0002\u0000\u0000\u0000\u0000\u0000\u0001\u0000\u0001l\u00f8\u0000\u0000\u0001\u0081>\u00d7\u00dbA\u0000\u0000\u0006j\u0000\u0000\u0000\u0000\u0000\u0001\u0000\u0001\u0080\u009e\u0000\u0000\u0001\u0081>\u00d7\u00db5\u0000\u0000\u0001R\u0000\u0000\u0000\u0000\u0000\u0001\u0000\u0000\u00e7C\u0000\u0000\u0001\u0081>\u00d7\u00db9\u0000\u0000\u0005\u0006\u0000\u0000\u0000\u0000\u0000\u0001\u0000\u0001R]\u0000\u0000\u0001\u0081>\u00d7\u00db7\u0000\u0000\u0003\u00f6\u0000\u0000\u0000\u0000\u0000\u0001\u0000\u00012\u00c6\u0000\u0000\u0001\u0081>\u00d7\u00db4\u0000\u0000\u0001\u00e8\u0000\u0000\u0000\u0000\u0000\u0001\u0000\u0000\u00f7\u00a2\u0000\u0000\u0001\u0081>\u00d7\u00db3\u0000\u0000\u0003\b\u0000\u0000\u0000\u0000\u0000\u0001\u0000\u0001\u001e\u0084\u0000\u0000\u0001\u0081>\u00d7\u00dbJ\u0000\u0000\u0002\u0098\u0000\u0000\u0000\u0000\u0000\u0001\u0000\u0001\f\u00b6\u0000\u0000\u0001\u0081>\u00d7\u00dbF\u0000\u0000\u0003\u0092\u0000\u0000\u0000\u0000\u0000\u0001\u0000\u0001+9\u0000\u0000\u0001\u0081>\u00d7\u00db.\u0000\u0000\u0002\"\u0000\u0000\u0000\u0000\u0000\u0001\u0000\u0000\u00fds\u0000\u0000\u0001\u0081>\u00d7\u00db5\u0000\u0000\u0004\u00a6\u0000\u0000\u0000\u0000\u0000\u0001\u0000\u0001G\u0093\u0000\u0000\u0001\u0081>\u00d7\u00dbB\u0000\u0000\u0005\u0080\u0000\u0000\u0000\u0000\u0000\u0001\u0000\u0001b}\u0000\u0000\u0001\u0081>\u00d7\u00dbH\u0000\u0000\u0002\u00b2\u0000\u0000\u0000\u0000\u0000\u0001\u0000\u0001\u0016e\u0000\u0000\u0001\u0081>\u00d7\u00dbI\u0000\u0000\u0006\u0082\u0000\u0000\u0000\u0000\u0000\u0001\u0000\u0001\u0084\u00fb\u0000\u0000\u0001\u0081>\u00d7\u00dbF\u0000\u0000\u0002\u00f2\u0000\u0000\u0000\u0000\u0000\u0001\u0000\u0001\u001b\u00bb\u0000\u0000\u0001\u0081>\u00d7\u00db8\u0000\u0000\u0003j\u0000\u0000\u0000\u0000\u0000\u0001\u0000\u0001&\u00e2\u0000\u0000\u0001\u0081>\u00d7\u00db:\u0000\u0000\u0003\u00b2\u0000\u0000\u0000\u0000\u0000\u0001\u0000\u0001-\u0082\u0000\u0000\u0001\u0081>\u00d7\u00db;\u0000\u0000\u0002\u0082\u0000\u0000\u0000\u0000\u0000\u0001\u0000\u0001\n\"\u0000\u0000\u0001\u0081>\u00d7\u00db<\u0000\u0000\u0000F\u0000\u0000\u0000\u0000\u0000\u0001\u0000\u0000\u00ca\u009c\u0000\u0000\u0001\u0081>\u00d7\u00db6\u0000\u0000\u0001\u00d0\u0000\u0000\u0000\u0000\u0000\u0001\u0000\u0000\u00f5U\u0000\u0000\u0001\u0081>\u00d7\u00dbJ\u0000\u0000\u0004\u00ea\u0000\u0000\u0000\u0000\u0000\u0001\u0000\u0001O\u0002\u0000\u0000\u0001\u0081>\u00d7\u00dbI\u0000\u0000\u0005N\u0000\u0000\u0000\u0000\u0000\u0001\u0000\u0001X7\u0000\u0000\u0001\u0081>\u00d7\u00dbG\u0000\u0000\u00000\u0000\u0000\u0000\u0000\u0000\u0001\u0000\u0000\u00c7\u0003\u0000\u0000\u0001\u0081>\u00d7\u00db0\u0000\u0000\u0001\"\u0000\u0000\u0000\u0000\u0000\u0001\u0000\u0000\u00e0.\u0000\u0000\u0001\u0081>\u00d7\u00db6\u0000\u0000\u0005\u00e4\u0000\u0000\u0000\u0000\u0000\u0001\u0000\u0001j\u00af\u0000\u0000\u0001\u0081>\u00d7\u00dbC\u0000\u0000\u0006N\u0000\u0000\u0000\u0000\u0000\u0001\u0000\u0001|\u0099\u0000\u0000\u0001\u0081>\u00d7\u00dbI\u0000\u0000\u0002F\u0000\u0000\u0000\u0000\u0000\u0001\u0000\u0000\u00ff\u00bc\u0000\u0000\u0001\u0081>\u00d7\u00db7\u0000\u0000\u0001\u00bc\u0000\u0000\u0000\u0000\u0000\u0001\u0000\u0000\u00f1\u0090\u0000\u0000\u0001\u0081>\u00d7\u00db<\u0000\u0000\u0005,\u0000\u0000\u0000\u0000\u0000\u0001\u0000\u0001U\u00be\u0000\u0000\u0001\u0081>\u00d7\u00db8\u0000\u0000\u0000\u00e6\u0000\u0000\u0000\u0000\u0000\u0001\u0000\u0000\u00da\u0081\u0000\u0000\u0001\u0081>\u00d7\u00db>\u0000\u0000\u0002\n\u0000\u0000\u0000\u0000\u0000\u0001\u0000\u0000\u00fa\u00f9\u0000\u0000\u0001\u0081>\u00d7\u00db>\u0000\u0000\u00068\u0000\u0000\u0000\u0000\u0000\u0001\u0000\u0001v\u0086\u0000\u0000\u0001\u0081>\u00d7\u00db-\u0000\u0000\u0000\u00d0\u0000\u0000\u0000\u0000\u0000\u0001\u0000\u0000\u00d6\u009b\u0000\u0000\u0001\u0081>\u00d7\u00db-\u0000\u0000\u0006\u0094\u0000\u0000\u0000\u0000\u0000\u0001\u0000\u0001\u0089K\u0000\u0000\u0001\u0081>\u00d7\u00db/\u0000\u0000\u0004v\u0000\u0000\u0000\u0000\u0000\u0001\u0000\u0001D\u0097\u0000\u0000\u0001\u0081>\u00d7\u00dbG\u0000\u0000\u0001\u0000\u0000\u0000\u0000\u0000\u0000\u0001\u0000\u0000\u00dc\u00cf\u0000\u0000\u0001\u0081>\u00d7\u00db=\u0000\u0000\u0005\u00ac\u0000\u0000\u0000\u0000\u0000\u0001\u0000\u0001eB\u0000\u0000\u0001\u0081>\u00d7\u00db9\u0000\u0000\u0002\u00c4\u0000\u0000\u0000\u0000\u0000\u0001\u0000\u0001\u00196\u0000\u0000\u0001\u0081>\u00d7\u00db=\u0000\u0000\u0004\\\u0000\u0000\u0000\u0000\u0000\u0001\u0000\u0001A,\u0000\u0000\u0001\u0081>\u00d7\u00dbD\u0000\u0000\u0004\u00d0\u0000\u0000\u0000\u0000\u0000\u0001\u0000\u0001K\\\u0000\u0000\u0001\u0081>\u00d7\u00db@\u0000\u0000\u0000h\u0000\u0000\u0000\u0000\u0000\u0001\u0000\u0000\u00ce\t\u0000\u0000\u0001\u0081>\u00d7\u00dbE\u0000\u0000\u0003\u00d4\u0000\u0000\u0000\u0000\u0000\u0001\u0000\u0001/\u00cb\u0000\u0000\u0001\u0081>\u00d7\u00db1\u0000\u0000\u00048\u0000\u0000\u0000\u0000\u0000\u0001\u0000\u0001:\u00fa\u0000\u0000\u0001\u0081>\u00d7\u00db5\u0000\u0000\u00018\u0000\u0000\u0000\u0000\u0000\u0001\u0000\u0000\u00e3\u00be\u0000\u0000\u0001\u0081>\u00d7\u00dbD\u0000\u0000\u0000\u00ac\u0000\u0000\u0000\u0000\u0000\u0001\u0000\u0000\u00d4\u000b\u0000\u0000\u0001\u0081>\u00d7\u00dbF\u0000\u0000\u0005d\u0000\u0000\u0000\u0000\u0000\u0001\u0000\u0001^\u00c9\u0000\u0000\u0001\u0081>\u00d7\u00dbH\u0000\u0000\u0001\u0096\u0000\u0000\u0000\u0000\u0000\u0001\u0000\u0000\u00ee/\u0000\u0000\u0001\u0081>\u00d7\u00dbC\u0000\u0000\u0002b\u0000\u0000\u0000\u0000\u0000\u0001\u0000\u0001\u0005\u00d3\u0000\u0000\u0001\u0081>\u00d7\u00dbA\u0000\u0000\u0001n\u0000\u0000\u0000\u0000\u0000\u0001\u0000\u0000\u00ea\u0000\u0000\u0000\u0001\u0081>\u00d7\u00dbF\u0000\u0000\u0003J\u0000\u0000\u0000\u0000\u0000\u0001\u0000\u0001$$\u0000\u0000\u0001\u0081>\u00d7\u00dbE\u0000\u0000\u0004\u001c\u0000\u0000\u0000\u0000\u0000\u0001\u0000\u00015\u00c6\u0000\u0000\u0001\u0081>\u00d7\u00db/\u0000\u0000\u0003(\u0000\u0000\u0000\u0000\u0000\u0001\u0000\u0001!\u00db\u0000\u0000\u0001\u0081>\u00d7\u00db6\u0000\u0000\u0005\u00c4\u0000\u0000\u0000\u0000\u0000\u0001\u0000\u0001f\u00da\u0000\u0000\u0001\u0081>\u00d7\u00db2\u0000\u0000\u0006\u0018\u0000\u0000\u0000\u0000\u0000\u0001\u0000\u0001p\u00d9\u0000\u0000\u0001\u0081>\u00d7\u00db0\u0000\u0000\u0000\u008a\u0000\u0000\u0000\u0000\u0000\u0001\u0000\u0000\u00d1\u00b5\u0000\u0000\u0001\u0081>\u00d7\u00db@" ], "napari/settings/_appearance.py": [ "dark", "napari_theme", "properties", "schema_version", "theme" ], "napari/settings/_application.py": [ "!QBYTE_", "first_time", "ipy_interactive", "open_history", "preferences_size", "save_history", "schema_version", "window_fullscreen", "window_maximized", "window_position", "window_size", "window_state", "window_statusbar" ], "napari/settings/_base.py": [ ".json", ".yaml", ".yml", "_config_file_settings", "_config_path", "_env_settings", "default", "env_names", "events", "exclude_defaults", "exclude_env", "json", "loc", "requires_restart", "sources", "strict_config_check", "value", "w", "yaml", "{_path.stem}.BAK{_path.suffix}", "{env_name}_{subf.name}", "{field}{event._type}" ], "napari/settings/_experimental.py": [ "boolean", "napari_async", "schema_version" ], "napari/settings/_fields.py": [ "\n ^\n (?P0|[1-9]\\d*)\n \\.\n (?P0|[1-9]\\d*)\n \\.\n (?P0|[1-9]\\d*)\n (?:-(?P\n (?:0|[1-9]\\d*|\\d*[a-zA-Z-][0-9a-zA-Z-]*)\n (?:\\.(?:0|[1-9]\\d*|\\d*[a-zA-Z-][0-9a-zA-Z-]*))*\n ))?\n (?:\\+(?P\n [0-9a-zA-Z-]+\n (?:\\.[0-9a-zA-Z-]+)*\n ))?\n $\n ", "Version", "UTF-8", "{self.major}.{self.minor}.{self.patch}" ], "napari/settings/_migrations.py": [ "NapariSettings", "0.3.0", "0.4.0", "napari.manifest", "Name", "0.5.0" ], "napari/settings/_napari_settings.py": [ "NAPARI_CONFIG", "NapariSettings (defaults excluded)\n", "__main__", "napari.schema.json", "napari_", "schema_version", "0.3.0" ], "napari/settings/_plugins.py": [ "disabled_plugins", "extension2writer", "schema_version", "napari hub", "PyPI", "plugin_api" ], "napari/settings/_shortcuts.py": ["schema_version"], "napari/settings/_utils.py": ["*", "*{pattern}"], "napari/settings/_yaml.py": ["Model", "yaml_dumper", "sort_keys"], "napari/types.py": [ "1", "20", "FunctionGui", "ImageData", "LabelsData", "LayerDataTuple", "PointsData", "QWidget", "ShapesData", "SurfaceData", "TracksData", "VectorsData", "dask.array.Array", "zarr.Array", "_DType_co" ], "napari/utils/__init__.py": [], "napari/utils/_appdirs.py": [ "Lib", "darwin", "lib", "napari", "nt", "plugins", "site-packages", "python{sys.version_info.major}.{sys.version_info.minor}" ], "napari/utils/_base.py": ["Napari", "en", "settings.yaml"], "napari/utils/_dask_utils.py": ["optimization.fuse.active"], "napari/utils/_magicgui.py": [ "Data", "native", "parent", "_qt_viewer", "widget", "{layer.name} (data)" ], "napari/utils/_octree.py": [ "0", "1", "auto_sync_ms", "delay_queue_ms", "enabled", "force_synchronous", "loader_defaults", "loaders", "log_path", "napari.loader", "num_workers", "octree", "tile_size", "use_processes" ], "napari/utils/_proxies.py": [ "PublicOnlyProxy", "^_[^_]", "_T", "__module__", "__wrapped__", "_getframe", "__" ], "napari/utils/_register.py": [ "\n\tThe newly-created {cls_name.lower()} layer.", "\n\nParameters\n----------\n", "\n\nReturns\n-------\n", "Add a{n} {cls_name} layer to the layer list. ", "add_", "aeiou", "def {name}{signature}:\n kwargs = locals()\n kwargs.pop('self', None)\n layer = {cls_name}(**kwargs)\n self.layers.append(layer)\n return layer\n", "layer", "layer : :class:`napari.layers.{cls_name}`", "n", "self", "", "exec" ], "napari/utils/_tracebacks.py": [ " ", "()", ";", "; ", "", "
", "
\n", "
\\1
", "", "", "Format exception with cgitb.html.", "Neutral", "Recurse through exception stack and chain cgitb_html calls.", "bgcolor=\"#.*\"", "black", "blink", "blue", "bold", "color", "cyan", "face=\"helvetica, arial\"", "font_weight", "green", "hidden", "italic", "lighter", "line-through", "mM", "magenta", "red", "text_decoration", "underline", "visibility", "white", "yellow", "{k}: {v}", "{type(arr)} {arr.shape} {arr.dtype}", "\u001b\\[([\\d;]*)([a-zA-z])" ], "napari/utils/action_manager.py": [ "bind_key", "bind_key", " or ", "[{name}]", "destroyed", "{Shortcut(s)}" ], "napari/utils/colormaps/__init__.py": [], "napari/utils/colormaps/bop_colors.py": [], "napari/utils/colormaps/categorical_colormap.py": [ "colormap", "fallback_color", "white" ], "napari/utils/colormaps/categorical_colormap_utils.py": [ "color_cycle", "cycle", "values", "white" ], "napari/utils/colormaps/colorbars.py": ["C"], "napari/utils/colormaps/colormap.py": [ "colors", "controls", "custom", "interpolation", "linear", "name", "zero", "right" ], "napari/utils/colormaps/colormap_utils.py": [ "[unnamed colormap", "[unnamed colormap {len(past_names)}]", "_controls", "colors", "ij", "interpolation", "lab", "label_colormap", "light_blues", "linear", "luv", "rgb", "single_hue", "vispy", "zero", "\"{cm}\"" ], "napari/utils/colormaps/standardize_color.py": [ "O", "U", "f", "i", "u", "{v.lower()}ff", "|U9", "...", "#{\"%02x\" * 4}" ], "napari/utils/config.py": [ "\nExperimental Features\n\nAsync Loading\n-------------\nImage layers will use the ChunkLoader to load data instead of loading\nthe data directly. Image layers will not call np.asarray() in the GUI\nthread. The ChunkLoader will call np.asarray() in a worker thread. That\nmeans any IO or computation done as part of the load will not block the\nGUI thread.\n\nSet NAPARI_ASYNC=1 to turn on async loading with default settings.\n\nOctree Rendering\n----------------\nImage layers use an octree for rendering. The octree organizes the image\ninto chunks/tiles. Only a subset of those chunks/tiles are loaded and\ndrawn at a time. Octree rendering is a work in progress.\n\nEnabled one of two ways:\n\n1) Set NAPARI_OCTREE=1 to enabled octree rendering with defaults.\n\n2) Set NAPARI_OCTREE=/tmp/config.json use a config file.\n\nSee napari/utils/_octree.py for the config file format.\n\nShared Memory Server\n--------------------\nExperimental shared memory service. Only enabled if NAPARI_MON is set to\nthe path of a config file. See this PR for more info:\nhttps://github.com/napari/napari/pull/1909.\n", "\nOther Config Options\n", "0", "NAPARI_MON", "enabled", "octree" ], "napari/utils/context/_context.py": [ "__new__", "_getframe", "_set_default_and_type", "changed", "dict", "root must be an instance of Context", "self", "settings.", "{self._PREFIX}{event.key}" ], "napari/utils/context/_context_keys.py": [ "A", "MISSING", "T", "__class__", "__doc__", "__members__", "__module__", "null" ], "napari/utils/context/_expressions.py": [ " else ", " if ", " {_OPS[type(node.op)]} ", " {_OPS[type(op)]} ", "!=", "%", "&", "*", "**", "+", "/", "//", "<", "<<", "<=", "", "==", ">", ">=", ">>", "@", "Expr", "PassedType", "T", "T2", "V", "^", "and", "ctx", "eval", "in", "is", "is not", "not", "not in", "or", "~", "{expr!r}" ], "napari/utils/context/_layerlist_context.py": [ "LayerSel", "image", "labels", "ndim", "rgb", "shape", "shapes" ], "napari/utils/events/__init__.py": [], "napari/utils/events/containers/__init__.py": [], "napari/utils/events/containers/_dict.py": [ "Cannot add object with type {type(e)} to TypedDict expecting type {self._basetypes}", "TypedMutableMapping[_T]", "_K", "_T" ], "napari/utils/events/containers/_evented_dict.py": [ "added", "adding", "changed", "changing", "events", "key", "removed", "removing", "updated" ], "napari/utils/events/containers/_evented_list.py": [ "EventedList[_T]", "changed", "events", "index", "inserted", "inserting", "move_multiple(sources={sources}, dest_index={dest_index})", "moved", "moving", "removed", "removing", "reordered" ], "napari/utils/events/containers/_nested_list.py": [ "ParentIndex", "_T", "index", "move(src_index={src_index}, dest_index={dest_index})", "new_index" ], "napari/utils/events/containers/_selectable_list.py": ["_T"], "napari/utils/events/containers/_selection.py": [ "ModelField", "[{i}]", "_S", "_T", "_current", "current", "selection", "{type(self).__name__}({repr(self._set)})" ], "napari/utils/events/containers/_set.py": [ "[{i}]", "_T", "changed", "events", "{type(self).__name__}({repr(self._set)})" ], "napari/utils/events/containers/_typed.py": [ "TypedMutableSequence[_T]", "_L", "_T" ], "napari/utils/events/custom_types.py": [ "Array", "__dtype__", "ConstrainedIntValue", "ModelField", "Number", "const", "ensure this value is not equal to {prohibited}", "enum", "not", "number.not_eq" ], "napari/utils/events/debugging.py": [ " \"{fname}\", line {frame.lineno}, in {obj}{frame.function}", " was triggered by {trigger}, via:", ",", ".../python", ".../site-packages", ".env", "Context", "Event", "TransformChain", "__module__", "event_debug_", "position", "self", "status", "{k}={v}", "{obj_type.__name__}.{f.function}()", "{source}.events.{event.type}({vals})", "\u2500" ], "napari/utils/events/event.py": [ "<...>", "<{} {}>", "EmitterGroup", "EventBlockerAll", "_", "__name__", "always", "first", "last", "never", "on_%s", "reminders", "type", "weakref.ReferenceType[Any]", "C++", "already deleted.", "has been deleted", "{name}={attr!r}", "1", "EventEmitter", "NAPARI_DEBUG_EVENTS", "dotenv", "true" ], "napari/utils/events/event_utils.py": [], "napari/utils/events/evented_model.py": [ "PySide2", "__weakref__", "_json_encode", "events", "use_enum_values", "EventedModel", "Unrecognized field dependency: {field}", "dependencies" ], "napari/utils/events/types.py": [], "napari/utils/geometry.py": [ "x_neg", "x_pos", "y_neg", "y_pos", "z_neg", "z_pos" ], "napari/utils/history.py": [], "napari/utils/info.py": [ " - failed to load screen information {e}", " - failed to load vispy", " - screen {i}: resolution {screen.geometry().width()}x{screen.geometry().height()}, scale {screen.devicePixelRatio()}
", " - {sys_info_text}
", "\"", "'", "-d", "-productVersion", "-r", "/etc/os-release", ":", "
", "", "Python: {sys_version}
", "Qt: Import failed ({e})
", "System: {__sys_name}
", "{name}: Import failed ({e})
", "{name}: {loaded[module].__version__}
", "
", "
- ", "
OpenGL:
", "
Screens:
", "=", "Dask", "Description", "MacOS {res.stdout.decode().strip()}", "NAME", "NumPy", "PRETTY_NAME", "PyQt5", "PySide2", "Release", "SciPy", "VERSION", "VERSION_ID", "VisPy", "darwin", "dask", "linux", "lsb_release", "numpy", "scipy", "sw_vers", "vispy", "{data[\"NAME\"]} (no version)", "{data[\"NAME\"]} {data[\"VERSION\"]}", "{data[\"NAME\"]} {data[\"VERSION_ID\"]}", "napari: {napari.__version__}
Platform: {platform.platform()}
", "Qt: {QtCore.__version__}
{API_NAME}: {API_VERSION}
", "magicgui", "superqt", "in_n_out", "in-n-out", "app_model", "app-model", "npe2", "napari contributors (2019). napari: a multi-dimensional image viewer for python. doi:10.5281/zenodo.3555620" ], "napari/utils/interactions.py": [ "\u2326", "+", "-", "", "{k}", "", "Alt", "Backspace", "Ctrl", "Delete", "Down", "Enter", "Esc", "Escape", "Left", "Meta", "Return", "Right", "Shift", "Summary", "Super", "Tab", "Up", "darwin", "linux", "rgb(134, 142, 147)", "\u2190", "\u2191", "\u2192", "\u2193", "\u21b5", "\u21b9", "\u21e7", "\u229e", "\u2303", "\u2318", "\u2325", "\u232b", "\u23ce", "Space", "\u2423", "" ], "napari/utils/io.py": [ ".tif", ".tiff", "zlib", "{tifffile.__version__:!r}" ], "napari/utils/key_bindings.py": [ "Alt", "Option", "Control", "Down", "Left", "Right", "Shift", "Up", "class_keymap", "Ctrl" ], "napari/utils/misc.py": [ "((?<=[a-z])[A-Z]|(?" ], "napari/utils/notifications.py": [ "1", "Closed by KeyboardInterrupt", "Exit on error", "INFO", "NAPARI_EXIT_ON_ERROR", "True", "actions", "debug", "error", "excepthook", "info", "message", "none", "warning", "{self.filename}:{self.lineno}: {category}: {self.warning}!", "\u24d8", "\u24e7", "\u26a0\ufe0f", "\ud83d\udc1b", "0", "False", "NAPARI_CATCH_ERRORS", "{str(self.severity).upper()}: {self.message}", "NoColor" ], "napari/utils/perf/__init__.py": ["0", "NAPARI_PERFMON"], "napari/utils/perf/_compat.py": [], "napari/utils/perf/_config.py": [ "0", "1", "NAPARI_PERFMON", "callable_lists", "trace_callables", "trace_file_on_start", "trace_qt_events", "{label}" ], "napari/utils/perf/_event.py": [ "Origin", "Span", "X", "process_id thread_id", "start_ns end_ns" ], "napari/utils/perf/_patcher.py": [ "Patcher: [ERROR] {exc}", "Patcher: [WARN] skipping duplicate {target_str}", "Patcher: patching {module.__name__}.{label}", "{class_str}.{callable_str}" ], "napari/utils/perf/_stat.py": ["no values"], "napari/utils/perf/_timers.py": [ "0", "C", "I", "NAPARI_PERFMON", "X", "{name} {event.duration_ms:.3f}ms" ], "napari/utils/perf/_trace_file.py": [ "C", "I", "X", "args", "cat", "dur", "name", "none", "p", "ph", "pid", "s", "tid", "ts", "w" ], "napari/utils/settings/__init__.py": [], "napari/utils/settings/_defaults.py": [ "ipy_interactive", "\"{self._value}\"", "SchemaVersion(\"{self._value}\")", "dark", "first_time", "napari_", "open_history", "preferences_size", "pyqt5", "pyside2", "save_history", "schema_version", "window_fullscreen", "window_maximized", "window_position", "window_size", "window_state", "window_statusbar", "appearance", "application", "boolean", "default", "description", "disabled_plugins", "experimental", "loc", "napari_async", "plugins", "properties", "section", "title" ], "napari/utils/settings/_manager.py": [ "json_schema", "model", "w", "_env_settings", "default", "properties", "section" ], "napari/utils/status_messages.py": [ ", {status_format(value[1])}", ": {status_format(value)}", ": {status_format(value[0])}", "[", "]", "{value:0.3g}", "{name} [{' '.join(full_coord)}]", "{name}", " [{' '.join(full_coord)}]" ], "napari/utils/theme.py": [ "([vh])gradient\\((.+)\\)", ")", "-", "black", "dark", "default", "h", "light", "native", "qlineargradient(x1: 0, y1: 0, x2: 0, y2: 1, ", "qlineargradient(x1: 0, y1: 0, x2: 1, y2: 0, ", "rgb(", "rgb(0, 122, 204)", "rgb(106, 115, 128)", "rgb(107, 105, 103)", "rgb(134, 142, 147)", "rgb(150, 146, 144)", "rgb(153, 18, 31)", "rgb(163, 158, 156)", "rgb(188, 184, 181)", "rgb(209, 210, 212)", "rgb(214, 208, 206)", "rgb(239, 235, 233)", "rgb(240, 241, 242)", "rgb(253, 240, 148)", "rgb(255, 18, 31)", "rgb(255, 255, 255)", "rgb(38, 41, 48)", "rgb(59, 58, 57)", "rgb(65, 72, 81)", "rgb(90, 98, 108)", "rgb({red}, {green}, {blue})", "rgba({red}, {green}, {blue}, {max(min(int(value), 255), 0)})", "stop: {n} {stop}", "syntax_style", "white", "{{ %s }}", "{{\\s?darken\\((\\w+),?\\s?([-\\d]+)?\\)\\s?}}", "{{\\s?lighten\\((\\w+),?\\s?([-\\d]+)?\\)\\s?}}", "{{\\s?opacity\\((\\w+),?\\s?([-\\d]+)?\\)\\s?}}", " {', '.join(STYLE_MAP)}", "system", "rgb(18, 18, 18)" ], "napari/utils/transforms/__init__.py": [], "napari/utils/transforms/transform_utils.py": [], "napari/utils/transforms/transforms.py": [ "Affine", "CompositeAffine", "ScaleTranslate", "Transform", "TransformChain", "_is_diagonal" ], "napari/utils/translations.py": [ "LANG", "LANGUAGE", "NAPARI_LANGUAGE", "_", "application", "displayName", "language", "locale", "n", "napari", "napari.languagepack", "nativeName", "{locale}.UTF-8" ], "napari/utils/tree/__init__.py": [], "napari/utils/tree/group.py": [ " {bul}", " \u2502", "Group", "NodeType", "\u2514\u2500\u2500", "\u251c\u2500\u2500" ], "napari/utils/tree/node.py": ["Node"], "napari/utils/validators.py": ["__getitem__"], "napari/view_layers.py": [ "Create a viewer and add a{n} {layer_string} layer.\n\n{params}\n\nReturns\n-------\nviewer : :class:`napari.Viewer`\n The newly-created viewer.\n", "Parameters", "add_{layer_string}", "aeiou", "n", "open", "path", "add_image", "add_labels", "add_points", "add_shapes", "add_surface", "add_tracks", "add_vectors", "kwargs", "return", "self", "view_", "viewer", "gray", "nearest", "linear", "mip", "volume", "translucent" ], "napari/viewer.py": ["Window", "napari", "Viewer"], "napari/window.py": [] }, "SKIP_WORDS_GLOBAL": [", ", ".", "-", "|", "_", ":", "[", "]", "napari"] } napari-0.5.0a1/tools/strings_list.py000066400000000000000000000004121437041365600174540ustar00rootroot00000000000000import json import pathlib data = json.loads( (pathlib.Path(__file__).parent / 'string_list.json').read_text() ) SKIP_FOLDERS = data['SKIP_FOLDERS'] SKIP_FILES = data['SKIP_FILES'] SKIP_WORDS_GLOBAL = data['SKIP_WORDS_GLOBAL'] SKIP_WORDS = data['SKIP_WORDS'] napari-0.5.0a1/tools/test_strings.py000066400000000000000000000454701437041365600174750ustar00rootroot00000000000000""" Script to check for string in the codebase not using `trans`. TODO: * Find all logger calls and add to skips * Find nested funcs inside if/else Rune manually with $ python tools/test_strings.py To interactively be prompted whether new strings should be ignored or need translations. You can pass a command to also have the option to open your editor. Example here to stop in Vim at the right file and linenumber. $ python tools/test_strings.py "vim {filename} +{linenumber}" """ import ast import os import subprocess import sys import termios import tokenize import tty from contextlib import suppress from pathlib import Path from types import ModuleType from typing import Dict, List, Optional, Set, Tuple import pytest from strings_list import ( SKIP_FILES, SKIP_FOLDERS, SKIP_WORDS, SKIP_WORDS_GLOBAL, ) REPO_ROOT = Path(__file__).resolve() NAPARI_MODULE = (REPO_ROOT / "napari").relative_to(REPO_ROOT) # Types StringIssuesDict = Dict[str, List[Tuple[int, str]]] OutdatedStringsDict = Dict[str, List[str]] TranslationErrorsDict = Dict[str, List[Tuple[str, str]]] class FindTransStrings(ast.NodeVisitor): """This node visitor finds translated strings.""" def __init__(self) -> None: super().__init__() self._found = set() self._trans_errors = [] def _check_vars(self, method_name, args, kwargs): """Find interpolation variables inside a translation string. This helps find any variables that need to be interpolated inside a string so we can check against the `kwargs` for both singular and plural strings (if present) inside `args`. Parameters ---------- method_name : str Translation method used. Options include "_", "_n", "_p" and "_np". args : list List of arguments passed to translation method. kwargs : kwargs List of keyword arguments passed to translation method. """ singular_kwargs = set(kwargs) - set({"n"}) plural_kwargs = set(kwargs) # If using trans methods with `context`, remove it since we are # only interested in the singular and plural strings (if any) if method_name in ["_p", "_np"]: args = args[1:] # Iterate on strings passed to the trans method. Could be just a # singular string or a singular and a plural. We use the index to # determine which one is used. for idx, arg in enumerate(args): found_vars = set() check_arg = arg[:] check_kwargs = {} while True: try: check_arg.format(**check_kwargs) except KeyError as err: key = err.args[0] found_vars.add(key) check_kwargs[key] = 0 continue break if idx == 0: check_1 = singular_kwargs - found_vars check_2 = found_vars - singular_kwargs else: check_1 = plural_kwargs - found_vars check_2 = found_vars - plural_kwargs if check_1 or check_2: error = (arg, check_1.union(check_2)) self._trans_errors.append(error) def visit_Call(self, node): method_name, args, kwargs = "", [], [] with suppress(AttributeError): if node.func.value.id == "trans": method_name = node.func.attr # Args for item in [arg.value for arg in node.args]: args.append(item) self._found.add(item) # Kwargs kwargs = [ kw.arg for kw in node.keywords if kw.arg != "deferred" ] if method_name: self._check_vars(method_name, args, kwargs) self.generic_visit(node) def reset(self): """Reset variables storing found strings and translation errors.""" self._found = set() self._trans_errors = [] show_trans_strings = FindTransStrings() def _find_func_definitions( node: ast.AST, defs: List[ast.FunctionDef] = None ) -> List[ast.FunctionDef]: """Find all functions definition recrusively. This also find functions nested inside other functions. Parameters ---------- node : ast.Node The initial node of the ast. defs : list of ast.FunctionDef A list of function definitions to accumulate. Returns ------- list of ast.FunctionDef Function definitions found in `node`. """ try: body = node.body except AttributeError: body = [] if defs is None: defs = [] for node in body: _find_func_definitions(node, defs=defs) if isinstance(node, ast.FunctionDef): defs.append(node) return defs def find_files( path: str, skip_folders: tuple, skip_files: tuple, extensions: tuple = (".py",), ) -> List[str]: """Find recursively all files in path. Parameters ---------- path : str Path to a folder to find files in. skip_folders : tuple Skip folders containing folder to skip skip_files : tuple Skip files. extensions : tuple, optional Extensions to filter by. Default is (".py", ) Returns ------- list Sorted list of found files. """ found_files = [] for root, _dirs, files in os.walk(path, topdown=False): for filename in files: fpath = os.path.join(root, filename) if any(folder in fpath for folder in skip_folders): continue if fpath in skip_files: continue if filename.endswith(extensions): found_files.append(fpath) return list(sorted(found_files)) def find_docstrings(fpath: str) -> Dict[str, str]: """Find all docstrings in file path. Parameters ---------- fpath : str File path. Returns ------- dict Simplified string as keys and the value is the original docstring found. """ with open(fpath) as fh: data = fh.read() module = ast.parse(data) docstrings = [] function_definitions = _find_func_definitions(module) docstrings.extend([ast.get_docstring(f) for f in function_definitions]) class_definitions = [ node for node in module.body if isinstance(node, ast.ClassDef) ] docstrings.extend([ast.get_docstring(f) for f in class_definitions]) method_definitions = [] for class_def in class_definitions: method_definitions.extend( [ node for node in class_def.body if isinstance(node, ast.FunctionDef) ] ) docstrings.extend([ast.get_docstring(f) for f in method_definitions]) docstrings.append(ast.get_docstring(module)) docstrings = [doc for doc in docstrings if doc] results = {} for doc in docstrings: key = " ".join([it for it in doc.split() if it != ""]) results[key] = doc return results def compress_str(gen): """ This function takes a stream of token and tries to join consecutive strings. This is usefull for long translation string to be broken across many lines. This should support both joined strings without backslashes: trans._( "this" "will" "work" ) Those have NL in between each STING. The following will work as well: trans._( "this"\ "as"\ "well" ) Those are just a sequence of STRING There _might_ be edge cases with quotes, but I'm unsure """ acc, acc_line = [], None for toktype, tokstr, (lineno, _), _, _ in gen: if toktype not in (tokenize.STRING, tokenize.NL): if acc: nt = repr(''.join(acc)) yield tokenize.STRING, nt, acc_line acc, acc_line = [], None yield toktype, tokstr, lineno elif toktype == tokenize.STRING: if tokstr.startswith(("'", '"')): acc.append(eval(tokstr)) else: # b"", f"" ... are Strings acc.append(eval(tokstr[1:])) if not acc_line: acc_line = lineno else: yield toktype, tokstr, lineno if acc: nt = repr(''.join(acc)) yield tokenize.STRING, nt, acc_line def find_strings(fpath: str) -> Dict[Tuple[int, str], Tuple[int, str]]: """Find all strings (and f-strings) for the given file. Parameters ---------- fpath : str File path. Returns ------- dict A dict with a tuple for key and a tuple for value. The tuple contains the line number and the stripped string. The value containes the line number and the original string. """ strings = {} with open(fpath) as f: for toktype, tokstr, lineno in compress_str( tokenize.generate_tokens(f.readline) ): if toktype == tokenize.STRING: try: string = eval(tokstr) except Exception: # noqa BLE001 string = eval(tokstr[1:]) if isinstance(string, str): key = " ".join([it for it in string.split() if it != ""]) strings[(lineno, key)] = (lineno, string) return strings def find_trans_strings( fpath: str, ) -> Tuple[Dict[str, str], List[Tuple[str, Set[str]]]]: """Find all translation strings for the given file. Parameters ---------- fpath : str File path. Returns ------- tuple The first item is a dict with a stripped string as key and the orginal string for value. The second item is a list of tuples that includes errors in translations. """ with open(fpath) as fh: data = fh.read() module = ast.parse(data) trans_strings = {} show_trans_strings.visit(module) for string in show_trans_strings._found: key = " ".join([it for it in string.split()]) trans_strings[key] = string errors = list(show_trans_strings._trans_errors) show_trans_strings.reset() return trans_strings, errors def import_module_by_path(fpath: str) -> Optional[ModuleType]: """Import a module given py a path. Parameters ---------- fpath : str The path to the file to import as module. Returns ------- ModuleType or None The imported module or `None`. """ import importlib.util fpath = fpath.replace("\\", "/") module_name = fpath.replace(".py", "").replace("/", ".") try: spec = importlib.util.spec_from_file_location(module_name, fpath) module = importlib.util.module_from_spec(spec) spec.loader.exec_module(module) except ImportError: module = None return module def find_issues( paths: List[str], skip_words: List[str] ) -> Tuple[StringIssuesDict, OutdatedStringsDict, TranslationErrorsDict]: """Find strings that have not been translated, and errors in translations. This will not raise errors but return a list with found issues wo they can be fixed at once. Parameters ---------- paths : list of str List of paths to files to check. skip_words : list of str List of words that should be skipped inside the given file. Returns ------- tuple The first item is a dictionary of the list of issues found per path. Each issue is a tuple with line number and the untranslated string. The second item is a dictionary of files that contain outdated skipped strings. The third item is a dictionary of the translation errors found per path. Translation errors referes to missing interpolation variables, or spelling errors of the `deferred` keyword. """ issues = {} outdated_strings = {} trans_errors = {} for fpath in paths: issues[fpath] = [] strings = find_strings(fpath) trans_strings, errors = find_trans_strings(fpath) doc_strings = find_docstrings(fpath) skip_words_for_file = skip_words.get(fpath, []) skip_words_for_file_check = skip_words_for_file[:] module = import_module_by_path(fpath) try: __all__strings = module.__all__ except AttributeError: __all__strings = [] for key in strings: _lineno, string = key _lineno, value = strings[key] if ( string not in doc_strings and string not in trans_strings and value not in skip_words_for_file and value not in __all__strings and string != "" and string.strip() != "" and value not in SKIP_WORDS_GLOBAL ): issues[fpath].append((_lineno, value)) elif value in skip_words_for_file_check: skip_words_for_file_check.remove(value) if skip_words_for_file_check: outdated_strings[fpath] = skip_words_for_file_check if errors: trans_errors[fpath] = errors if not issues[fpath]: issues.pop(fpath) return issues, outdated_strings, trans_errors # --- Fixture # ---------------------------------------------------------------------------- def _checks(): paths = find_files(NAPARI_MODULE, SKIP_FOLDERS, SKIP_FILES) issues, outdated_strings, trans_errors = find_issues(paths, SKIP_WORDS) return issues, outdated_strings, trans_errors @pytest.fixture(scope="module") def checks(): return _checks() # --- Tests # ---------------------------------------------------------------------------- def test_missing_translations(checks): issues, _, _ = checks print( "\nSome strings on the following files might need to be translated " "or added to the skip list.\nSkip list is located at " "`tools/strings_list.py` file.\n\n" ) for fpath, values in issues.items(): print(f"{fpath}\n{'*' * len(fpath)}") unique_values = set() for line, value in values: unique_values.add(value) print(f"{line}:\t{repr(value)}") print("\n") if fpath in SKIP_WORDS: print( f"List below can be copied directly to `tools/strings_list.py` file inside the '{fpath}' key:\n" ) for value in sorted(unique_values): print(f" {repr(value)},") else: print( "List below can be copied directly to `tools/strings_list.py` file:\n" ) print(f" {repr(fpath)}: [") for value in sorted(unique_values): print(f" {repr(value)},") print(" ],") print("\n") no_issues = not issues assert no_issues def test_outdated_string_skips(checks): _, outdated_strings, _ = checks print( "\nSome strings on the skip list on the `tools/strings_list.py` are " "outdated.\nPlease remove them from the skip list.\n\n" ) for fpath, values in outdated_strings.items(): print(f"{fpath}\n{'*' * len(fpath)}") print(", ".join(repr(value) for value in values)) print("") no_outdated_strings = not outdated_strings assert no_outdated_strings def test_translation_errors(checks): _, _, trans_errors = checks print( "\nThe following translation strings do not provide some " "interpolation variables:\n\n" ) for fpath, errors in trans_errors.items(): print(f"{fpath}\n{'*' * len(fpath)}") for string, variables in errors: print(f"String:\t\t{repr(string)}") print( f"Variables:\t{', '.join(repr(value) for value in variables)}" ) print("") print("") no_trans_errors = not trans_errors assert no_trans_errors def getch(): fd = sys.stdin.fileno() old_settings = termios.tcgetattr(fd) try: tty.setraw(sys.stdin.fileno()) ch = sys.stdin.read(1) finally: termios.tcsetattr(fd, termios.TCSADRAIN, old_settings) return ch GREEN = "\x1b[1;32m" RED = "\x1b[1;31m" NORMAL = "\x1b[1;0m" if __name__ == '__main__': issues, outdated_strings, trans_errors = _checks() import json import pathlib if len(sys.argv) > 1: edit_cmd = sys.argv[1] else: edit_cmd = None pth = pathlib.Path(__file__).parent / 'string_list.json' data = json.loads(pth.read_text()) for file, items in outdated_strings.items(): for to_remove in set(items): # we don't use set logic to keep the order the same as in the target # files. data['SKIP_WORDS'][file].remove(to_remove) break_ = False for file, missing in issues.items(): code = Path(file).read_text().splitlines() if break_: break for line, text in missing: # skip current item if it has been added to current list # this happens when a new strings is often added many time # in the same file. if text in data['SKIP_WORDS'].get(file, []): continue print() print(f"{RED}{file}:{line}{NORMAL}", GREEN, repr(text), NORMAL) print() for lt in code[line - 3 : line - 1]: print(' ', lt) print('>', code[line - 1].replace(text, GREEN + text + NORMAL)) for lt in code[line : line + 3]: print(' ', lt) print() print( f"{RED}i{NORMAL} : ignore – add to ignored localised strings" ) print(f"{RED}q{NORMAL} : quit – quit w/o saving") print(f"{RED}c{NORMAL} : continue – go to next") if edit_cmd: print(f"{RED}e{NORMAL} : EDIT – using {edit_cmd!r}") else: print( "- : Edit not available, call with python tools/test_strings.py '$COMMAND {filename} {linenumber} '" ) print(f"{RED}s{NORMAL} : save and quit") print('> ', end='') sys.stdout.flush() val = getch() if val == 'e' and edit_cmd: subprocess.run( edit_cmd.format(filename=file, linenumber=line).split(' ') ) if val == 'c': continue if val == 'i': data['SKIP_WORDS'].setdefault(file, []).append(text) elif val == 'q': import sys sys.exit(0) elif val == 's': break_ = True break pth.write_text(json.dumps(data, indent=2, sort_keys=True)) # test_outdated_string_skips(issues, outdated_strings, trans_errors) napari-0.5.0a1/tox.ini000066400000000000000000000102701437041365600145340ustar00rootroot00000000000000# Tox (http://tox.testrun.org/) is a tool for running tests # in multiple virtualenvs. This configuration file will run the # test suite on all supported python versions. To use it, "pip install tox" # and then run "tox" from this directory. # NOTE: if you use conda for environments and an error like this: # "ERROR: InterpreterNotFound: python3.8" # then run `pip install tox-conda` to use conda to build environments [tox] # this is the list of tox "environments" that will run *by default* # when you just run "tox" alone on the command line # non-platform appropriate tests will be skipped # to run a specific test, use the "tox -e" option, for instance: # "tox -e py38-macos-pyqt" will test python3.8 with pyqt on macos # (even if a combination of factors is not in the default envlist # you can run it manually... like py39-linux-pyside2-async) envlist = py{38,39,310}-{linux,macos,windows}-{pyqt5,pyside2,pyqt6,pyside6} isolated_build = true toxworkdir=/tmp/.tox [gh-actions] python = 3.8: py38 3.9: py39 3.9.0: py390 3.10: py310 fail_on_no_env = True # This section turns environment variables from github actions # into tox environment factors. This, combined with the [gh-actions] # section above would mean that a test running python 3.9 on ubuntu-latest # with an environment variable of BACKEND=pyqt would be converted to a # tox env of `py39-linux-pyqt5` [gh-actions:env] PLATFORM = ubuntu-latest: linux ubuntu-16.04: linux ubuntu-18.04: linux ubuntu-20.04: linux windows-latest: windows macos-latest: macos macos-11: macos BACKEND = pyqt5: pyqt5 pyqt6: pyqt6 pyside2: pyside2 pyside6: pyside6 headless: headless # Settings defined in the top-level testenv section are automatically # inherited by individual environments unless overridden. [testenv] platform = macos: darwin linux: linux windows: win32 # These environment variables will be passed from the calling environment # to the tox environment passenv = CI GITHUB_ACTIONS DISPLAY XAUTHORITY NUMPY_EXPERIMENTAL_ARRAY_FUNCTION PYVISTA_OFF_SCREEN MIN_REQ CONDA_EXE CONDA # Set various environment variables, depending on the factors in # the tox environment being run setenv = PYTHONPATH = {toxinidir} async: NAPARI_ASYNC = 1 async: PYTEST_ADDOPTS = --async_only async: PYTEST_PATH = napari deps = pytest-cov pyqt6: PyQt6 pyside6: PySide6 < 6.3.2 ; python_version < '3.10' pyside6: PySide6 ; python_version >= '3.10' pytest-json-report # use extras specified in setup.cfg for certain test envs extras = testing pyqt5: pyqt5 pyside2: pyside2 commands_pre = # strictly only need to uninstall pytest-qt (which will raise without a backend) # the rest is for good measure headless: pip uninstall -y pytest-qt qtpy pyqt5 pyside2 pyside6 pyqt6 commands = !headless: python -m pytest {env:PYTEST_PATH:} --color=yes --basetemp={envtmpdir} \ --cov-report=xml --cov={env:PYTEST_PATH:napari} --ignore tools --maxfail=5 \ --json-report --json-report-file={toxinidir}/report-{envname}.json \ {posargs} # do not add ignores to this line just to make headless tests pass. # nothing outside of _qt or _vispy should require Qt or make_napari_viewer headless: python -m pytest --color=yes --basetemp={envtmpdir} --ignore napari/_vispy \ --ignore napari/_qt --ignore napari/_tests --ignore tools \ --json-report --json-report-file={toxinidir}/report-{envname}.json {posargs} [testenv:py{38,39,310}-{linux,macos,windows}-{pyqt5,pyside2}-examples] commands = python -m pytest napari/_tests/test_examples.py -v --color=yes --basetemp={envtmpdir} {posargs} [testenv:ruff] skip_install = True deps = pre-commit commands = pre-commit run ruff --all-files [testenv:black] skip_install = True deps = pre-commit commands = pre-commit run black --all-files [testenv:import-lint] skip_install = True deps = pre-commit commands = pre-commit run --hook-stage manual import-linter --all-files [testenv:package] isolated_build = true skip_install = true deps = check_manifest wheel twine build commands = check-manifest python -m build python -m twine check dist/*
{keycodes}{keymap[key]}